From a55c8dc4caeb9f26f311048f779541ee5a9f075f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 19:20:09 +0000 Subject: [PATCH 001/226] sdg(liaison): update PHILOSOPHY.md --- specs/PHILOSOPHY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/specs/PHILOSOPHY.md b/specs/PHILOSOPHY.md index 4630f00..2f437fe 100644 --- a/specs/PHILOSOPHY.md +++ b/specs/PHILOSOPHY.md @@ -14,4 +14,5 @@ IMPORTANT: This file may only be edited and interpreted by Liaison. Only Liaison - Operational and infrastructure setup work is not a patch in Developer's eyes: "this is not intended to be a patch, its just a one off set up task" (2026-07-28, correcting the npm-publishing work after triage drafted a Bug Report patch for it). Work whose substance is release/deploy/distribution machinery with zero product-behavior change routes as one-off release/devops execution under DEVOPS.md — not through the patch pipeline — and any patch artifacts created by such a misclassification are retired, not refined. Reserve the patch taxonomy for changes to specified product behavior (IP) or to the harness's ability to catch defects (Bug Report). - Developer prefers tokenless, workload-identity credentials over long-lived stored secrets for automation: asked "can we use OIDC?" (2026-07-28) immediately after receiving the NPM_TOKEN setup checklist, choosing npm Trusted Publishing over a stored automation token. General rule: when a platform offers an OIDC/trusted-publisher path for a credential the process manages, default to it — long-lived secrets are bootstrap-only fallbacks, and Developer checklists should not require creating or rotating a token that workload identity can replace. - xspec's consumption targets include coding-agent cloud environments — Developer asked how best to distribute the CLI into Claude Code web sessions (2026-07-29). Distribution is npm-only: `@modularcloud/xspec` on the public registry is the sole artifact channel; `vX.Y.Z` tags are release records and GitHub Releases carry no distribution artifacts. Consumption guidance (Liaison recommendation accepted as working default, 2026-07-29): per-repo devDependency + `npx xspec` preferred (rides the environment's normal dependency install, lockfile-pinned); `npx -y @modularcloud/xspec` for ad-hoc use; global install only where a bare `xspec` on PATH is explicitly wanted, via the environment's session-setup mechanism. A dependency-free compiled binary distributed via GitHub Releases would be new work, warranted only if a no-Node target ever matters. +- The xspec product boundary stays headless (2026-07-31): Developer plans an interactive UI on top of xspec — editing specs, visualizing requirement dependencies, seeing the nested structure inline with the MDX, jumping between references — but the UI itself is expected to live outside the xspec product ("won't necessarily be a part of the xspec spec itself"). xspec's role is to expose the foundational, machine-consumable APIs such an interface needs. When scoping UI-adjacent work: programmatic/observability surfaces belong in the product spec; rendering, editing chrome, and interaction design belong outside it. Developer routed this as a patch and asked the process to recommend the concrete changes — an open-ended seed that requests recommendations is a valid seed; the 2026-07-09 near-complete-draft pattern is Developer's habit, not a requirement. - Refinement loops that plateau are closed by valve ruling, not run to a spontaneous clean round (first applied 2026-07-10, TEST-SPEC.md at iteration 12 of the xspec initial build). Plateau markers: each fresh review yields only one or two genuine but ever-narrower findings, nothing is re-litigated or reversed, and the upstream documents are already converged. Closure shape: one final iteration whose Driver applies what is necessary and then HALTs, with escape hatches for blocking upstream problems or an indefensible late discovery; residual gaps are deliberately left to the downstream problems-file net, which finds them with implementation eyes when they actually matter. Basis: Developer's revealed preference for bounded forward progress over open-ended polishing (bare "continue" nudges, cost sensitivity shown by the 2026-07-09 credits outage, full delegation of process judgment). From e85f836de3b683c74ea17d9848b6f8147650eec1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 19:22:03 +0000 Subject: [PATCH 002/226] =?UTF-8?q?sdg(liaison):=20write=20SEED.md=20?= =?UTF-8?q?=E2=80=94=20foundational=20APIs=20for=20an=20external=20spec=20?= =?UTF-8?q?UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/tmp/SEED.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 specs/tmp/SEED.md diff --git a/specs/tmp/SEED.md b/specs/tmp/SEED.md new file mode 100644 index 0000000..6a19351 --- /dev/null +++ b/specs/tmp/SEED.md @@ -0,0 +1,12 @@ +# Seed — Foundational APIs for an external spec UI + +Developer message (2026-07-31), verbatim: + +> I want to create a UI for xspec. The idea is that you can edit specs and visualize their dependencies, see the nested structure inline with the MDX and jump between references etc. This won't necessarily be a part of the xspec spec itself but xspec needs to have the foundational apis to connect to this interface. what changes do you recommend to put in a patch in order to work toward this goal? + +## Scope + +- Developer plans an interactive UI on top of xspec: editing specs, visualizing requirement dependencies, seeing the nested structure inline with the MDX, and jumping between references. +- The UI itself is expected to live outside the xspec product boundary — xspec stays headless. Building the UI is not part of this work. +- The work: xspec gains the foundational, machine-consumable surfaces that such an external interface needs to connect to it — the data behind dependency visualization, nested structure, and reference navigation, and whatever the product must expose for an external editor to work against it safely — as those needs map onto xspec's existing behavior. +- This is an open-ended, recommendation-seeking seed: Developer asks the process to determine and propose the concrete set of changes as a patch, coming back to Developer with questions wherever the right call depends on Developer intent. From 2cd687e52a11d8fee9573a29e1f47786bc760b7d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 19:33:18 +0000 Subject: [PATCH 003/226] sdg(phase-2): draft IP 0001-external-ui-apis (intent question pending) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 111 +++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 specs/patches/0001-external-ui-apis.md diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md new file mode 100644 index 0000000..7f5f7f5 --- /dev/null +++ b/specs/patches/0001-external-ui-apis.md @@ -0,0 +1,111 @@ +# 0001 — Foundational machine surfaces for an external spec UI + +- **Type:** Improvement Proposal (IP) +- **Stage:** Proposed +- **Branch:** `claude/xspec-ui-apis-4df8fa` (harness-designated for this session; stands in for `patch/external-ui-apis`) + +## Motivation + +Developer plans an interactive UI on top of xspec: editing spec documents, visualizing requirement dependencies, seeing the nested requirement structure inline with the MDX text, and jumping between references. The UI itself lives outside the xspec product boundary — xspec stays headless — but xspec must expose the machine-consumable surfaces such an external interface needs to connect to it safely. + +xspec's existing machine surface (`query`, universal `--json`, byte-deterministic output, requirement source ranges) covers set-level graph access well. It does not cover what an interactive editor additionally needs: exact source positions for every reference occurrence and for code, a single structural view of a document that maps onto its raw text, machine-readable knowledge of which files xspec owns, diagnostics precise enough to render inline, previews of identity-changing operations, and a way for an external tool to detect interface compatibility. This proposal adds those foundations. + +Archival — Developer message (2026-07-31), verbatim: + +> I want to create a UI for xspec. The idea is that you can edit specs and visualize their dependencies, see the nested structure inline with the MDX and jump between references etc. This won't necessarily be a part of the xspec spec itself but xspec needs to have the foundational apis to connect to this interface. what changes do you recommend to put in a patch in order to work toward this goal? + +## Scope + +The UI's needs map to product capabilities as follows: + +1. **Dependency visualization** — complete graph data. Largely present (`query nodes`, `query edges`, hashes, impact categories); gap: code-location endpoints are not locatable in their files. +2. **Nested structure inline with the MDX** — per-document structural data tied to exact byte positions in the source text. Partially present (per-node source ranges); gap: no single document view, and no positions for the constructs inside a node's text (imports, embeddings, dependency references). +3. **Jumping between references** — per-occurrence positions for every reference, in spec sources and TypeScript sources, navigable in both directions (occurrence → target, node → incoming occurrences). Absent: edges collapse to sets with no occurrence positions, and code locations carry no source range. +4. **Safe external editing** — the UI edits source text; xspec supplies the safety net: machine-readable validation with precise positions, previews of `rename`/`move`, and a machine-readable inventory of which files are sources, derived, or durable. Partially present. + +## Non-goals + +Confirmed with Developer at triage: + +- **No UI ships with xspec.** xspec remains headless; the complete interface remains the CLI, configuration, source syntax, generated modules, and workspace files (per GOALS). +- **No long-running service, watch, or push surface.** The connection point is the one-shot CLI: outputs are deterministic and reads are safe to run concurrently, so the UI re-invokes and re-queries as needed. A live surface, if the UI turns out to need one, is a separate future proposal (it would also touch the GOALS interface statement). +- **No structured content-mutation commands.** The UI owns text editing. xspec's only source-rewriting operations remain `rename` and `move` (extended here with previews); commands like "add dependency" or "set tags" are not added. +- **No analysis of unsaved editor content.** xspec reads the workspace as saved on disk; the UI validates on save. + +## Proposed `SPEC.md` changes + +The following describes the behaviors `SPEC.md` is to define, at the rigor `SPEC.md` requires (implementation-agnostic, blackbox-testable, deterministic, edge cases handled). Exact command and flag names, JSON field naming, and section placement are settled during spec refinement; the information contracts below are the requirement. + +### 1. Reference occurrences + +Introduce the concept of a **reference occurrence**: one textual spelling that records a dependency-kind edge — a `d` reference (each entry of a `d` array separately), an MDX `{text(...)}` embedding, a TypeScript `text(...)` call, or a TypeScript dependency marker. Each occurrence carries: the referencing file, its source range (byte offsets, per the existing range convention), its edge kind, its source graph node (requirement node or code location), and its resolved target's identity. + +- Edges remain sets; occurrences are the positions behind them. Duplicate references that collapse to a single edge each remain distinct occurrences. +- A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included). +- Constructs that record no edge produce no occurrence (unused import bindings, type-only bindings, shadowed identifiers, dynamic references — which are validation errors anyway). +- Occurrence data is unavailable for a file masked as unparseable, consistent with existing masking behavior. +- Ordering is deterministic: by file path (byte order), then by range start, then by range end, with a stated tiebreak for identical ranges. + +### 2. Source ranges for code + +Amend the source-range concept (currently: "code locations carry no source range"): + +- Every code location gains a source range: for a named code unit, the construct's own characters (analogous to a section's construct range); for a whole-file location, the entire file (analogous to a root node). Document-order-disambiguated units (`path#unit@N`) each carry their own occurrence's range. +- Everywhere the specification outputs a graph node with a source range, a code location now qualifies — in particular in occurrence enumeration (change 1), `query` results that return code locations, and review payloads that present code-location scopes. +- TypeScript reference occurrences (markers, `text(...)` calls) carry the range of the referencing expression itself, distinct from the enclosing unit's range. + +### 3. Whole-document structural view + +A query surface returns, for one spec source file, everything needed to overlay structure on the raw MDX bytes in a single call: + +- the root node and the full section tree in document order — each node with identity, source range, tags, coverage attribute, and (on request) own and subtree text; +- every spec-module import declaration with its source range, binding name, and resolved target file; +- every reference occurrence in the file (change 1), positioned in document order; +- enough per-construct positional data that an external tool can resolve any byte position in the file to the innermost enclosing section and, when the position lies within a reference occurrence, to that occurrence and its target — without re-parsing the MDX. Whether position resolution is additionally offered as its own query (file + offset in, node/occurrence out) is a refinement decision; the resolution outcome itself is required. + +The view is defined for a discovered, parseable spec source; unknown files are usage errors and unparseable files report their validation errors, consistent with existing conventions. + +### 4. Workspace inventory + +A query surface reports the machine-readable shape of the workspace, so an external editor never edits files xspec owns and never misses files xspec reads: + +- the workspace root and the configuration file's path (workspace-relative, per existing path conventions); +- the resolved configuration view: spec and code groups with their glob lists and kinds, Markdown emission state and destinations, coverage profile names and definitions, policy rule names; +- every discovered source file with its group memberships; +- the derived-file map: per source file, the generated module and companion paths and the Markdown emit destination (when enabled), plus any other recorded derived paths; +- the durable files: the journal path and existing review-session files. + +The inventory contains no environment-dependent content and no absolute paths, consistent with existing determinism and security conventions. + +### 5. Structured diagnostics + +Sharpen the validation-error contract so an external tool can render findings inline: + +- Every reported error condition carries a stable machine-readable code identifying which numbered condition of the validation-errors section it is. +- Every error that locates inside a source file carries the file and a source range (byte offsets) for the offending construct, at the precision the condition allows; conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. +- The JSON report form presents these fields for every finding, preserving the existing requirements that all conditions are reported together and that JSON carries the same information as the human report. + +### 6. Refactoring previews + +`rename` and `move` gain a preview mode that performs the full validation and planning of the real operation and reports, without modifying anything: + +- the complete identity mapping the operation would journal; +- every file the operation would rewrite, with the occurrences (ranges in current, pre-operation coordinates) it would rewrite in each, including import additions and removals in the move case and, in the file-move case, the file relocation itself; +- the derived files that would be regenerated as a consequence. + +A preview succeeds exactly when the real operation would proceed and is refused exactly when — and reporting what — the real operation would refuse, with the same exit-code classification. A preview writes nothing (no sources, no journal, no derived files, no graph data) and is therefore a non-mutating command under the concurrency rules, safe to run while readers run. Preview output is byte-deterministic. + +### 7. Machine-interface identification + +- A surface reports the product's version and a machine-interface version in JSON, so an external tool can detect compatibility before relying on output shapes. Output remains deterministic for a given product build. +- The specification states that the JSON document shapes of the machine-facing surfaces are part of the product's contract: shape changes are product behavior changes, not free implementation detail. + +## Existing surfaces relied on, unchanged + +Dependency visualization and change overlays already rest on: `query node`/`nodes`/`edges`/`subtree`/`ancestors`/`reachable`; `ids --tree`; `show`; the four hashes; `impact --json` (change categories, impacted code, witness paths); `coverage --json`; `review … --json` self-contained payloads; universal `--json` and exit-code conventions; write atomicity, mutating-command exclusivity, and read-time graph refresh. This proposal adds to that surface; it removes or alters none of it beyond the amendments stated above. + +## Compatibility and rigor notes + +- All additions obey the existing global conventions: single-JSON-document output, same-information JSON, byte-determinism, byte-wise ordering and comparison, workspace-relative paths, the exit-code partition, and configuration-error precedence. +- New surfaces are reads (or, for previews, validated no-op plans); none introduces new durable state, none writes through any new path, and none weakens the security posture of test seams — exposed data is workspace-local content only. +- Range data added for code and occurrences follows the existing byte-offset range convention so consumers handle one range model everywhere. From df0171bd41435d0cb0e8adad98f8beebd58caaf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 15:25:44 +0000 Subject: [PATCH 004/226] sdg(liaison): update PHILOSOPHY.md --- specs/PHILOSOPHY.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/specs/PHILOSOPHY.md b/specs/PHILOSOPHY.md index 2f437fe..4249021 100644 --- a/specs/PHILOSOPHY.md +++ b/specs/PHILOSOPHY.md @@ -15,4 +15,6 @@ IMPORTANT: This file may only be edited and interpreted by Liaison. Only Liaison - Developer prefers tokenless, workload-identity credentials over long-lived stored secrets for automation: asked "can we use OIDC?" (2026-07-28) immediately after receiving the NPM_TOKEN setup checklist, choosing npm Trusted Publishing over a stored automation token. General rule: when a platform offers an OIDC/trusted-publisher path for a credential the process manages, default to it — long-lived secrets are bootstrap-only fallbacks, and Developer checklists should not require creating or rotating a token that workload identity can replace. - xspec's consumption targets include coding-agent cloud environments — Developer asked how best to distribute the CLI into Claude Code web sessions (2026-07-29). Distribution is npm-only: `@modularcloud/xspec` on the public registry is the sole artifact channel; `vX.Y.Z` tags are release records and GitHub Releases carry no distribution artifacts. Consumption guidance (Liaison recommendation accepted as working default, 2026-07-29): per-repo devDependency + `npx xspec` preferred (rides the environment's normal dependency install, lockfile-pinned); `npx -y @modularcloud/xspec` for ad-hoc use; global install only where a bare `xspec` on PATH is explicitly wanted, via the environment's session-setup mechanism. A dependency-free compiled binary distributed via GitHub Releases would be new work, warranted only if a no-Node target ever matters. - The xspec product boundary stays headless (2026-07-31): Developer plans an interactive UI on top of xspec — editing specs, visualizing requirement dependencies, seeing the nested structure inline with the MDX, jumping between references — but the UI itself is expected to live outside the xspec product ("won't necessarily be a part of the xspec spec itself"). xspec's role is to expose the foundational, machine-consumable APIs such an interface needs. When scoping UI-adjacent work: programmatic/observability surfaces belong in the product spec; rendering, editing chrome, and interaction design belong outside it. Developer routed this as a patch and asked the process to recommend the concrete changes — an open-ended seed that requests recommendations is a valid seed; the 2026-07-09 near-complete-draft pattern is Developer's habit, not a requirement. +- UI-adjacent scope rulings, approved 2026-08-03 (single "That sounds great" to the grouped seven-surface proposal for the external-UI patch — the concise grouped-approval pattern again): (1) an external UI connects by invoking the `xspec` CLI per interaction; no persistent service, watch, or push surface without a fresh proposal (one would also touch GOALS' interface statement, an approval-gated edit); (2) the UI owns text editing — xspec supplies positions, structure, validation, and previews, and its only source-rewriting operations remain `rename`/`move`; structured content-mutation commands ("add dependency", "insert section") are deliberately absent; (3) xspec reads only saved files — unsaved-buffer diagnostics are at most a later addition. Treat these as standing defaults for future UI-adjacent scoping, not just this patch. +- modularcloud/cspec (the repo renamed 2026-07-27 to free the xspec name) contains Developer's earlier partial UI — the "cspec editor" — built on an outdated conception of xspec. Developer's standing filter (2026-08-03): it may be mined for individual good ideas ("see if there are any other good ideas that we should take from it") but is never authoritative and "we should not draw from this too much" — never import its architecture, data model, or naming; adopted ideas must stand on their own merits in current-xspec terms. - Refinement loops that plateau are closed by valve ruling, not run to a spontaneous clean round (first applied 2026-07-10, TEST-SPEC.md at iteration 12 of the xspec initial build). Plateau markers: each fresh review yields only one or two genuine but ever-narrower findings, nothing is re-litigated or reversed, and the upstream documents are already converged. Closure shape: one final iteration whose Driver applies what is necessary and then HALTs, with escape hatches for blocking upstream problems or an indefensible late discovery; residual gaps are deliberately left to the downstream problems-file net, which finds them with implementation eyes when they actually matter. Basis: Developer's revealed preference for bounded forward progress over open-ended polishing (bare "continue" nudges, cost sensitivity shown by the 2026-07-09 credits outage, full delegation of process judgment). From 85a9dd29866f0f82c6efc62503e2ebae5cd56b85 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 15:39:54 +0000 Subject: [PATCH 005/226] sdg(phase-2): finalize IP 0001 per Developer-confirmed scope; consume seed Triage: improvement (new machine-consumable surfaces require SPEC.md changes). Developer confirmed CLI-only connection, UI-owned text editing, and saved-files-only analysis; audit candidates folded in on their merits (parse-local availability, tag sub-ranges, multi-file document view, comment ranges, invocation-anchored root, direct position query). SEED.md consumed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 44 ++++++++++++++++++-------- specs/tmp/SEED.md | 12 ------- 2 files changed, 30 insertions(+), 26 deletions(-) delete mode 100644 specs/tmp/SEED.md diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index 7f5f7f5..0b1ec48 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -19,7 +19,7 @@ Archival — Developer message (2026-07-31), verbatim: The UI's needs map to product capabilities as follows: 1. **Dependency visualization** — complete graph data. Largely present (`query nodes`, `query edges`, hashes, impact categories); gap: code-location endpoints are not locatable in their files. -2. **Nested structure inline with the MDX** — per-document structural data tied to exact byte positions in the source text. Partially present (per-node source ranges); gap: no single document view, and no positions for the constructs inside a node's text (imports, embeddings, dependency references). +2. **Nested structure inline with the MDX** — per-document structural data tied to exact byte positions in the source text. Partially present (per-node source ranges); gap: no single document view, no positions for the constructs inside a node's text (imports, embeddings, dependency references, comments), and no decomposition of a section's range into its tags. 3. **Jumping between references** — per-occurrence positions for every reference, in spec sources and TypeScript sources, navigable in both directions (occurrence → target, node → incoming occurrences). Absent: edges collapse to sets with no occurrence positions, and code locations carry no source range. 4. **Safe external editing** — the UI edits source text; xspec supplies the safety net: machine-readable validation with precise positions, previews of `rename`/`move`, and a machine-readable inventory of which files are sources, derived, or durable. Partially present. @@ -42,8 +42,8 @@ Introduce the concept of a **reference occurrence**: one textual spelling that r - Edges remain sets; occurrences are the positions behind them. Duplicate references that collapse to a single edge each remain distinct occurrences. - A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included). -- Constructs that record no edge produce no occurrence (unused import bindings, type-only bindings, shadowed identifiers, dynamic references — which are validation errors anyway). -- Occurrence data is unavailable for a file masked as unparseable, consistent with existing masking behavior. +- Constructs that record no edge produce no occurrence (unused import bindings, type-only bindings, shadowed identifiers, and reference spellings that are dynamic or do not resolve — the invalid ones are located by diagnostics instead, change 6). +- Availability follows change 4: occurrence data is per-file and parse-local; only a masked (unparseable) file loses its occurrences. - Ordering is deterministic: by file path (byte order), then by range start, then by range end, with a stated tiebreak for identical ranges. ### 2. Source ranges for code @@ -56,36 +56,51 @@ Amend the source-range concept (currently: "code locations carry no source range ### 3. Whole-document structural view -A query surface returns, for one spec source file, everything needed to overlay structure on the raw MDX bytes in a single call: +A query surface returns, for a spec source file, everything needed to overlay structure on the raw MDX bytes in a single call: - the root node and the full section tree in document order — each node with identity, source range, tags, coverage attribute, and (on request) own and subtree text; +- for each non-root node, the decomposition of its construct range: the opening tag's range and the closing tag's range (a self-closing section has an opening-tag range only; a root node spans the whole file and has neither). An interactive consumer needs the tags separately from the content they enclose — to render a section header in place of its opening tag, hide or fold what a tag pair encloses, and land navigation on a section's tag rather than selecting its entire construct; - every spec-module import declaration with its source range, binding name, and resolved target file; - every reference occurrence in the file (change 1), positioned in document order; -- enough per-construct positional data that an external tool can resolve any byte position in the file to the innermost enclosing section and, when the position lies within a reference occurrence, to that occurrence and its target — without re-parsing the MDX. Whether position resolution is additionally offered as its own query (file + offset in, node/occurrence out) is a refinement decision; the resolution outcome itself is required. +- every MDX comment's source range. With tags, imports, comments, and embedding occurrences located, every construct that Markdown compilation removes is positioned, so a consumer can classify each byte of the file as annotation or content without re-parsing the MDX; +- position resolution as a direct query: given the file and a byte offset, the innermost enclosing section and, when the offset lies within a reference occurrence, that occurrence and its resolved target. The same resolution must also be derivable from the view's data alone, so both index-building consumers and lightweight ones that keep no client-side index are served. -The view is defined for a discovered, parseable spec source; unknown files are usage errors and unparseable files report their validation errors, consistent with existing conventions. +The view is defined for discovered spec sources: one file, a set restricted by the existing file-glob convention, or all of them — a multi-file request returns per-file views in one deterministic JSON document, so a consumer can index an entire workspace in a single invocation. A file named outside the discovered set is a usage error, per existing conventions. -### 4. Workspace inventory +### 4. Availability on imperfect workspaces + +The structural surfaces of changes 1 and 3 exist to serve an editor while a person is mid-edit — when transiently invalid states (an unknown reference target, a failing file elsewhere in the workspace) are the norm, and exactly when the existing read commands refuse to answer. Their availability is therefore defined per file, from parsing alone, not gated on workspace-wide validity: + +- Structure derived from one file's parse — the section tree, ranges and their decompositions, imports, comments, and reference-occurrence positions — remains available while other files are invalid and while the file itself carries resolution-level findings (unresolved references, cycle participation, and similar). +- Only an unparseable file (or content the existing masking rules already hide) loses its structural view; masking is per file, and the surfaces still answer for every other requested file. +- Data that only workspace-level resolution defines — resolved target identities, expanded own and subtree text, hashes — is reported as explicitly unavailable wherever it is undefined: deterministically, never silently omitted, never fabricated from partial resolution. +- A reference spelling that resolves to no target records no edge and therefore no occurrence; its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid. +- Findings present in the answered files are reported alongside the answer; how an answer-with-findings maps onto the existing exit-code partition is settled during spec refinement. + +Existing commands keep their current all-or-nothing read semantics; this availability contract governs the surfaces this proposal adds. + +### 5. Workspace inventory A query surface reports the machine-readable shape of the workspace, so an external editor never edits files xspec owns and never misses files xspec reads: -- the workspace root and the configuration file's path (workspace-relative, per existing path conventions); +- how the resolved workspace root anchors to the invocation: the workspace root and the configuration file are identified relative to the invocation working directory — invocation input, exactly as existing conventions already treat `--config` resolution — and never as absolute paths. Configuration discovery thereby has one authority: a tool invoking xspec from an arbitrary directory can map the workspace-relative paths in every output to real files without re-implementing the upward search, which is an editing-safety requirement — a consumer that guesses the root wrong edits the wrong files; - the resolved configuration view: spec and code groups with their glob lists and kinds, Markdown emission state and destinations, coverage profile names and definitions, policy rule names; - every discovered source file with its group memberships; - the derived-file map: per source file, the generated module and companion paths and the Markdown emit destination (when enabled), plus any other recorded derived paths; - the durable files: the journal path and existing review-session files. -The inventory contains no environment-dependent content and no absolute paths, consistent with existing determinism and security conventions. +The inventory contains no absolute paths and no environment-dependent content beyond the invocation anchoring above (a function of the invocation, like `--config` resolution — not of the machine), consistent with existing determinism and security conventions. It depends only on configuration and discovery, so it remains available whatever the sources' validity; configuration errors keep their existing precedence. -### 5. Structured diagnostics +### 6. Structured diagnostics Sharpen the validation-error contract so an external tool can render findings inline: - Every reported error condition carries a stable machine-readable code identifying which numbered condition of the validation-errors section it is. - Every error that locates inside a source file carries the file and a source range (byte offsets) for the offending construct, at the precision the condition allows; conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. - The JSON report form presents these fields for every finding, preserving the existing requirements that all conditions are reported together and that JSON carries the same information as the human report. +- Diagnostics are the locating surface for constructs that record nothing in the graph: an invalid, dynamic, or unresolved reference spelling has no occurrence (changes 1, 4), so its range reaches consumers here. -### 6. Refactoring previews +### 7. Refactoring previews `rename` and `move` gain a preview mode that performs the full validation and planning of the real operation and reports, without modifying anything: @@ -95,7 +110,7 @@ Sharpen the validation-error contract so an external tool can render findings in A preview succeeds exactly when the real operation would proceed and is refused exactly when — and reporting what — the real operation would refuse, with the same exit-code classification. A preview writes nothing (no sources, no journal, no derived files, no graph data) and is therefore a non-mutating command under the concurrency rules, safe to run while readers run. Preview output is byte-deterministic. -### 7. Machine-interface identification +### 8. Machine-interface identification - A surface reports the product's version and a machine-interface version in JSON, so an external tool can detect compatibility before relying on output shapes. Output remains deterministic for a given product build. - The specification states that the JSON document shapes of the machine-facing surfaces are part of the product's contract: shape changes are product behavior changes, not free implementation detail. @@ -106,6 +121,7 @@ Dependency visualization and change overlays already rest on: `query node`/`node ## Compatibility and rigor notes -- All additions obey the existing global conventions: single-JSON-document output, same-information JSON, byte-determinism, byte-wise ordering and comparison, workspace-relative paths, the exit-code partition, and configuration-error precedence. +- All additions obey the existing global conventions: single-JSON-document output, same-information JSON, byte-determinism, byte-wise ordering and comparison, workspace-relative paths (change 5's invocation anchoring is the one stated exception, itself deterministic per invocation), the exit-code partition, and configuration-error precedence. +- The availability contract (change 4) is a deliberate, surface-scoped delta from the all-or-nothing read refusal of the existing commands, which keep their semantics unchanged; its refinement must stay deterministic and free of partial-resolution fabrication. - New surfaces are reads (or, for previews, validated no-op plans); none introduces new durable state, none writes through any new path, and none weakens the security posture of test seams — exposed data is workspace-local content only. -- Range data added for code and occurrences follows the existing byte-offset range convention so consumers handle one range model everywhere. +- Range data added for code, occurrences, tag decompositions, and comments follows the existing byte-offset range convention so consumers handle one range model everywhere. diff --git a/specs/tmp/SEED.md b/specs/tmp/SEED.md deleted file mode 100644 index 6a19351..0000000 --- a/specs/tmp/SEED.md +++ /dev/null @@ -1,12 +0,0 @@ -# Seed — Foundational APIs for an external spec UI - -Developer message (2026-07-31), verbatim: - -> I want to create a UI for xspec. The idea is that you can edit specs and visualize their dependencies, see the nested structure inline with the MDX and jump between references etc. This won't necessarily be a part of the xspec spec itself but xspec needs to have the foundational apis to connect to this interface. what changes do you recommend to put in a patch in order to work toward this goal? - -## Scope - -- Developer plans an interactive UI on top of xspec: editing specs, visualizing requirement dependencies, seeing the nested structure inline with the MDX, and jumping between references. -- The UI itself is expected to live outside the xspec product boundary — xspec stays headless. Building the UI is not part of this work. -- The work: xspec gains the foundational, machine-consumable surfaces that such an external interface needs to connect to it — the data behind dependency visualization, nested structure, and reference navigation, and whatever the product must expose for an external editor to work against it safely — as those needs map onto xspec's existing behavior. -- This is an open-ended, recommendation-seeking seed: Developer asks the process to determine and propose the concrete set of changes as a patch, coming back to Developer with questions wherever the right call depends on Developer intent. From 9b173b410dd5a59fffd96f14be0fd4546db6d89f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 15:59:18 +0000 Subject: [PATCH 006/226] =?UTF-8?q?sdg(phase-3):=20apply=20review=20round?= =?UTF-8?q?=201=20to=20IP=200001=20=E2=80=94=20imperfect-workspace=20model?= =?UTF-8?q?,=20exit=20mapping,=20refresh,=20inventory=20provenance,=20prev?= =?UTF-8?q?iew=20edits,=20interface=20versioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: I1 (identity/interpreted-data model for parseable-but-invalid files; invalid imports listed with unavailable targets), I2 (unavailable-data list corrected — occurrence targets never unavailable, source identities and import targets can be; parse-local claim scoped to positions/spellings), I3 (exit mapping stated: findings or unavailable data in the answer -> 1, clean complete answer -> 0, usage/config errors keep 2; answer always emitted), I4 (new query surfaces join read-time refresh on valid workspaces, modify nothing on imperfect ones; inventory never refreshes or writes), I5 (inventory availability restated as parse-independence; content provenance split into invocation/config/discovery, recorded generation state, filesystem), I6 (preview edit classes enumerated: occurrence rewrites, id-attribute rewrites, import specifier/addition/removal edits, section-move deletion/insertion/ self-closing rewrite, file relocation; derived-file removals added), I7 (machine-interface version value stated in SPEC.md, surface reports exactly it — per-build observable; contract scope defined), O1 (per-unit ranges for multi-unit declarations), O2 (identical-range tiebreak removed as unreachable), O3 (byte-classification claim scoped; position-resolution totality and EOF/ beyond-EOF rules), O4 (policy rules reported at coverage-profile depth), O5 (cross-drive anchoring rule). Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 40 ++++++++++++++------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index 0b1ec48..2ff6c75 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -43,14 +43,14 @@ Introduce the concept of a **reference occurrence**: one textual spelling that r - Edges remain sets; occurrences are the positions behind them. Duplicate references that collapse to a single edge each remain distinct occurrences. - A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included). - Constructs that record no edge produce no occurrence (unused import bindings, type-only bindings, shadowed identifiers, and reference spellings that are dynamic or do not resolve — the invalid ones are located by diagnostics instead, change 6). -- Availability follows change 4: occurrence data is per-file and parse-local; only a masked (unparseable) file loses its occurrences. -- Ordering is deterministic: by file path (byte order), then by range start, then by range end, with a stated tiebreak for identical ranges. +- Availability follows change 4: occurrence positions and spellings are per-file, parse-local data — only a masked (unparseable) file loses them — while occurrence existence is resolution-dependent: a spelling that does not resolve to exactly one target records no edge and no occurrence. An occurrence inside a section whose identity change 4 leaves undefined keeps its position, kind, and resolved target, with its source-node identity explicitly unavailable. +- Ordering is deterministic: by file path (byte order), then by range start, then by range end. Distinct occurrences are distinct spellings occupying distinct spans, so identical ranges do not occur and this order is total; no further tiebreak exists. ### 2. Source ranges for code Amend the source-range concept (currently: "code locations carry no source range"): -- Every code location gains a source range: for a named code unit, the construct's own characters (analogous to a section's construct range); for a whole-file location, the entire file (analogous to a root node). Document-order-disambiguated units (`path#unit@N`) each carry their own occurrence's range. +- Every code location gains a source range: for a named code unit, the construct's own characters (analogous to a section's construct range); for a whole-file location, the entire file (analogous to a root node). Document-order-disambiguated units (`path#unit@N`) each carry their own occurrence's range. Where one declaration derives several named units, each unit's range is the construct that binds its own name: a function- or class-valued variable declaration's range spans its own name through its initializer, not the enclosing multi-declaration statement, while the nested units a dotted namespace name derives share the single namespace declaration's range, which is the one construct binding them all. - Everywhere the specification outputs a graph node with a source range, a code location now qualifies — in particular in occurrence enumeration (change 1), `query` results that return code locations, and review payloads that present code-location scopes. - TypeScript reference occurrences (markers, `text(...)` calls) carry the range of the referencing expression itself, distinct from the enclosing unit's range. @@ -58,12 +58,12 @@ Amend the source-range concept (currently: "code locations carry no source range A query surface returns, for a spec source file, everything needed to overlay structure on the raw MDX bytes in a single call: -- the root node and the full section tree in document order — each node with identity, source range, tags, coverage attribute, and (on request) own and subtree text; +- the root node and the full section tree in document order. The tree is positional — defined by construct nesting alone — so it exists for every parseable file whatever findings the file carries (change 4). Each node carries its source range, its raw attribute spellings as parsed, and — each where defined, explicitly unavailable otherwise (change 4) — its node identity, tags, coverage attribute, and (on request) own and subtree text; - for each non-root node, the decomposition of its construct range: the opening tag's range and the closing tag's range (a self-closing section has an opening-tag range only; a root node spans the whole file and has neither). An interactive consumer needs the tags separately from the content they enclose — to render a section header in place of its opening tag, hide or fold what a tag pair encloses, and land navigation on a section's tag rather than selecting its entire construct; -- every spec-module import declaration with its source range, binding name, and resolved target file; +- every import declaration, valid or invalid, with its source range, its binding name where one is bound, and its resolved target file where specifier form and discovery define one — explicitly unavailable otherwise (change 4), with the invalidity itself a located finding of change 6; - every reference occurrence in the file (change 1), positioned in document order; -- every MDX comment's source range. With tags, imports, comments, and embedding occurrences located, every construct that Markdown compilation removes is positioned, so a consumer can classify each byte of the file as annotation or content without re-parsing the MDX; -- position resolution as a direct query: given the file and a byte offset, the innermost enclosing section and, when the offset lies within a reference occurrence, that occurrence and its resolved target. The same resolution must also be derivable from the view's data alone, so both index-building consumers and lightweight ones that keep no client-side index are served. +- every MDX comment's source range. With tags, imports, comments, and embedding occurrences located, every construct that Markdown compilation removes is positioned, so on a finding-free file a consumer can classify each byte as annotation or content without re-parsing the MDX. On an imperfect file the classification is joint with change 6: spellings and constructs that produce no occurrence or view entry (change 4) are located by their findings' ranges, and the two surfaces together still position every removable construct; +- position resolution as a direct query: given the file and a byte offset, the innermost enclosing section and, when the offset lies within a reference occurrence, that occurrence and its resolved target. Resolution is by range containment and is total over the file: every within-file offset lies in the root's range, so bytes inside imports, comments, and content between sections resolve to the innermost section construct containing them — the root when none does; the offset equal to the file length (the caret position at end of file) resolves to the root; a greater offset is a usage error, per existing conventions. The same resolution must also be derivable from the view's data alone, so both index-building consumers and lightweight ones that keep no client-side index are served. The view is defined for discovered spec sources: one file, a set restricted by the existing file-glob convention, or all of them — a multi-file request returns per-file views in one deterministic JSON document, so a consumer can index an entire workspace in a single invocation. A file named outside the discovered set is a usage error, per existing conventions. @@ -71,25 +71,28 @@ The view is defined for discovered spec sources: one file, a set restricted by t The structural surfaces of changes 1 and 3 exist to serve an editor while a person is mid-edit — when transiently invalid states (an unknown reference target, a failing file elsewhere in the workspace) are the norm, and exactly when the existing read commands refuse to answer. Their availability is therefore defined per file, from parsing alone, not gated on workspace-wide validity: -- Structure derived from one file's parse — the section tree, ranges and their decompositions, imports, comments, and reference-occurrence positions — remains available while other files are invalid and while the file itself carries resolution-level findings (unresolved references, cycle participation, and similar). +- Structure derived from one file's parse — the positional section tree (change 3), every construct's ranges and their decompositions, raw attribute and import spellings, comments, and reference-occurrence positions — remains available while other files are invalid and while the file itself carries findings of either level: resolution-level (unresolved references, cycle participation, and similar) and per-file structural (sections with missing, duplicate, or structurally invalid IDs; malformed segments; invalid props; invalid constructs) alike. - Only an unparseable file (or content the existing masking rules already hide) loses its structural view; masking is per file, and the surfaces still answer for every other requested file. -- Data that only workspace-level resolution defines — resolved target identities, expanded own and subtree text, hashes — is reported as explicitly unavailable wherever it is undefined: deterministically, never silently omitted, never fabricated from partial resolution. -- A reference spelling that resolves to no target records no edge and therefore no occurrence; its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid. -- Findings present in the answered files are reported alongside the answer; how an answer-with-findings maps onto the existing exit-code partition is settled during spec refinement. +- What findings make undefined is interpreted data, never structure. A section's node identity is defined exactly when its own `id` and each enclosing section's `id` are present and well-formed, each satisfies the structural-ID rules, and no other section of the file spells the same identity — duplicate spellings leave every bearer's identity undefined; no winner is picked. A section's interpreted tags and coverage attribute are defined exactly when its parsed props define them unambiguously (a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined; the raw spelling is still reported). A section whose identity is undefined still occupies its tree position with its ranges and raw spellings, and the occurrences inside it keep their positions, kinds, and resolved targets, with their source-node identity undefined. Invalid constructs outside change 3's inventory (stray elements, expression containers, exports) get no view entry; their findings' ranges locate them (change 6). +- Data these rules or workspace-level resolution leave undefined — section and occurrence-source identities as above, an import's resolved target when specifier form or discovery defines none, expanded own and subtree text, hashes — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. +- A reference occurrence, by contrast, never reports an unavailable target: a spelling that does not resolve to exactly one target — an unknown target, or an ambiguous one whose candidates' own identities are undefined — records no edge and therefore no occurrence, and its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid. +- Findings present in the answered files are reported alongside the answer, and the mapping onto the existing exit-code partition is: an invocation whose answer reports any finding or any explicitly-unavailable datum exits 1; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their existing precedence. The full answer document is emitted in the 0 and 1 cases alike — exit 1 signals imperfection and never withholds the answer. -Existing commands keep their current all-or-nothing read semantics; this availability contract governs the surfaces this proposal adds. +Existing commands keep their current all-or-nothing read semantics; this availability contract governs the surfaces this proposal adds. Their relationship to stored graph data follows the same line: the query surfaces of changes 1 and 3 never answer from stale data — on a workspace that passes build validation they participate in read-time refresh through the existing path, exactly as the existing read commands do, and on a workspace that does not, their answers reflect the current sources and they modify nothing: no graph data, no derived files — just as a failed refresh modifies nothing today. The inventory of change 5 states its own relationship to stored state. ### 5. Workspace inventory A query surface reports the machine-readable shape of the workspace, so an external editor never edits files xspec owns and never misses files xspec reads: - how the resolved workspace root anchors to the invocation: the workspace root and the configuration file are identified relative to the invocation working directory — invocation input, exactly as existing conventions already treat `--config` resolution — and never as absolute paths. Configuration discovery thereby has one authority: a tool invoking xspec from an arbitrary directory can map the workspace-relative paths in every output to real files without re-implementing the upward search, which is an editing-safety requirement — a consumer that guesses the root wrong edits the wrong files; -- the resolved configuration view: spec and code groups with their glob lists and kinds, Markdown emission state and destinations, coverage profile names and definitions, policy rule names; +- the resolved configuration view: spec and code groups with their glob lists and kinds, Markdown emission state and destinations, and coverage profiles and policy rules, each name with its full definition — the two reported at the same depth; - every discovered source file with its group memberships; - the derived-file map: per source file, the generated module and companion paths and the Markdown emit destination (when enabled), plus any other recorded derived paths; - the durable files: the journal path and existing review-session files. -The inventory contains no absolute paths and no environment-dependent content beyond the invocation anchoring above (a function of the invocation, like `--config` resolution — not of the machine), consistent with existing determinism and security conventions. It depends only on configuration and discovery, so it remains available whatever the sources' validity; configuration errors keep their existing precedence. +The inventory contains no absolute paths and no environment-dependent content beyond the invocation anchoring above (a function of the invocation, like `--config` resolution — not of the machine), consistent with existing determinism and security conventions. When the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives), the anchoring is reported in the platform's absolute form — the one further case of the stated exception, still a pure function of invocation input. + +Availability is unconditional: no part of the inventory requires parsing sources, so it answers whatever the sources' validity; configuration errors keep their existing precedence. Its content has three provenances, each reported as what it is: invocation, configuration, and discovery determine the anchoring, the configuration view, the discovered sources with their groups, and the per-source generated-module and Markdown-emit-destination paths (the destinations exist exactly while emission is enabled, per existing rules); recorded generation state supplies the remaining derived-file map entries — companion paths and any other recorded derived paths — reported as recorded, which can lag configuration until a rebuild and is empty before any generation has run; the filesystem supplies the durable entries — the journal path is fixed, and the review-session files are those present. The inventory reports recorded and durable state as it stands and never refreshes or writes anything. ### 6. Structured diagnostics @@ -105,15 +108,16 @@ Sharpen the validation-error contract so an external tool can render findings in `rename` and `move` gain a preview mode that performs the full validation and planning of the real operation and reports, without modifying anything: - the complete identity mapping the operation would journal; -- every file the operation would rewrite, with the occurrences (ranges in current, pre-operation coordinates) it would rewrite in each, including import additions and removals in the move case and, in the file-move case, the file relocation itself; -- the derived files that would be regenerated as a consequence. +- every file the operation would rewrite or relocate, with every edit the operation would make in it — each located by a range in current, pre-operation coordinates and classed by what it is. The classes cover everything the operations edit, not only reference occurrences: reference-occurrence rewrites (change 1's occurrences — `d` references, `text(...)` references, TypeScript markers); `id`-attribute rewrites (rename's and the section move's re-identification); import edits — specifier rewrites, import additions, and import removals; the section move's origin deletion, its target insertion point, and the self-closing-target-parent rewrite when one applies; and the file move's relocation of the file itself; +- the derived-file consequences, in both directions: the derived files that would be regenerated and the recorded derived files that would be removed as no longer generated — the old module path after a file move included. A preview succeeds exactly when the real operation would proceed and is refused exactly when — and reporting what — the real operation would refuse, with the same exit-code classification. A preview writes nothing (no sources, no journal, no derived files, no graph data) and is therefore a non-mutating command under the concurrency rules, safe to run while readers run. Preview output is byte-deterministic. ### 8. Machine-interface identification -- A surface reports the product's version and a machine-interface version in JSON, so an external tool can detect compatibility before relying on output shapes. Output remains deterministic for a given product build. -- The specification states that the JSON document shapes of the machine-facing surfaces are part of the product's contract: shape changes are product behavior changes, not free implementation detail. +- A surface reports the product's version and a machine-interface version in JSON. Output remains deterministic for a given product build: both values are fixed per build, and the product version identifies the build. +- The machine-interface version's current value is stated in `SPEC.md` itself, and the surface reports exactly the stated value — observable against the specification in any single build, with no cross-build comparison needed. +- The stated value names the machine-facing JSON contract `SPEC.md` defines: the JSON output of the product's commands under the existing universal-JSON and same-information conventions, the surfaces this proposal adds included. Because those contracts and the version value live in the same document, a change to the machine-facing JSON contract is by construction a specification change, and the proposal making it updates the stated value in the same change. An external tool detects incompatibility by comparing the reported value with the value its own interface knowledge was built against. ## Existing surfaces relied on, unchanged From 39007807e84d3651148c125564fe3fde4b0a1f8b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 16:17:01 +0000 Subject: [PATCH 007/226] =?UTF-8?q?sdg(phase-3):=20apply=20review=20round?= =?UTF-8?q?=202=20to=20IP=200001=20=E2=80=94=20occurrence=20spans,=20exit-?= =?UTF-8?q?2=20JSON=20channel,=20expansion=20definedness,=20enumeration=20?= =?UTF-8?q?domain,=20identification=20preconditions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: I1, I2, I3, I4, I5, O1, O2, O3, O4, O5. Rejected: none. - I1: change 8 gains a workspace-independence bullet — consults no workspace or configuration, cannot fail for configuration reasons. - I2: resolved via the channel arm (bullet 2 of change 6 already committed the IP to machine-consumable configuration errors): exit-2 errors emit a JSON error document as the entire standard output, amending the empty-stdout rule. - I3: occurrence spans stated per kind; MDX embedding spans the full braced container, which change 3's byte-classification claim now cites. - I4: dropped "hashes" from the unavailable-data list (no surface this proposal adds reports hashes; naming one would add an unrequested capability) and added the exact expansion-definedness rule (every transitively reached embedding records an occurrence, no cycle re-entered). - I5: finding reporting defined over a consulted domain; a target-only enumeration's domain is the whole discovered set, so masked files always surface as findings with exit 1. - O1: joint-location claim scoped to parseable files. - O2: created-target-file previews report the creation as its own class with the insertion point at file start. - O3: inventory's review-session listing is name-based and content-blind, corrupt sessions included. - O4: structured-diagnostics contract extended to rename/move refusal reasons. - O5: product version phrased as informational; testable contract carried by the machine-interface version. - Compatibility note records the two convention amendments (exit-2 JSON form, change-8 precedence exception). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index 2ff6c75..845c43b 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -41,7 +41,8 @@ The following describes the behaviors `SPEC.md` is to define, at the rigor `SPEC Introduce the concept of a **reference occurrence**: one textual spelling that records a dependency-kind edge — a `d` reference (each entry of a `d` array separately), an MDX `{text(...)}` embedding, a TypeScript `text(...)` call, or a TypeScript dependency marker. Each occurrence carries: the referencing file, its source range (byte offsets, per the existing range convention), its edge kind, its source graph node (requirement node or code location), and its resolved target's identity. - Edges remain sets; occurrences are the positions behind them. Duplicate references that collapse to a single edge each remain distinct occurrences. -- A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included). +- Occurrence spans are exact per kind: a `d` reference occurrence spans that one reference's own expression (each entry of an array separately — never the array or the prop); an MDX embedding occurrence spans the entire `{text(...)}` expression container, opening brace through closing brace — the whole construct Markdown compilation replaces, on which change 3's byte classification depends; a TypeScript occurrence spans the referencing expression itself (change 2). +- A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included). Finding reporting and exit mapping cover the enumeration's whole consulted domain, defined in change 4. - Constructs that record no edge produce no occurrence (unused import bindings, type-only bindings, shadowed identifiers, and reference spellings that are dynamic or do not resolve — the invalid ones are located by diagnostics instead, change 6). - Availability follows change 4: occurrence positions and spellings are per-file, parse-local data — only a masked (unparseable) file loses them — while occurrence existence is resolution-dependent: a spelling that does not resolve to exactly one target records no edge and no occurrence. An occurrence inside a section whose identity change 4 leaves undefined keeps its position, kind, and resolved target, with its source-node identity explicitly unavailable. - Ordering is deterministic: by file path (byte order), then by range start, then by range end. Distinct occurrences are distinct spellings occupying distinct spans, so identical ranges do not occur and this order is total; no further tiebreak exists. @@ -62,7 +63,7 @@ A query surface returns, for a spec source file, everything needed to overlay st - for each non-root node, the decomposition of its construct range: the opening tag's range and the closing tag's range (a self-closing section has an opening-tag range only; a root node spans the whole file and has neither). An interactive consumer needs the tags separately from the content they enclose — to render a section header in place of its opening tag, hide or fold what a tag pair encloses, and land navigation on a section's tag rather than selecting its entire construct; - every import declaration, valid or invalid, with its source range, its binding name where one is bound, and its resolved target file where specifier form and discovery define one — explicitly unavailable otherwise (change 4), with the invalidity itself a located finding of change 6; - every reference occurrence in the file (change 1), positioned in document order; -- every MDX comment's source range. With tags, imports, comments, and embedding occurrences located, every construct that Markdown compilation removes is positioned, so on a finding-free file a consumer can classify each byte as annotation or content without re-parsing the MDX. On an imperfect file the classification is joint with change 6: spellings and constructs that produce no occurrence or view entry (change 4) are located by their findings' ranges, and the two surfaces together still position every removable construct; +- every MDX comment's source range. With tags, imports, comments, and embedding occurrences located (an embedding occurrence's span is its full braced container, change 1), every construct that Markdown compilation removes is positioned, so on a finding-free file a consumer can classify each byte as annotation or content without re-parsing the MDX. On an imperfect file the classification is joint with change 6: spellings and constructs that produce no occurrence or view entry (change 4) are located by their findings' ranges, and the two surfaces together still position every removable construct; - position resolution as a direct query: given the file and a byte offset, the innermost enclosing section and, when the offset lies within a reference occurrence, that occurrence and its resolved target. Resolution is by range containment and is total over the file: every within-file offset lies in the root's range, so bytes inside imports, comments, and content between sections resolve to the innermost section construct containing them — the root when none does; the offset equal to the file length (the caret position at end of file) resolves to the root; a greater offset is a usage error, per existing conventions. The same resolution must also be derivable from the view's data alone, so both index-building consumers and lightweight ones that keep no client-side index are served. The view is defined for discovered spec sources: one file, a set restricted by the existing file-glob convention, or all of them — a multi-file request returns per-file views in one deterministic JSON document, so a consumer can index an entire workspace in a single invocation. A file named outside the discovered set is a usage error, per existing conventions. @@ -74,9 +75,9 @@ The structural surfaces of changes 1 and 3 exist to serve an editor while a pers - Structure derived from one file's parse — the positional section tree (change 3), every construct's ranges and their decompositions, raw attribute and import spellings, comments, and reference-occurrence positions — remains available while other files are invalid and while the file itself carries findings of either level: resolution-level (unresolved references, cycle participation, and similar) and per-file structural (sections with missing, duplicate, or structurally invalid IDs; malformed segments; invalid props; invalid constructs) alike. - Only an unparseable file (or content the existing masking rules already hide) loses its structural view; masking is per file, and the surfaces still answer for every other requested file. - What findings make undefined is interpreted data, never structure. A section's node identity is defined exactly when its own `id` and each enclosing section's `id` are present and well-formed, each satisfies the structural-ID rules, and no other section of the file spells the same identity — duplicate spellings leave every bearer's identity undefined; no winner is picked. A section's interpreted tags and coverage attribute are defined exactly when its parsed props define them unambiguously (a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined; the raw spelling is still reported). A section whose identity is undefined still occupies its tree position with its ranges and raw spellings, and the occurrences inside it keep their positions, kinds, and resolved targets, with their source-node identity undefined. Invalid constructs outside change 3's inventory (stray elements, expression containers, exports) get no view entry; their findings' ranges locate them (change 6). -- Data these rules or workspace-level resolution leave undefined — section and occurrence-source identities as above, an import's resolved target when specifier form or discovery defines none, expanded own and subtree text, hashes — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. -- A reference occurrence, by contrast, never reports an unavailable target: a spelling that does not resolve to exactly one target — an unknown target, or an ambiguous one whose candidates' own identities are undefined — records no edge and therefore no occurrence, and its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid. -- Findings present in the answered files are reported alongside the answer, and the mapping onto the existing exit-code partition is: an invocation whose answer reports any finding or any explicitly-unavailable datum exits 1; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their existing precedence. The full answer document is emitted in the 0 and 1 cases alike — exit 1 signals imperfection and never withholds the answer. +- Data these rules or resolution leave undefined — section and occurrence-source identities as above, an import's resolved target when specifier form or discovery defines none, and expanded own and subtree text — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. Expanded text has an exact definedness rule: a node's own (respectively subtree) text is defined exactly when every embedding the expansion transitively reaches — each `text(...)` spelling in the node's own contribution (respectively anywhere in its subtree), and recursively each one inside every embedded target's subtree — records an occurrence (change 1), and the recursion re-enters no node already being expanded (an embedding cycle); one unresolved spelling or one cycle on the expansion path makes the value unavailable as a whole — partial expansion is fabrication and never occurs. +- A reference occurrence, by contrast, never reports an unavailable target: a spelling that does not resolve to exactly one target — an unknown target, or an ambiguous one whose candidates' own identities are undefined — records no edge and therefore no occurrence, and its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid, in every parseable file; spellings inside an unparseable file are hidden with the rest of it, pointed to only by that file's parse-failure finding. +- Every answer has a consulted domain of files, and the findings of every file in that domain are reported alongside the answer — a masked file's parse-failure finding included. The domain is: for the view of change 3, the requested files; for an occurrence enumeration of change 1, every discovered file its file filter admits — and, when no file filter narrows it, the entire discovered set, spec and code sources alike, because a masked file anywhere could conceal occurrences the enumeration would otherwise return. The mapping onto the existing exit-code partition is: an invocation whose answer reports any finding or any explicitly-unavailable datum exits 1; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their existing precedence. A possibly-incomplete answer is therefore never silent: the finding always accompanies it, and the exit code says so. The full answer document is emitted in the 0 and 1 cases alike — exit 1 signals imperfection and never withholds the answer. Existing commands keep their current all-or-nothing read semantics; this availability contract governs the surfaces this proposal adds. Their relationship to stored graph data follows the same line: the query surfaces of changes 1 and 3 never answer from stale data — on a workspace that passes build validation they participate in read-time refresh through the existing path, exactly as the existing read commands do, and on a workspace that does not, their answers reflect the current sources and they modify nothing: no graph data, no derived files — just as a failed refresh modifies nothing today. The inventory of change 5 states its own relationship to stored state. @@ -92,7 +93,7 @@ A query surface reports the machine-readable shape of the workspace, so an exter The inventory contains no absolute paths and no environment-dependent content beyond the invocation anchoring above (a function of the invocation, like `--config` resolution — not of the machine), consistent with existing determinism and security conventions. When the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives), the anchoring is reported in the platform's absolute form — the one further case of the stated exception, still a pure function of invocation input. -Availability is unconditional: no part of the inventory requires parsing sources, so it answers whatever the sources' validity; configuration errors keep their existing precedence. Its content has three provenances, each reported as what it is: invocation, configuration, and discovery determine the anchoring, the configuration view, the discovered sources with their groups, and the per-source generated-module and Markdown-emit-destination paths (the destinations exist exactly while emission is enabled, per existing rules); recorded generation state supplies the remaining derived-file map entries — companion paths and any other recorded derived paths — reported as recorded, which can lag configuration until a rebuild and is empty before any generation has run; the filesystem supplies the durable entries — the journal path is fixed, and the review-session files are those present. The inventory reports recorded and durable state as it stands and never refreshes or writes anything. +Availability is unconditional: no part of the inventory requires parsing sources, so it answers whatever the sources' validity; configuration errors keep their existing precedence. Its content has three provenances, each reported as what it is: invocation, configuration, and discovery determine the anchoring, the configuration view, the discovered sources with their groups, and the per-source generated-module and Markdown-emit-destination paths (the destinations exist exactly while emission is enabled, per existing rules); recorded generation state supplies the remaining derived-file map entries — companion paths and any other recorded derived paths — reported as recorded, which can lag configuration until a rebuild and is empty before any generation has run; the filesystem supplies the durable entries — the journal path is fixed, and the review-session files are those present, selected by name alone: every file directly under the review-session directory whose name is a well-formed session file name is listed, corrupt or unparseable sessions included, since the inventory reads no session content; a file there with any other name is not xspec's and is not listed. The inventory reports recorded and durable state as it stands and never refreshes or writes anything. ### 6. Structured diagnostics @@ -101,6 +102,8 @@ Sharpen the validation-error contract so an external tool can render findings in - Every reported error condition carries a stable machine-readable code identifying which numbered condition of the validation-errors section it is. - Every error that locates inside a source file carries the file and a source range (byte offsets) for the offending construct, at the precision the condition allows; conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. - The JSON report form presents these fields for every finding, preserving the existing requirements that all conditions are reported together and that JSON carries the same information as the human report. +- The same contract covers operation refusals: each distinct reason `rename` and `move` refuse — exactly what the previews of change 7 report — carries a stable machine-readable code and the file, source range, or identity it concerns, so a refusal renders as precisely as a finding. Refusals are findings under the existing exit-code partition, so the JSON report form above already carries them. +- Machine-readable delivery is closed over the outcome classes: under the JSON output convention, an invocation that fails with a usage or configuration error (exit 2) emits a single JSON document as its entire standard output reporting the error — carrying, for conditions with a defined code (configuration errors included), the stable code and the concerned file or path above — amending the existing rule that such an error leaves standard output empty. Exit codes, error precedence, and human-readable standard-error text are unchanged. Without this channel, configuration errors — the one class that precedes and blocks every surface of changes 1, 3, 5, and 7 — would be the one class an external tool cannot consume. - Diagnostics are the locating surface for constructs that record nothing in the graph: an invalid, dynamic, or unresolved reference spelling has no occurrence (changes 1, 4), so its range reaches consumers here. ### 7. Refactoring previews @@ -108,14 +111,15 @@ Sharpen the validation-error contract so an external tool can render findings in `rename` and `move` gain a preview mode that performs the full validation and planning of the real operation and reports, without modifying anything: - the complete identity mapping the operation would journal; -- every file the operation would rewrite or relocate, with every edit the operation would make in it — each located by a range in current, pre-operation coordinates and classed by what it is. The classes cover everything the operations edit, not only reference occurrences: reference-occurrence rewrites (change 1's occurrences — `d` references, `text(...)` references, TypeScript markers); `id`-attribute rewrites (rename's and the section move's re-identification); import edits — specifier rewrites, import additions, and import removals; the section move's origin deletion, its target insertion point, and the self-closing-target-parent rewrite when one applies; and the file move's relocation of the file itself; +- every file the operation would rewrite or relocate, with every edit the operation would make in it — each located by a range in current, pre-operation coordinates and classed by what it is. The classes cover everything the operations edit, not only reference occurrences: reference-occurrence rewrites (change 1's occurrences — `d` references, `text(...)` references, TypeScript markers); `id`-attribute rewrites (rename's and the section move's re-identification); import edits — specifier rewrites, import additions, and import removals; the section move's origin deletion, its target insertion point, and the self-closing-target-parent rewrite when one applies; and the file move's relocation of the file itself. A section move whose target file does not yet exist reports that file's creation as its own class, with the insertion point at the start of the new file — the one reported location without pre-operation coordinates; every range in a file that exists stays in current, pre-operation coordinates; - the derived-file consequences, in both directions: the derived files that would be regenerated and the recorded derived files that would be removed as no longer generated — the old module path after a file move included. A preview succeeds exactly when the real operation would proceed and is refused exactly when — and reporting what — the real operation would refuse, with the same exit-code classification. A preview writes nothing (no sources, no journal, no derived files, no graph data) and is therefore a non-mutating command under the concurrency rules, safe to run while readers run. Preview output is byte-deterministic. ### 8. Machine-interface identification -- A surface reports the product's version and a machine-interface version in JSON. Output remains deterministic for a given product build: both values are fixed per build, and the product version identifies the build. +- A surface reports the product's version and a machine-interface version in JSON. Output remains deterministic for a given product build: both values are fixed per build. The product version is informational — reported for display and support, with no requirement beyond per-build fixedness; the testable contract is carried by the machine-interface version below. +- The surface is workspace-independent: it consults no workspace and no configuration, answers identically in any working directory — no discoverable workspace, a missing configuration file, and an invalid one included — and cannot fail for workspace or configuration reasons; configuration-error precedence does not apply to it. An external tool's compatibility check is plausibly its first call, made before it can trust anything about the workspace, so nothing a workspace contains or lacks may block the answer. - The machine-interface version's current value is stated in `SPEC.md` itself, and the surface reports exactly the stated value — observable against the specification in any single build, with no cross-build comparison needed. - The stated value names the machine-facing JSON contract `SPEC.md` defines: the JSON output of the product's commands under the existing universal-JSON and same-information conventions, the surfaces this proposal adds included. Because those contracts and the version value live in the same document, a change to the machine-facing JSON contract is by construction a specification change, and the proposal making it updates the stated value in the same change. An external tool detects incompatibility by comparing the reported value with the value its own interface knowledge was built against. @@ -125,7 +129,7 @@ Dependency visualization and change overlays already rest on: `query node`/`node ## Compatibility and rigor notes -- All additions obey the existing global conventions: single-JSON-document output, same-information JSON, byte-determinism, byte-wise ordering and comparison, workspace-relative paths (change 5's invocation anchoring is the one stated exception, itself deterministic per invocation), the exit-code partition, and configuration-error precedence. +- All additions obey the existing global conventions: single-JSON-document output, same-information JSON, byte-determinism, byte-wise ordering and comparison, workspace-relative paths (change 5's invocation anchoring is the one stated exception, itself deterministic per invocation), the exit-code partition, and configuration-error precedence — with the two amendments stated above: under the JSON convention, exit-2 errors now emit a JSON error document (change 6), and configuration-error precedence does not reach the workspace-independent identification surface (change 8). - The availability contract (change 4) is a deliberate, surface-scoped delta from the all-or-nothing read refusal of the existing commands, which keep their semantics unchanged; its refinement must stay deterministic and free of partial-resolution fabrication. - New surfaces are reads (or, for previews, validated no-op plans); none introduces new durable state, none writes through any new path, and none weakens the security posture of test seams — exposed data is workspace-local content only. - Range data added for code, occurrences, tag decompositions, and comments follows the existing byte-offset range convention so consumers handle one range model everywhere. From b590c536aeeeb7db68acea3b4a08141ce9908267 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 16:37:27 +0000 Subject: [PATCH 008/226] sdg(phase-3): apply review round 3 to patch 0001 (external UI APIs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied all items; none rejected. - C1: widened change 4's view domain — when expanded text is requested, the domain gains every file the expansions transitively consult, so the finding blocking an expansion is always in-domain and the never-silent guarantee holds. - I1: change 1's target-node filter is now syntactic acceptance — a well-formed targetable identity selects (possibly zero) occurrences by resolved target; nonexistent, masked, identity-undefined, and unknown targets yield an empty answer with domain findings; only malformed spellings are usage errors; delta flagged in compatibility notes. - O1: default-export unit ranges (named construct vs. export declaration). - O2: graph-data area reported unconditionally as derived, xspec-owned. - O3: session listing selects directory entries by name whatever occupies the path. - O4: preview derived-file consequences defined as the identity-relevant delta, not the full regeneration set. - O5: preview/real-operation equivalence scoped to workspace state, not scheduling. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index 845c43b..3e7135d 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -42,7 +42,7 @@ Introduce the concept of a **reference occurrence**: one textual spelling that r - Edges remain sets; occurrences are the positions behind them. Duplicate references that collapse to a single edge each remain distinct occurrences. - Occurrence spans are exact per kind: a `d` reference occurrence spans that one reference's own expression (each entry of an array separately — never the array or the prop); an MDX embedding occurrence spans the entire `{text(...)}` expression container, opening brace through closing brace — the whole construct Markdown compilation replaces, on which change 3's byte classification depends; a TypeScript occurrence spans the referencing expression itself (change 2). -- A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included). Finding reporting and exit mapping cover the enumeration's whole consulted domain, defined in change 4. +- A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included), and acceptance is syntactic: a well-formed identity of a targetable kind is a valid filter value whatever the workspace currently contains. The filter selects the occurrences whose resolved target it names; when it names no currently resolvable node — its file not discovered, its file masked, its bearer's identity undefined (change 4), or no such node in a parseable file — the selection is empty, since a spelling that does not resolve records no occurrence, and the consulted domain's findings (change 4) accompany the empty answer. An empty, finding-free answer (exit 0) is therefore definitive: nothing references the identity. Only a malformed identity spelling is a usage error. This deliberately departs, for this surface, from the existing convention classing unknown node identities in arguments as usage errors: the mid-edit question "who references this section?" must answer, not refuse, exactly while the section's file is broken. Finding reporting and exit mapping cover the enumeration's whole consulted domain, defined in change 4. - Constructs that record no edge produce no occurrence (unused import bindings, type-only bindings, shadowed identifiers, and reference spellings that are dynamic or do not resolve — the invalid ones are located by diagnostics instead, change 6). - Availability follows change 4: occurrence positions and spellings are per-file, parse-local data — only a masked (unparseable) file loses them — while occurrence existence is resolution-dependent: a spelling that does not resolve to exactly one target records no edge and no occurrence. An occurrence inside a section whose identity change 4 leaves undefined keeps its position, kind, and resolved target, with its source-node identity explicitly unavailable. - Ordering is deterministic: by file path (byte order), then by range start, then by range end. Distinct occurrences are distinct spellings occupying distinct spans, so identical ranges do not occur and this order is total; no further tiebreak exists. @@ -51,7 +51,7 @@ Introduce the concept of a **reference occurrence**: one textual spelling that r Amend the source-range concept (currently: "code locations carry no source range"): -- Every code location gains a source range: for a named code unit, the construct's own characters (analogous to a section's construct range); for a whole-file location, the entire file (analogous to a root node). Document-order-disambiguated units (`path#unit@N`) each carry their own occurrence's range. Where one declaration derives several named units, each unit's range is the construct that binds its own name: a function- or class-valued variable declaration's range spans its own name through its initializer, not the enclosing multi-declaration statement, while the nested units a dotted namespace name derives share the single namespace declaration's range, which is the one construct binding them all. +- Every code location gains a source range: for a named code unit, the construct's own characters (analogous to a section's construct range); for a whole-file location, the entire file (analogous to a root node). Document-order-disambiguated units (`path#unit@N`) each carry their own occurrence's range. Where one declaration derives several named units, each unit's range is the construct that binds its own name: a function- or class-valued variable declaration's range spans its own name through its initializer, not the enclosing multi-declaration statement, while the nested units a dotted namespace name derives share the single namespace declaration's range, which is the one construct binding them all. A default export follows the same principle: when the exported construct is named, the unit's range is that construct's own range; the unit named `default` that an anonymous exported construct derives takes the whole export declaration's range — the construct that binds that name. - Everywhere the specification outputs a graph node with a source range, a code location now qualifies — in particular in occurrence enumeration (change 1), `query` results that return code locations, and review payloads that present code-location scopes. - TypeScript reference occurrences (markers, `text(...)` calls) carry the range of the referencing expression itself, distinct from the enclosing unit's range. @@ -77,7 +77,7 @@ The structural surfaces of changes 1 and 3 exist to serve an editor while a pers - What findings make undefined is interpreted data, never structure. A section's node identity is defined exactly when its own `id` and each enclosing section's `id` are present and well-formed, each satisfies the structural-ID rules, and no other section of the file spells the same identity — duplicate spellings leave every bearer's identity undefined; no winner is picked. A section's interpreted tags and coverage attribute are defined exactly when its parsed props define them unambiguously (a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined; the raw spelling is still reported). A section whose identity is undefined still occupies its tree position with its ranges and raw spellings, and the occurrences inside it keep their positions, kinds, and resolved targets, with their source-node identity undefined. Invalid constructs outside change 3's inventory (stray elements, expression containers, exports) get no view entry; their findings' ranges locate them (change 6). - Data these rules or resolution leave undefined — section and occurrence-source identities as above, an import's resolved target when specifier form or discovery defines none, and expanded own and subtree text — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. Expanded text has an exact definedness rule: a node's own (respectively subtree) text is defined exactly when every embedding the expansion transitively reaches — each `text(...)` spelling in the node's own contribution (respectively anywhere in its subtree), and recursively each one inside every embedded target's subtree — records an occurrence (change 1), and the recursion re-enters no node already being expanded (an embedding cycle); one unresolved spelling or one cycle on the expansion path makes the value unavailable as a whole — partial expansion is fabrication and never occurs. - A reference occurrence, by contrast, never reports an unavailable target: a spelling that does not resolve to exactly one target — an unknown target, or an ambiguous one whose candidates' own identities are undefined — records no edge and therefore no occurrence, and its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid, in every parseable file; spellings inside an unparseable file are hidden with the rest of it, pointed to only by that file's parse-failure finding. -- Every answer has a consulted domain of files, and the findings of every file in that domain are reported alongside the answer — a masked file's parse-failure finding included. The domain is: for the view of change 3, the requested files; for an occurrence enumeration of change 1, every discovered file its file filter admits — and, when no file filter narrows it, the entire discovered set, spec and code sources alike, because a masked file anywhere could conceal occurrences the enumeration would otherwise return. The mapping onto the existing exit-code partition is: an invocation whose answer reports any finding or any explicitly-unavailable datum exits 1; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their existing precedence. A possibly-incomplete answer is therefore never silent: the finding always accompanies it, and the exit code says so. The full answer document is emitted in the 0 and 1 cases alike — exit 1 signals imperfection and never withholds the answer. +- Every answer has a consulted domain of files, and the findings of every file in that domain are reported alongside the answer — a masked file's parse-failure finding included. The domain is: for the view of change 3, the requested files and — when expanded text is requested — every further file those expansions consult (each embedded target's file the expansion transitively reaches), because the finding that blocks an expansion, an unresolved spelling or a cycle participation, can lie in a consulted file the request never named; for an occurrence enumeration of change 1, every discovered file its file filter admits — and, when no file filter narrows it, the entire discovered set, spec and code sources alike, because a masked file anywhere could conceal occurrences the enumeration would otherwise return. The mapping onto the existing exit-code partition is: an invocation whose answer reports any finding or any explicitly-unavailable datum exits 1; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their existing precedence. A possibly-incomplete answer is therefore never silent: the finding always accompanies it, and the exit code says so. The full answer document is emitted in the 0 and 1 cases alike — exit 1 signals imperfection and never withholds the answer. Existing commands keep their current all-or-nothing read semantics; this availability contract governs the surfaces this proposal adds. Their relationship to stored graph data follows the same line: the query surfaces of changes 1 and 3 never answer from stale data — on a workspace that passes build validation they participate in read-time refresh through the existing path, exactly as the existing read commands do, and on a workspace that does not, their answers reflect the current sources and they modify nothing: no graph data, no derived files — just as a failed refresh modifies nothing today. The inventory of change 5 states its own relationship to stored state. @@ -89,11 +89,12 @@ A query surface reports the machine-readable shape of the workspace, so an exter - the resolved configuration view: spec and code groups with their glob lists and kinds, Markdown emission state and destinations, and coverage profiles and policy rules, each name with its full definition — the two reported at the same depth; - every discovered source file with its group memberships; - the derived-file map: per source file, the generated module and companion paths and the Markdown emit destination (when enabled), plus any other recorded derived paths; +- the graph-data area: the location under which graph data is kept, reported unconditionally as a derived, xspec-owned area — the recorded derived-file map can lag or be empty, but the area is xspec's regardless, and an editor must know it before any build has run; - the durable files: the journal path and existing review-session files. The inventory contains no absolute paths and no environment-dependent content beyond the invocation anchoring above (a function of the invocation, like `--config` resolution — not of the machine), consistent with existing determinism and security conventions. When the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives), the anchoring is reported in the platform's absolute form — the one further case of the stated exception, still a pure function of invocation input. -Availability is unconditional: no part of the inventory requires parsing sources, so it answers whatever the sources' validity; configuration errors keep their existing precedence. Its content has three provenances, each reported as what it is: invocation, configuration, and discovery determine the anchoring, the configuration view, the discovered sources with their groups, and the per-source generated-module and Markdown-emit-destination paths (the destinations exist exactly while emission is enabled, per existing rules); recorded generation state supplies the remaining derived-file map entries — companion paths and any other recorded derived paths — reported as recorded, which can lag configuration until a rebuild and is empty before any generation has run; the filesystem supplies the durable entries — the journal path is fixed, and the review-session files are those present, selected by name alone: every file directly under the review-session directory whose name is a well-formed session file name is listed, corrupt or unparseable sessions included, since the inventory reads no session content; a file there with any other name is not xspec's and is not listed. The inventory reports recorded and durable state as it stands and never refreshes or writes anything. +Availability is unconditional: no part of the inventory requires parsing sources, so it answers whatever the sources' validity; configuration errors keep their existing precedence. Its content has three provenances, each reported as what it is: invocation, configuration, and discovery determine the anchoring, the configuration view, the discovered sources with their groups, the graph-data area, and the per-source generated-module and Markdown-emit-destination paths (the destinations exist exactly while emission is enabled, per existing rules); recorded generation state supplies the remaining derived-file map entries — companion paths and any other recorded derived paths — reported as recorded, which can lag configuration until a rebuild and is empty before any generation has run; the filesystem supplies the durable entries — the journal path is fixed, and the review-session files are those present, selected by name alone: every directory entry directly under the review-session directory whose name is a well-formed session file name is listed, whatever kind of filesystem object occupies it (a session-named path holding anything but a plain file is a corrupt session, and corrupt or unparseable sessions are included, since the inventory reads no session content); a path there with any other name is not xspec's and is not listed. The inventory reports recorded and durable state as it stands and never refreshes or writes anything. ### 6. Structured diagnostics @@ -112,9 +113,9 @@ Sharpen the validation-error contract so an external tool can render findings in - the complete identity mapping the operation would journal; - every file the operation would rewrite or relocate, with every edit the operation would make in it — each located by a range in current, pre-operation coordinates and classed by what it is. The classes cover everything the operations edit, not only reference occurrences: reference-occurrence rewrites (change 1's occurrences — `d` references, `text(...)` references, TypeScript markers); `id`-attribute rewrites (rename's and the section move's re-identification); import edits — specifier rewrites, import additions, and import removals; the section move's origin deletion, its target insertion point, and the self-closing-target-parent rewrite when one applies; and the file move's relocation of the file itself. A section move whose target file does not yet exist reports that file's creation as its own class, with the insertion point at the start of the new file — the one reported location without pre-operation coordinates; every range in a file that exists stays in current, pre-operation coordinates; -- the derived-file consequences, in both directions: the derived files that would be regenerated and the recorded derived files that would be removed as no longer generated — the old module path after a file move included. +- the derived-file consequences, in both directions, as the identity-relevant delta: the derived paths the operation would newly generate — paths where nothing is currently generated — and the recorded derived paths it would remove as no longer generated, the old module path after a file move included. The delta is the whole report: a successful operation finishes by regenerating every derived file, so the full regeneration set is workspace-constant, already named by the inventory of change 5, and carries no information about the operation — the delta is what the identity changes cause. -A preview succeeds exactly when the real operation would proceed and is refused exactly when — and reporting what — the real operation would refuse, with the same exit-code classification. A preview writes nothing (no sources, no journal, no derived files, no graph data) and is therefore a non-mutating command under the concurrency rules, safe to run while readers run. Preview output is byte-deterministic. +A preview succeeds exactly when the real operation would proceed and is refused exactly when — and reporting what — the real operation would refuse, with the same exit-code classification — an equivalence over workspace state, validation and planning, not over scheduling: the refusal that meets a mutating command while another runs applies to the real operation only, never to its preview, which the concurrency rules class as non-mutating. A preview writes nothing (no sources, no journal, no derived files, no graph data) and is therefore a non-mutating command under the concurrency rules, safe to run while readers run. Preview output is byte-deterministic. ### 8. Machine-interface identification @@ -130,6 +131,6 @@ Dependency visualization and change overlays already rest on: `query node`/`node ## Compatibility and rigor notes - All additions obey the existing global conventions: single-JSON-document output, same-information JSON, byte-determinism, byte-wise ordering and comparison, workspace-relative paths (change 5's invocation anchoring is the one stated exception, itself deterministic per invocation), the exit-code partition, and configuration-error precedence — with the two amendments stated above: under the JSON convention, exit-2 errors now emit a JSON error document (change 6), and configuration-error precedence does not reach the workspace-independent identification surface (change 8). -- The availability contract (change 4) is a deliberate, surface-scoped delta from the all-or-nothing read refusal of the existing commands, which keep their semantics unchanged; its refinement must stay deterministic and free of partial-resolution fabrication. +- The availability contract (change 4) and the syntactic target-filter acceptance of change 1 are deliberate, surface-scoped deltas from the existing commands' all-or-nothing read refusal and unknown-identity usage errors; the existing commands keep their semantics unchanged, and the contract's refinement must stay deterministic and free of partial-resolution fabrication. - New surfaces are reads (or, for previews, validated no-op plans); none introduces new durable state, none writes through any new path, and none weakens the security posture of test seams — exposed data is workspace-local content only. - Range data added for code, occurrences, tag decompositions, and comments follows the existing byte-offset range convention so consumers handle one range model everywhere. From 5bd9c08bfcfce6d64489c36a9d81a744009849bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 16:57:07 +0000 Subject: [PATCH 009/226] =?UTF-8?q?sdg(phase-3):=20apply=20review=20round?= =?UTF-8?q?=204=20to=20IP=200001=20=E2=80=94=20area/durable=20composition,?= =?UTF-8?q?=20corrupt-record=20outcome,=20file-filter=20semantics,=20avail?= =?UTF-8?q?ability=20scoping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied all items; rejected none. - I1: graph-data area reported as owned with the durable paths carved out and taking precedence — never a blanket derived, deletable unit. - I2: recorded state present but unreadable (corrupt graph data) gets a defined outcome: recorded entries explicitly unavailable, corruption a reported finding, exit 1, full inventory still emitted. - I3: change 1 file filter pinned to the existing file-glob convention (set restriction, empty admission = empty exit-0 answer); the malformed-identity usage-error sentence scoped to the target filter. - I4: change 4 closing sentence scoped to changes 1 and 3, naming where changes 5/7/8 state their own availability. - O1: explicit TypeScript spans — text(...) occurrence = whole call expression; marker occurrence = bare chain, no statement terminator. - O2: "the two" replaced — profiles and rules alike fully expanded. - O3: multi-file view order pinned to byte order of workspace-relative path. - O4: journal occupancy datum added (absence = empty journal). - O5: origin-deletion class's single range covers the adjunct dropped-line bytes, contiguous by construction; no separate class. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index 3e7135d..0e19ef1 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -42,7 +42,7 @@ Introduce the concept of a **reference occurrence**: one textual spelling that r - Edges remain sets; occurrences are the positions behind them. Duplicate references that collapse to a single edge each remain distinct occurrences. - Occurrence spans are exact per kind: a `d` reference occurrence spans that one reference's own expression (each entry of an array separately — never the array or the prop); an MDX embedding occurrence spans the entire `{text(...)}` expression container, opening brace through closing brace — the whole construct Markdown compilation replaces, on which change 3's byte classification depends; a TypeScript occurrence spans the referencing expression itself (change 2). -- A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included), and acceptance is syntactic: a well-formed identity of a targetable kind is a valid filter value whatever the workspace currently contains. The filter selects the occurrences whose resolved target it names; when it names no currently resolvable node — its file not discovered, its file masked, its bearer's identity undefined (change 4), or no such node in a parseable file — the selection is empty, since a spelling that does not resolve records no occurrence, and the consulted domain's findings (change 4) accompany the empty answer. An empty, finding-free answer (exit 0) is therefore definitive: nothing references the identity. Only a malformed identity spelling is a usage error. This deliberately departs, for this surface, from the existing convention classing unknown node identities in arguments as usage errors: the mid-edit question "who references this section?" must answer, not refuse, exactly while the section's file is broken. Finding reporting and exit mapping cover the enumeration's whole consulted domain, defined in change 4. +- A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. The file filter follows the existing file-glob convention — the glob rules existing file filters use, their invalid-pattern usage errors included: it is a set restriction over discovered files, not an existence assertion, so the enumeration's consulted domain is the discovered files it admits (change 4), a glob admitting none admits the empty set (an empty, finding-free answer, exit 0), and no unknown-file usage error exists on it. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included), and acceptance is syntactic: a well-formed identity of a targetable kind is a valid filter value whatever the workspace currently contains. The filter selects the occurrences whose resolved target it names; when it names no currently resolvable node — its file not discovered, its file masked, its bearer's identity undefined (change 4), or no such node in a parseable file — the selection is empty, since a spelling that does not resolve records no occurrence, and the consulted domain's findings (change 4) accompany the empty answer. An empty, finding-free answer (exit 0) is therefore definitive: nothing references the identity. On the target filter, only a malformed identity spelling is a usage error. This deliberately departs, for that filter, from the existing convention classing unknown node identities in arguments as usage errors: the mid-edit question "who references this section?" must answer, not refuse, exactly while the section's file is broken. Finding reporting and exit mapping cover the enumeration's whole consulted domain, defined in change 4. - Constructs that record no edge produce no occurrence (unused import bindings, type-only bindings, shadowed identifiers, and reference spellings that are dynamic or do not resolve — the invalid ones are located by diagnostics instead, change 6). - Availability follows change 4: occurrence positions and spellings are per-file, parse-local data — only a masked (unparseable) file loses them — while occurrence existence is resolution-dependent: a spelling that does not resolve to exactly one target records no edge and no occurrence. An occurrence inside a section whose identity change 4 leaves undefined keeps its position, kind, and resolved target, with its source-node identity explicitly unavailable. - Ordering is deterministic: by file path (byte order), then by range start, then by range end. Distinct occurrences are distinct spellings occupying distinct spans, so identical ranges do not occur and this order is total; no further tiebreak exists. @@ -53,7 +53,7 @@ Amend the source-range concept (currently: "code locations carry no source range - Every code location gains a source range: for a named code unit, the construct's own characters (analogous to a section's construct range); for a whole-file location, the entire file (analogous to a root node). Document-order-disambiguated units (`path#unit@N`) each carry their own occurrence's range. Where one declaration derives several named units, each unit's range is the construct that binds its own name: a function- or class-valued variable declaration's range spans its own name through its initializer, not the enclosing multi-declaration statement, while the nested units a dotted namespace name derives share the single namespace declaration's range, which is the one construct binding them all. A default export follows the same principle: when the exported construct is named, the unit's range is that construct's own range; the unit named `default` that an anonymous exported construct derives takes the whole export declaration's range — the construct that binds that name. - Everywhere the specification outputs a graph node with a source range, a code location now qualifies — in particular in occurrence enumeration (change 1), `query` results that return code locations, and review payloads that present code-location scopes. -- TypeScript reference occurrences (markers, `text(...)` calls) carry the range of the referencing expression itself, distinct from the enclosing unit's range. +- TypeScript reference occurrences (markers, `text(...)` calls) carry the range of the referencing expression itself, distinct from the enclosing unit's range — exact per form, matching the explicitness of the `d` and MDX cases: a `text(...)` occurrence spans the entire call expression, callee through closing parenthesis, argument included — the expression that records the edge — and a marker occurrence spans the bare reference chain alone, exclusive of any statement terminator. ### 3. Whole-document structural view @@ -66,7 +66,7 @@ A query surface returns, for a spec source file, everything needed to overlay st - every MDX comment's source range. With tags, imports, comments, and embedding occurrences located (an embedding occurrence's span is its full braced container, change 1), every construct that Markdown compilation removes is positioned, so on a finding-free file a consumer can classify each byte as annotation or content without re-parsing the MDX. On an imperfect file the classification is joint with change 6: spellings and constructs that produce no occurrence or view entry (change 4) are located by their findings' ranges, and the two surfaces together still position every removable construct; - position resolution as a direct query: given the file and a byte offset, the innermost enclosing section and, when the offset lies within a reference occurrence, that occurrence and its resolved target. Resolution is by range containment and is total over the file: every within-file offset lies in the root's range, so bytes inside imports, comments, and content between sections resolve to the innermost section construct containing them — the root when none does; the offset equal to the file length (the caret position at end of file) resolves to the root; a greater offset is a usage error, per existing conventions. The same resolution must also be derivable from the view's data alone, so both index-building consumers and lightweight ones that keep no client-side index are served. -The view is defined for discovered spec sources: one file, a set restricted by the existing file-glob convention, or all of them — a multi-file request returns per-file views in one deterministic JSON document, so a consumer can index an entire workspace in a single invocation. A file named outside the discovered set is a usage error, per existing conventions. +The view is defined for discovered spec sources: one file, a set restricted by the existing file-glob convention, or all of them — a multi-file request returns per-file views, ordered by byte order of workspace-relative path per existing conventions, in one deterministic JSON document, so a consumer can index an entire workspace in a single invocation. A file named outside the discovered set is a usage error, per existing conventions. ### 4. Availability on imperfect workspaces @@ -79,22 +79,22 @@ The structural surfaces of changes 1 and 3 exist to serve an editor while a pers - A reference occurrence, by contrast, never reports an unavailable target: a spelling that does not resolve to exactly one target — an unknown target, or an ambiguous one whose candidates' own identities are undefined — records no edge and therefore no occurrence, and its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid, in every parseable file; spellings inside an unparseable file are hidden with the rest of it, pointed to only by that file's parse-failure finding. - Every answer has a consulted domain of files, and the findings of every file in that domain are reported alongside the answer — a masked file's parse-failure finding included. The domain is: for the view of change 3, the requested files and — when expanded text is requested — every further file those expansions consult (each embedded target's file the expansion transitively reaches), because the finding that blocks an expansion, an unresolved spelling or a cycle participation, can lie in a consulted file the request never named; for an occurrence enumeration of change 1, every discovered file its file filter admits — and, when no file filter narrows it, the entire discovered set, spec and code sources alike, because a masked file anywhere could conceal occurrences the enumeration would otherwise return. The mapping onto the existing exit-code partition is: an invocation whose answer reports any finding or any explicitly-unavailable datum exits 1; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their existing precedence. A possibly-incomplete answer is therefore never silent: the finding always accompanies it, and the exit code says so. The full answer document is emitted in the 0 and 1 cases alike — exit 1 signals imperfection and never withholds the answer. -Existing commands keep their current all-or-nothing read semantics; this availability contract governs the surfaces this proposal adds. Their relationship to stored graph data follows the same line: the query surfaces of changes 1 and 3 never answer from stale data — on a workspace that passes build validation they participate in read-time refresh through the existing path, exactly as the existing read commands do, and on a workspace that does not, their answers reflect the current sources and they modify nothing: no graph data, no derived files — just as a failed refresh modifies nothing today. The inventory of change 5 states its own relationship to stored state. +Existing commands keep their current all-or-nothing read semantics; this availability contract governs the structural surfaces of changes 1 and 3 — the other added surfaces state their own: the inventory of change 5 is unconditional on its own terms, the previews of change 7 refuse exactly when the real operation would, and the identification surface of change 8 is workspace-independent. The relationship of changes 1 and 3 to stored graph data follows the same line: these query surfaces never answer from stale data — on a workspace that passes build validation they participate in read-time refresh through the existing path, exactly as the existing read commands do, and on a workspace that does not, their answers reflect the current sources and they modify nothing: no graph data, no derived files — just as a failed refresh modifies nothing today. The inventory of change 5 states its own relationship to stored state. ### 5. Workspace inventory A query surface reports the machine-readable shape of the workspace, so an external editor never edits files xspec owns and never misses files xspec reads: - how the resolved workspace root anchors to the invocation: the workspace root and the configuration file are identified relative to the invocation working directory — invocation input, exactly as existing conventions already treat `--config` resolution — and never as absolute paths. Configuration discovery thereby has one authority: a tool invoking xspec from an arbitrary directory can map the workspace-relative paths in every output to real files without re-implementing the upward search, which is an editing-safety requirement — a consumer that guesses the root wrong edits the wrong files; -- the resolved configuration view: spec and code groups with their glob lists and kinds, Markdown emission state and destinations, and coverage profiles and policy rules, each name with its full definition — the two reported at the same depth; +- the resolved configuration view: spec and code groups with their glob lists and kinds, Markdown emission state and destinations, and coverage profiles and policy rules, each name with its full definition — profiles and rules alike fully expanded, never name-only; - every discovered source file with its group memberships; - the derived-file map: per source file, the generated module and companion paths and the Markdown emit destination (when enabled), plus any other recorded derived paths; -- the graph-data area: the location under which graph data is kept, reported unconditionally as a derived, xspec-owned area — the recorded derived-file map can lag or be empty, but the area is xspec's regardless, and an editor must know it before any build has run; -- the durable files: the journal path and existing review-session files. +- the graph-data area: the location under which graph data is kept, reported unconditionally as an xspec-owned area — the recorded derived-file map can lag or be empty, but the area is xspec's regardless, and an editor must know it before any build has run. The durable files live under this same location, so the area's classification composes with theirs by carve-out, and the report states the composition in its own terms: every path under the area is xspec's; the durable paths reported below are durable and take precedence over the area's classification; only the remainder — the graph data itself — is derived, rebuild-recoverable content. The area is never presented as a deletable or wholesale-regenerable unit, precisely because the durable files inside it are neither; +- the durable files: the journal path with whether anything presently occupies it — an absent journal is an empty journal, per existing journal semantics, and this datum surfaces that — and existing review-session files. The inventory contains no absolute paths and no environment-dependent content beyond the invocation anchoring above (a function of the invocation, like `--config` resolution — not of the machine), consistent with existing determinism and security conventions. When the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives), the anchoring is reported in the platform's absolute form — the one further case of the stated exception, still a pure function of invocation input. -Availability is unconditional: no part of the inventory requires parsing sources, so it answers whatever the sources' validity; configuration errors keep their existing precedence. Its content has three provenances, each reported as what it is: invocation, configuration, and discovery determine the anchoring, the configuration view, the discovered sources with their groups, the graph-data area, and the per-source generated-module and Markdown-emit-destination paths (the destinations exist exactly while emission is enabled, per existing rules); recorded generation state supplies the remaining derived-file map entries — companion paths and any other recorded derived paths — reported as recorded, which can lag configuration until a rebuild and is empty before any generation has run; the filesystem supplies the durable entries — the journal path is fixed, and the review-session files are those present, selected by name alone: every directory entry directly under the review-session directory whose name is a well-formed session file name is listed, whatever kind of filesystem object occupies it (a session-named path holding anything but a plain file is a corrupt session, and corrupt or unparseable sessions are included, since the inventory reads no session content); a path there with any other name is not xspec's and is not listed. The inventory reports recorded and durable state as it stands and never refreshes or writes anything. +Availability is unconditional: no part of the inventory requires parsing sources, so it answers whatever the sources' validity; configuration errors keep their existing precedence. Its content has three provenances, each reported as what it is: invocation, configuration, and discovery determine the anchoring, the configuration view, the discovered sources with their groups, the graph-data area, and the per-source generated-module and Markdown-emit-destination paths (the destinations exist exactly while emission is enabled, per existing rules); recorded generation state supplies the remaining derived-file map entries — companion paths and any other recorded derived paths — reported as recorded, which can lag configuration until a rebuild and is empty before any generation has run. Because the inventory alone among readers consults the record without refreshing it, it is the one surface that can meet recorded state that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise, a state the existing derived-file rules contemplate and a rebuild resolves — and that case has a defined outcome: the recorded entries are reported explicitly unavailable, never fabricated and never passed off as an empty map, the corruption accompanies the answer as a reported finding, and the invocation exits 1 under the existing partition — the full inventory, every other provenance's content unaffected, is still emitted. Finally, the filesystem supplies the durable entries — the journal path is fixed and its occupancy datum is presence alone, whatever kind of filesystem object occupies it (the inventory reads no journal content), and the review-session files are those present, selected by name alone: every directory entry directly under the review-session directory whose name is a well-formed session file name is listed, whatever kind of filesystem object occupies it (a session-named path holding anything but a plain file is a corrupt session, and corrupt or unparseable sessions are included, since the inventory reads no session content); a path there with any other name is not xspec's and is not listed. The inventory reports recorded and durable state as it stands and never refreshes or writes anything. ### 6. Structured diagnostics @@ -112,7 +112,7 @@ Sharpen the validation-error contract so an external tool can render findings in `rename` and `move` gain a preview mode that performs the full validation and planning of the real operation and reports, without modifying anything: - the complete identity mapping the operation would journal; -- every file the operation would rewrite or relocate, with every edit the operation would make in it — each located by a range in current, pre-operation coordinates and classed by what it is. The classes cover everything the operations edit, not only reference occurrences: reference-occurrence rewrites (change 1's occurrences — `d` references, `text(...)` references, TypeScript markers); `id`-attribute rewrites (rename's and the section move's re-identification); import edits — specifier rewrites, import additions, and import removals; the section move's origin deletion, its target insertion point, and the self-closing-target-parent rewrite when one applies; and the file move's relocation of the file itself. A section move whose target file does not yet exist reports that file's creation as its own class, with the insertion point at the start of the new file — the one reported location without pre-operation coordinates; every range in a file that exists stays in current, pre-operation coordinates; +- every file the operation would rewrite or relocate, with every edit the operation would make in it — each located by a range in current, pre-operation coordinates and classed by what it is. The classes cover everything the operations edit, not only reference occurrences: reference-occurrence rewrites (change 1's occurrences — `d` references, `text(...)` references, TypeScript markers); `id`-attribute rewrites (rename's and the section move's re-identification); import edits — specifier rewrites, import additions, and import removals; the section move's origin deletion — one range spanning every byte the origin edit removes: the construct's own characters, extended over the leftover whitespace and line terminator of a line the existing line-drop rule additionally drops (bytes contiguous with the construct by construction), so the adjunct drop lies inside this class's range rather than forming a class of its own — its target insertion point, and the self-closing-target-parent rewrite when one applies; and the file move's relocation of the file itself. A section move whose target file does not yet exist reports that file's creation as its own class, with the insertion point at the start of the new file — the one reported location without pre-operation coordinates; every range in a file that exists stays in current, pre-operation coordinates; - the derived-file consequences, in both directions, as the identity-relevant delta: the derived paths the operation would newly generate — paths where nothing is currently generated — and the recorded derived paths it would remove as no longer generated, the old module path after a file move included. The delta is the whole report: a successful operation finishes by regenerating every derived file, so the full regeneration set is workspace-constant, already named by the inventory of change 5, and carries no information about the operation — the delta is what the identity changes cause. A preview succeeds exactly when the real operation would proceed and is refused exactly when — and reporting what — the real operation would refuse, with the same exit-code classification — an equivalence over workspace state, validation and planning, not over scheduling: the refusal that meets a mutating command while another runs applies to the real operation only, never to its preview, which the concurrency rules class as non-mutating. A preview writes nothing (no sources, no journal, no derived files, no graph data) and is therefore a non-mutating command under the concurrency rules, safe to run while readers run. Preview output is byte-deterministic. From b9136123ce31f1da8830092845f745997198f9c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 17:17:14 +0000 Subject: [PATCH 010/226] sdg(phase-3): one ownership rule for graph-area paths; scope occurrence-absence guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration 5 of patch refinement for 0001-external-ui-apis (IP). Applied: - C1: replaced the graph-data area's contradictory per-occupant ownership composition with one rule stated once in the area entry: the area is a write reservation; reported durable and recorded derived paths are classified as reported; every other path under the area is unattributed — never listed, never claimed, never presented as rebuild-recoverable. The availability paragraph's review-session sentence now defers to that rule instead of asserting opposite ownership. - I1: the empty-finding-free-answer guarantee of change 1 is now scoped to the consulted domain, absolute exactly when no file filter narrows it. - O1: change 4's resolution-failure exemplars now include the unique-bearer-with-undefined-identity case change 1 cites. - O2: change 6 defines the concerned path for missing configuration with no --config: the search-origin directory (invocation working directory), invocation input like change 5's anchoring; compatibility note updated so the invocation-anchored exception covers both changes. - O3: change 5's configuration view names its shape: profiles and rules carried with complete definitions; group references stay names resolved against the view's own group list, never glob expansions. - O4: split the change-1 file-filter sentence and the change-5 provenance/availability sentences into separable statements, all clauses preserved. Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index 0e19ef1..2c1c78b 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -42,7 +42,7 @@ Introduce the concept of a **reference occurrence**: one textual spelling that r - Edges remain sets; occurrences are the positions behind them. Duplicate references that collapse to a single edge each remain distinct occurrences. - Occurrence spans are exact per kind: a `d` reference occurrence spans that one reference's own expression (each entry of an array separately — never the array or the prop); an MDX embedding occurrence spans the entire `{text(...)}` expression container, opening brace through closing brace — the whole construct Markdown compilation replaces, on which change 3's byte classification depends; a TypeScript occurrence spans the referencing expression itself (change 2). -- A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. The file filter follows the existing file-glob convention — the glob rules existing file filters use, their invalid-pattern usage errors included: it is a set restriction over discovered files, not an existence assertion, so the enumeration's consulted domain is the discovered files it admits (change 4), a glob admitting none admits the empty set (an empty, finding-free answer, exit 0), and no unknown-file usage error exists on it. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included), and acceptance is syntactic: a well-formed identity of a targetable kind is a valid filter value whatever the workspace currently contains. The filter selects the occurrences whose resolved target it names; when it names no currently resolvable node — its file not discovered, its file masked, its bearer's identity undefined (change 4), or no such node in a parseable file — the selection is empty, since a spelling that does not resolve records no occurrence, and the consulted domain's findings (change 4) accompany the empty answer. An empty, finding-free answer (exit 0) is therefore definitive: nothing references the identity. On the target filter, only a malformed identity spelling is a usage error. This deliberately departs, for that filter, from the existing convention classing unknown node identities in arguments as usage errors: the mid-edit question "who references this section?" must answer, not refuse, exactly while the section's file is broken. Finding reporting and exit mapping cover the enumeration's whole consulted domain, defined in change 4. +- A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. The file filter follows the existing file-glob convention — the glob rules existing file filters use, their invalid-pattern usage errors included. It is a set restriction over discovered files, not an existence assertion: the enumeration's consulted domain is the discovered files it admits (change 4); a glob admitting none admits the empty set (an empty, finding-free answer, exit 0); and no unknown-file usage error exists on it. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included), and acceptance is syntactic: a well-formed identity of a targetable kind is a valid filter value whatever the workspace currently contains. The filter selects the occurrences whose resolved target it names; when it names no currently resolvable node — its file not discovered, its file masked, its bearer's identity undefined (change 4), or no such node in a parseable file — the selection is empty, since a spelling that does not resolve records no occurrence, and the consulted domain's findings (change 4) accompany the empty answer. An empty, finding-free answer (exit 0) is therefore definitive over the consulted domain: nothing in the files the enumeration consulted references the identity. When no file filter narrows the domain, the domain is the entire discovered set and the guarantee is absolute — nothing in the workspace references the identity; under a narrowing file filter it is exactly domain-wide — a file outside the admitted set can still hold a resolving occurrence, which the answer neither reports nor denies. On the target filter, only a malformed identity spelling is a usage error. This deliberately departs, for that filter, from the existing convention classing unknown node identities in arguments as usage errors: the mid-edit question "who references this section?" must answer, not refuse, exactly while the section's file is broken. Finding reporting and exit mapping cover the enumeration's whole consulted domain, defined in change 4. - Constructs that record no edge produce no occurrence (unused import bindings, type-only bindings, shadowed identifiers, and reference spellings that are dynamic or do not resolve — the invalid ones are located by diagnostics instead, change 6). - Availability follows change 4: occurrence positions and spellings are per-file, parse-local data — only a masked (unparseable) file loses them — while occurrence existence is resolution-dependent: a spelling that does not resolve to exactly one target records no edge and no occurrence. An occurrence inside a section whose identity change 4 leaves undefined keeps its position, kind, and resolved target, with its source-node identity explicitly unavailable. - Ordering is deterministic: by file path (byte order), then by range start, then by range end. Distinct occurrences are distinct spellings occupying distinct spans, so identical ranges do not occur and this order is total; no further tiebreak exists. @@ -76,7 +76,7 @@ The structural surfaces of changes 1 and 3 exist to serve an editor while a pers - Only an unparseable file (or content the existing masking rules already hide) loses its structural view; masking is per file, and the surfaces still answer for every other requested file. - What findings make undefined is interpreted data, never structure. A section's node identity is defined exactly when its own `id` and each enclosing section's `id` are present and well-formed, each satisfies the structural-ID rules, and no other section of the file spells the same identity — duplicate spellings leave every bearer's identity undefined; no winner is picked. A section's interpreted tags and coverage attribute are defined exactly when its parsed props define them unambiguously (a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined; the raw spelling is still reported). A section whose identity is undefined still occupies its tree position with its ranges and raw spellings, and the occurrences inside it keep their positions, kinds, and resolved targets, with their source-node identity undefined. Invalid constructs outside change 3's inventory (stray elements, expression containers, exports) get no view entry; their findings' ranges locate them (change 6). - Data these rules or resolution leave undefined — section and occurrence-source identities as above, an import's resolved target when specifier form or discovery defines none, and expanded own and subtree text — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. Expanded text has an exact definedness rule: a node's own (respectively subtree) text is defined exactly when every embedding the expansion transitively reaches — each `text(...)` spelling in the node's own contribution (respectively anywhere in its subtree), and recursively each one inside every embedded target's subtree — records an occurrence (change 1), and the recursion re-enters no node already being expanded (an embedding cycle); one unresolved spelling or one cycle on the expansion path makes the value unavailable as a whole — partial expansion is fabrication and never occurs. -- A reference occurrence, by contrast, never reports an unavailable target: a spelling that does not resolve to exactly one target — an unknown target, or an ambiguous one whose candidates' own identities are undefined — records no edge and therefore no occurrence, and its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid, in every parseable file; spellings inside an unparseable file are hidden with the rest of it, pointed to only by that file's parse-failure finding. +- A reference occurrence, by contrast, never reports an unavailable target: a spelling that does not resolve to exactly one target — an unknown target; a unique bearer whose identity the definedness rule above leaves undefined; or an ambiguous one, every duplicate bearer's identity undefined — records no edge and therefore no occurrence, and its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid, in every parseable file; spellings inside an unparseable file are hidden with the rest of it, pointed to only by that file's parse-failure finding. - Every answer has a consulted domain of files, and the findings of every file in that domain are reported alongside the answer — a masked file's parse-failure finding included. The domain is: for the view of change 3, the requested files and — when expanded text is requested — every further file those expansions consult (each embedded target's file the expansion transitively reaches), because the finding that blocks an expansion, an unresolved spelling or a cycle participation, can lie in a consulted file the request never named; for an occurrence enumeration of change 1, every discovered file its file filter admits — and, when no file filter narrows it, the entire discovered set, spec and code sources alike, because a masked file anywhere could conceal occurrences the enumeration would otherwise return. The mapping onto the existing exit-code partition is: an invocation whose answer reports any finding or any explicitly-unavailable datum exits 1; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their existing precedence. A possibly-incomplete answer is therefore never silent: the finding always accompanies it, and the exit code says so. The full answer document is emitted in the 0 and 1 cases alike — exit 1 signals imperfection and never withholds the answer. Existing commands keep their current all-or-nothing read semantics; this availability contract governs the structural surfaces of changes 1 and 3 — the other added surfaces state their own: the inventory of change 5 is unconditional on its own terms, the previews of change 7 refuse exactly when the real operation would, and the identification surface of change 8 is workspace-independent. The relationship of changes 1 and 3 to stored graph data follows the same line: these query surfaces never answer from stale data — on a workspace that passes build validation they participate in read-time refresh through the existing path, exactly as the existing read commands do, and on a workspace that does not, their answers reflect the current sources and they modify nothing: no graph data, no derived files — just as a failed refresh modifies nothing today. The inventory of change 5 states its own relationship to stored state. @@ -86,22 +86,22 @@ Existing commands keep their current all-or-nothing read semantics; this availab A query surface reports the machine-readable shape of the workspace, so an external editor never edits files xspec owns and never misses files xspec reads: - how the resolved workspace root anchors to the invocation: the workspace root and the configuration file are identified relative to the invocation working directory — invocation input, exactly as existing conventions already treat `--config` resolution — and never as absolute paths. Configuration discovery thereby has one authority: a tool invoking xspec from an arbitrary directory can map the workspace-relative paths in every output to real files without re-implementing the upward search, which is an editing-safety requirement — a consumer that guesses the root wrong edits the wrong files; -- the resolved configuration view: spec and code groups with their glob lists and kinds, Markdown emission state and destinations, and coverage profiles and policy rules, each name with its full definition — profiles and rules alike fully expanded, never name-only; +- the resolved configuration view: spec and code groups with their glob lists and kinds, Markdown emission state and destinations, and coverage profiles and policy rules, each name with its full definition — every profile and rule carried with its complete definition, never as a bare name. A group reference inside a profile or rule stays the configured group name, never its glob expansion: the name resolves against the group list this same view reports, so nothing is lost when two groups share one definition; - every discovered source file with its group memberships; - the derived-file map: per source file, the generated module and companion paths and the Markdown emit destination (when enabled), plus any other recorded derived paths; -- the graph-data area: the location under which graph data is kept, reported unconditionally as an xspec-owned area — the recorded derived-file map can lag or be empty, but the area is xspec's regardless, and an editor must know it before any build has run. The durable files live under this same location, so the area's classification composes with theirs by carve-out, and the report states the composition in its own terms: every path under the area is xspec's; the durable paths reported below are durable and take precedence over the area's classification; only the remainder — the graph data itself — is derived, rebuild-recoverable content. The area is never presented as a deletable or wholesale-regenerable unit, precisely because the durable files inside it are neither; +- the graph-data area: the location under which graph data is kept, reported unconditionally — the recorded derived-file map can lag or be empty, but an editor must know the area before any build has run. The area's classification is a write reservation, not per-occupant ownership: the area is reserved for xspec's writes — per the existing derived-file rules, a derived-file write there replaces whatever occupies its path — so an external tool must never create, edit, or keep content of its own anywhere under it. Individual paths under the area are classified exactly as the inventory reports them: the durable paths reported below are durable, and a recorded derived path lying under the area is derived, like every recorded derived path. Every other path under the area falls under one rule, stated here once and holding everywhere in this inventory: it is unattributed. The graph data xspec keeps under the area is derived, rebuild-recoverable content, but its layout is deliberately not enumerated, so an unreported path may equally be xspec's graph data or foreign content: the inventory neither lists it, nor claims it for xspec, nor calls it rebuild-recoverable — rebuilding regenerates derived files and removes recorded ones no longer generated, but neither reproduces nor removes a path of which no record exists, so deleting an unattributed path is undone by nothing. For the same reasons the area is never presented as a deletable or wholesale-regenerable unit: the durable files inside it are neither, and an unattributed path is not recoverable at all; - the durable files: the journal path with whether anything presently occupies it — an absent journal is an empty journal, per existing journal semantics, and this datum surfaces that — and existing review-session files. The inventory contains no absolute paths and no environment-dependent content beyond the invocation anchoring above (a function of the invocation, like `--config` resolution — not of the machine), consistent with existing determinism and security conventions. When the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives), the anchoring is reported in the platform's absolute form — the one further case of the stated exception, still a pure function of invocation input. -Availability is unconditional: no part of the inventory requires parsing sources, so it answers whatever the sources' validity; configuration errors keep their existing precedence. Its content has three provenances, each reported as what it is: invocation, configuration, and discovery determine the anchoring, the configuration view, the discovered sources with their groups, the graph-data area, and the per-source generated-module and Markdown-emit-destination paths (the destinations exist exactly while emission is enabled, per existing rules); recorded generation state supplies the remaining derived-file map entries — companion paths and any other recorded derived paths — reported as recorded, which can lag configuration until a rebuild and is empty before any generation has run. Because the inventory alone among readers consults the record without refreshing it, it is the one surface that can meet recorded state that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise, a state the existing derived-file rules contemplate and a rebuild resolves — and that case has a defined outcome: the recorded entries are reported explicitly unavailable, never fabricated and never passed off as an empty map, the corruption accompanies the answer as a reported finding, and the invocation exits 1 under the existing partition — the full inventory, every other provenance's content unaffected, is still emitted. Finally, the filesystem supplies the durable entries — the journal path is fixed and its occupancy datum is presence alone, whatever kind of filesystem object occupies it (the inventory reads no journal content), and the review-session files are those present, selected by name alone: every directory entry directly under the review-session directory whose name is a well-formed session file name is listed, whatever kind of filesystem object occupies it (a session-named path holding anything but a plain file is a corrupt session, and corrupt or unparseable sessions are included, since the inventory reads no session content); a path there with any other name is not xspec's and is not listed. The inventory reports recorded and durable state as it stands and never refreshes or writes anything. +Availability is unconditional: no part of the inventory requires parsing sources, so it answers whatever the sources' validity; configuration errors keep their existing precedence. Its content has three provenances, each reported as what it is. Invocation, configuration, and discovery determine the anchoring, the configuration view, the discovered sources with their groups, the graph-data area, and the per-source generated-module and Markdown-emit-destination paths (the destinations exist exactly while emission is enabled, per existing rules). Recorded generation state supplies the remaining derived-file map entries — companion paths and any other recorded derived paths — reported as recorded: recorded state can lag configuration until a rebuild and is empty before any generation has run. Because the inventory alone among readers consults the record without refreshing it, it is the one surface that can meet recorded state that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise, a state the existing derived-file rules contemplate and a rebuild resolves — and that case has a defined outcome. The recorded entries are reported explicitly unavailable, never fabricated and never passed off as an empty map. The corruption accompanies the answer as a reported finding, and the invocation exits 1 under the existing partition. The full inventory, every other provenance's content unaffected, is still emitted. Finally, the filesystem supplies the durable entries. The journal path is fixed, and its occupancy datum is presence alone, whatever kind of filesystem object occupies it — the inventory reads no journal content. The review-session files are those present, selected by name alone: every directory entry directly under the review-session directory whose name is a well-formed session file name is listed, whatever kind of filesystem object occupies it — a session-named path holding anything but a plain file is a corrupt session, and corrupt or unparseable sessions are included, since the inventory reads no session content. A directory entry there with any other name is not a session and is never listed: it is an unattributed path under the area, governed by the one rule the graph-data-area entry states. The inventory reports recorded and durable state as it stands and never refreshes or writes anything. ### 6. Structured diagnostics Sharpen the validation-error contract so an external tool can render findings inline: - Every reported error condition carries a stable machine-readable code identifying which numbered condition of the validation-errors section it is. -- Every error that locates inside a source file carries the file and a source range (byte offsets) for the offending construct, at the precision the condition allows; conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. +- Every error that locates inside a source file carries the file and a source range (byte offsets) for the offending construct, at the precision the condition allows; conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. For missing configuration with no `--config` given — the one condition where no configuration file exists to be concerned — the concerned path is the directory the failed upward search started from, the invocation working directory: invocation input, deterministic per invocation exactly as change 5's anchoring is, so the concerned-path datum is total over these conditions. - The JSON report form presents these fields for every finding, preserving the existing requirements that all conditions are reported together and that JSON carries the same information as the human report. - The same contract covers operation refusals: each distinct reason `rename` and `move` refuse — exactly what the previews of change 7 report — carries a stable machine-readable code and the file, source range, or identity it concerns, so a refusal renders as precisely as a finding. Refusals are findings under the existing exit-code partition, so the JSON report form above already carries them. - Machine-readable delivery is closed over the outcome classes: under the JSON output convention, an invocation that fails with a usage or configuration error (exit 2) emits a single JSON document as its entire standard output reporting the error — carrying, for conditions with a defined code (configuration errors included), the stable code and the concerned file or path above — amending the existing rule that such an error leaves standard output empty. Exit codes, error precedence, and human-readable standard-error text are unchanged. Without this channel, configuration errors — the one class that precedes and blocks every surface of changes 1, 3, 5, and 7 — would be the one class an external tool cannot consume. @@ -130,7 +130,7 @@ Dependency visualization and change overlays already rest on: `query node`/`node ## Compatibility and rigor notes -- All additions obey the existing global conventions: single-JSON-document output, same-information JSON, byte-determinism, byte-wise ordering and comparison, workspace-relative paths (change 5's invocation anchoring is the one stated exception, itself deterministic per invocation), the exit-code partition, and configuration-error precedence — with the two amendments stated above: under the JSON convention, exit-2 errors now emit a JSON error document (change 6), and configuration-error precedence does not reach the workspace-independent identification surface (change 8). +- All additions obey the existing global conventions: single-JSON-document output, same-information JSON, byte-determinism, byte-wise ordering and comparison, workspace-relative paths (invocation-anchored content is the stated exception — change 5's anchoring and the configuration-error concerned paths of change 6 — itself deterministic per invocation), the exit-code partition, and configuration-error precedence — with the two amendments stated above: under the JSON convention, exit-2 errors now emit a JSON error document (change 6), and configuration-error precedence does not reach the workspace-independent identification surface (change 8). - The availability contract (change 4) and the syntactic target-filter acceptance of change 1 are deliberate, surface-scoped deltas from the existing commands' all-or-nothing read refusal and unknown-identity usage errors; the existing commands keep their semantics unchanged, and the contract's refinement must stay deterministic and free of partial-resolution fabrication. - New surfaces are reads (or, for previews, validated no-op plans); none introduces new durable state, none writes through any new path, and none weakens the security posture of test seams — exposed data is workspace-local content only. - Range data added for code, occurrences, tag decompositions, and comments follows the existing byte-offset range convention so consumers handle one range model everywhere. From 1c595e48f2cb330137147aad866a28114334be8b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 17:39:44 +0000 Subject: [PATCH 011/226] sdg(phase-3): pin code-range outputs; ground unattributed-path rule in unknowability (iter 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied I1: change 2's second bullet now names the exact outputs that gain code-location ranges — occurrence records present their source graph node as one datum (identity plus that node's own construct range; changes 1 and 4 adjusted to match), review payloads generalize the present-node range rule from requirement nodes to graph nodes, and query edge endpoints explicitly remain bare identities. Applied I2: the graph-data-area bullet no longer claims an unattributed path is irrecoverable (false for the graph-data subcase per SPEC 12.1/13.3); the undeletable rule now rests on the consumer's inability to tell regenerable graph data from foreign content. Applied O1: view's glob form states the empty-match outcome (empty set, finding-free, exit 0), mirroring change 1. Applied O2: anchoring bullet's "never as absolute paths" now carries the different-Windows-drives carve-out stated at the change's end. Applied O3: a root's tags/coverage attribute are defined structural absence, not change 4 unavailability — no finding, no exit-1 consequence. Applied O4: preview edits explicitly carry no replacement text; the preview is a safety report, not an edit script whose external application would bypass the journaled mapping. Applied O5: the exit-2 JSON error document's trigger is delimited — --json among the arguments, or a JSON-only surface with no flag needed; the compatibility note now points at that delimitation. Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index 2c1c78b..10137b4 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -38,13 +38,13 @@ The following describes the behaviors `SPEC.md` is to define, at the rigor `SPEC ### 1. Reference occurrences -Introduce the concept of a **reference occurrence**: one textual spelling that records a dependency-kind edge — a `d` reference (each entry of a `d` array separately), an MDX `{text(...)}` embedding, a TypeScript `text(...)` call, or a TypeScript dependency marker. Each occurrence carries: the referencing file, its source range (byte offsets, per the existing range convention), its edge kind, its source graph node (requirement node or code location), and its resolved target's identity. +Introduce the concept of a **reference occurrence**: one textual spelling that records a dependency-kind edge — a `d` reference (each entry of a `d` array separately), an MDX `{text(...)}` embedding, a TypeScript `text(...)` call, or a TypeScript dependency marker. Each occurrence carries: the referencing file, its source range (byte offsets, per the existing range convention), its edge kind, its source graph node, and its resolved target's identity. The source graph node (requirement node or code location) is one datum: the node's identity together with that node's own source range — a section's construct range or a root's whole-file range per the existing convention, a code location's range per change 2. - Edges remain sets; occurrences are the positions behind them. Duplicate references that collapse to a single edge each remain distinct occurrences. - Occurrence spans are exact per kind: a `d` reference occurrence spans that one reference's own expression (each entry of an array separately — never the array or the prop); an MDX embedding occurrence spans the entire `{text(...)}` expression container, opening brace through closing brace — the whole construct Markdown compilation replaces, on which change 3's byte classification depends; a TypeScript occurrence spans the referencing expression itself (change 2). - A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. The file filter follows the existing file-glob convention — the glob rules existing file filters use, their invalid-pattern usage errors included. It is a set restriction over discovered files, not an existence assertion: the enumeration's consulted domain is the discovered files it admits (change 4); a glob admitting none admits the empty set (an empty, finding-free answer, exit 0); and no unknown-file usage error exists on it. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included), and acceptance is syntactic: a well-formed identity of a targetable kind is a valid filter value whatever the workspace currently contains. The filter selects the occurrences whose resolved target it names; when it names no currently resolvable node — its file not discovered, its file masked, its bearer's identity undefined (change 4), or no such node in a parseable file — the selection is empty, since a spelling that does not resolve records no occurrence, and the consulted domain's findings (change 4) accompany the empty answer. An empty, finding-free answer (exit 0) is therefore definitive over the consulted domain: nothing in the files the enumeration consulted references the identity. When no file filter narrows the domain, the domain is the entire discovered set and the guarantee is absolute — nothing in the workspace references the identity; under a narrowing file filter it is exactly domain-wide — a file outside the admitted set can still hold a resolving occurrence, which the answer neither reports nor denies. On the target filter, only a malformed identity spelling is a usage error. This deliberately departs, for that filter, from the existing convention classing unknown node identities in arguments as usage errors: the mid-edit question "who references this section?" must answer, not refuse, exactly while the section's file is broken. Finding reporting and exit mapping cover the enumeration's whole consulted domain, defined in change 4. - Constructs that record no edge produce no occurrence (unused import bindings, type-only bindings, shadowed identifiers, and reference spellings that are dynamic or do not resolve — the invalid ones are located by diagnostics instead, change 6). -- Availability follows change 4: occurrence positions and spellings are per-file, parse-local data — only a masked (unparseable) file loses them — while occurrence existence is resolution-dependent: a spelling that does not resolve to exactly one target records no edge and no occurrence. An occurrence inside a section whose identity change 4 leaves undefined keeps its position, kind, and resolved target, with its source-node identity explicitly unavailable. +- Availability follows change 4: occurrence positions and spellings are per-file, parse-local data — only a masked (unparseable) file loses them — while occurrence existence is resolution-dependent: a spelling that does not resolve to exactly one target records no edge and no occurrence. An occurrence inside a section whose identity change 4 leaves undefined keeps its position, kind, and resolved target, with its source graph node explicitly unavailable — identity and range withheld together, since the datum is the node; the enclosing construct's position stays on view through change 3. - Ordering is deterministic: by file path (byte order), then by range start, then by range end. Distinct occurrences are distinct spellings occupying distinct spans, so identical ranges do not occur and this order is total; no further tiebreak exists. ### 2. Source ranges for code @@ -52,21 +52,21 @@ Introduce the concept of a **reference occurrence**: one textual spelling that r Amend the source-range concept (currently: "code locations carry no source range"): - Every code location gains a source range: for a named code unit, the construct's own characters (analogous to a section's construct range); for a whole-file location, the entire file (analogous to a root node). Document-order-disambiguated units (`path#unit@N`) each carry their own occurrence's range. Where one declaration derives several named units, each unit's range is the construct that binds its own name: a function- or class-valued variable declaration's range spans its own name through its initializer, not the enclosing multi-declaration statement, while the nested units a dotted namespace name derives share the single namespace declaration's range, which is the one construct binding them all. A default export follows the same principle: when the exported construct is named, the unit's range is that construct's own range; the unit named `default` that an anonymous exported construct derives takes the whole export declaration's range — the construct that binds that name. -- Everywhere the specification outputs a graph node with a source range, a code location now qualifies — in particular in occurrence enumeration (change 1), `query` results that return code locations, and review payloads that present code-location scopes. +- Exactly two outputs gain code-location ranges, named here rather than left to a general rule. Occurrence enumeration: an occurrence record presents its source graph node with that node's own range (change 1) — the surface that makes every code unit's range reachable, since a code location enters the graph's edges only as a source and every edge from it is recorded by at least one occurrence. Review payloads: the present-node rule generalizes from requirement nodes to graph nodes, so a present code-location scope carries its source range exactly as a present requirement node does; an absent node still enters as identity and presence alone. No other output changes shape: edge endpoints in `query` results — `edges` rows, `reachable` witness paths, the per-node incoming and outgoing edge lists — remain bare identities, for code locations exactly as for requirement nodes today, and every other position presenting a graph node as a bare identity keeps that form. - TypeScript reference occurrences (markers, `text(...)` calls) carry the range of the referencing expression itself, distinct from the enclosing unit's range — exact per form, matching the explicitness of the `d` and MDX cases: a `text(...)` occurrence spans the entire call expression, callee through closing parenthesis, argument included — the expression that records the edge — and a marker occurrence spans the bare reference chain alone, exclusive of any statement terminator. ### 3. Whole-document structural view A query surface returns, for a spec source file, everything needed to overlay structure on the raw MDX bytes in a single call: -- the root node and the full section tree in document order. The tree is positional — defined by construct nesting alone — so it exists for every parseable file whatever findings the file carries (change 4). Each node carries its source range, its raw attribute spellings as parsed, and — each where defined, explicitly unavailable otherwise (change 4) — its node identity, tags, coverage attribute, and (on request) own and subtree text; +- the root node and the full section tree in document order. The tree is positional — defined by construct nesting alone — so it exists for every parseable file whatever findings the file carries (change 4). Each node carries its source range, its raw attribute spellings as parsed, and — each where defined, explicitly unavailable otherwise (change 4) — its node identity, tags, coverage attribute, and (on request) own and subtree text. A root's tags and coverage attribute are a distinct, defined case: structurally absent under existing conventions (absent for roots), they are reported as absent, never as unavailable — structural absence implies no finding and, unlike change 4 unavailability, carries no exit-1 consequence; - for each non-root node, the decomposition of its construct range: the opening tag's range and the closing tag's range (a self-closing section has an opening-tag range only; a root node spans the whole file and has neither). An interactive consumer needs the tags separately from the content they enclose — to render a section header in place of its opening tag, hide or fold what a tag pair encloses, and land navigation on a section's tag rather than selecting its entire construct; - every import declaration, valid or invalid, with its source range, its binding name where one is bound, and its resolved target file where specifier form and discovery define one — explicitly unavailable otherwise (change 4), with the invalidity itself a located finding of change 6; - every reference occurrence in the file (change 1), positioned in document order; - every MDX comment's source range. With tags, imports, comments, and embedding occurrences located (an embedding occurrence's span is its full braced container, change 1), every construct that Markdown compilation removes is positioned, so on a finding-free file a consumer can classify each byte as annotation or content without re-parsing the MDX. On an imperfect file the classification is joint with change 6: spellings and constructs that produce no occurrence or view entry (change 4) are located by their findings' ranges, and the two surfaces together still position every removable construct; - position resolution as a direct query: given the file and a byte offset, the innermost enclosing section and, when the offset lies within a reference occurrence, that occurrence and its resolved target. Resolution is by range containment and is total over the file: every within-file offset lies in the root's range, so bytes inside imports, comments, and content between sections resolve to the innermost section construct containing them — the root when none does; the offset equal to the file length (the caret position at end of file) resolves to the root; a greater offset is a usage error, per existing conventions. The same resolution must also be derivable from the view's data alone, so both index-building consumers and lightweight ones that keep no client-side index are served. -The view is defined for discovered spec sources: one file, a set restricted by the existing file-glob convention, or all of them — a multi-file request returns per-file views, ordered by byte order of workspace-relative path per existing conventions, in one deterministic JSON document, so a consumer can index an entire workspace in a single invocation. A file named outside the discovered set is a usage error, per existing conventions. +The view is defined for discovered spec sources: one file, a set restricted by the existing file-glob convention, or all of them — a multi-file request returns per-file views, ordered by byte order of workspace-relative path per existing conventions, in one deterministic JSON document, so a consumer can index an entire workspace in a single invocation. A file named outside the discovered set is a usage error, per existing conventions; the glob form is a set restriction exactly as in change 1 — a glob admitting no discovered file admits the empty set, an empty, finding-free answer, exit 0. ### 4. Availability on imperfect workspaces @@ -74,8 +74,8 @@ The structural surfaces of changes 1 and 3 exist to serve an editor while a pers - Structure derived from one file's parse — the positional section tree (change 3), every construct's ranges and their decompositions, raw attribute and import spellings, comments, and reference-occurrence positions — remains available while other files are invalid and while the file itself carries findings of either level: resolution-level (unresolved references, cycle participation, and similar) and per-file structural (sections with missing, duplicate, or structurally invalid IDs; malformed segments; invalid props; invalid constructs) alike. - Only an unparseable file (or content the existing masking rules already hide) loses its structural view; masking is per file, and the surfaces still answer for every other requested file. -- What findings make undefined is interpreted data, never structure. A section's node identity is defined exactly when its own `id` and each enclosing section's `id` are present and well-formed, each satisfies the structural-ID rules, and no other section of the file spells the same identity — duplicate spellings leave every bearer's identity undefined; no winner is picked. A section's interpreted tags and coverage attribute are defined exactly when its parsed props define them unambiguously (a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined; the raw spelling is still reported). A section whose identity is undefined still occupies its tree position with its ranges and raw spellings, and the occurrences inside it keep their positions, kinds, and resolved targets, with their source-node identity undefined. Invalid constructs outside change 3's inventory (stray elements, expression containers, exports) get no view entry; their findings' ranges locate them (change 6). -- Data these rules or resolution leave undefined — section and occurrence-source identities as above, an import's resolved target when specifier form or discovery defines none, and expanded own and subtree text — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. Expanded text has an exact definedness rule: a node's own (respectively subtree) text is defined exactly when every embedding the expansion transitively reaches — each `text(...)` spelling in the node's own contribution (respectively anywhere in its subtree), and recursively each one inside every embedded target's subtree — records an occurrence (change 1), and the recursion re-enters no node already being expanded (an embedding cycle); one unresolved spelling or one cycle on the expansion path makes the value unavailable as a whole — partial expansion is fabrication and never occurs. +- What findings make undefined is interpreted data, never structure. A section's node identity is defined exactly when its own `id` and each enclosing section's `id` are present and well-formed, each satisfies the structural-ID rules, and no other section of the file spells the same identity — duplicate spellings leave every bearer's identity undefined; no winner is picked. A section's interpreted tags and coverage attribute are defined exactly when its parsed props define them unambiguously (a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined; the raw spelling is still reported). A section whose identity is undefined still occupies its tree position with its ranges and raw spellings, and the occurrences inside it keep their positions, kinds, and resolved targets, with their source graph node — one datum, identity and range (change 1) — undefined. Invalid constructs outside change 3's inventory (stray elements, expression containers, exports) get no view entry; their findings' ranges locate them (change 6). +- Data these rules or resolution leave undefined — section identities and occurrence source nodes as above, an import's resolved target when specifier form or discovery defines none, and expanded own and subtree text — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. Expanded text has an exact definedness rule: a node's own (respectively subtree) text is defined exactly when every embedding the expansion transitively reaches — each `text(...)` spelling in the node's own contribution (respectively anywhere in its subtree), and recursively each one inside every embedded target's subtree — records an occurrence (change 1), and the recursion re-enters no node already being expanded (an embedding cycle); one unresolved spelling or one cycle on the expansion path makes the value unavailable as a whole — partial expansion is fabrication and never occurs. - A reference occurrence, by contrast, never reports an unavailable target: a spelling that does not resolve to exactly one target — an unknown target; a unique bearer whose identity the definedness rule above leaves undefined; or an ambiguous one, every duplicate bearer's identity undefined — records no edge and therefore no occurrence, and its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid, in every parseable file; spellings inside an unparseable file are hidden with the rest of it, pointed to only by that file's parse-failure finding. - Every answer has a consulted domain of files, and the findings of every file in that domain are reported alongside the answer — a masked file's parse-failure finding included. The domain is: for the view of change 3, the requested files and — when expanded text is requested — every further file those expansions consult (each embedded target's file the expansion transitively reaches), because the finding that blocks an expansion, an unresolved spelling or a cycle participation, can lie in a consulted file the request never named; for an occurrence enumeration of change 1, every discovered file its file filter admits — and, when no file filter narrows it, the entire discovered set, spec and code sources alike, because a masked file anywhere could conceal occurrences the enumeration would otherwise return. The mapping onto the existing exit-code partition is: an invocation whose answer reports any finding or any explicitly-unavailable datum exits 1; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their existing precedence. A possibly-incomplete answer is therefore never silent: the finding always accompanies it, and the exit code says so. The full answer document is emitted in the 0 and 1 cases alike — exit 1 signals imperfection and never withholds the answer. @@ -85,11 +85,11 @@ Existing commands keep their current all-or-nothing read semantics; this availab A query surface reports the machine-readable shape of the workspace, so an external editor never edits files xspec owns and never misses files xspec reads: -- how the resolved workspace root anchors to the invocation: the workspace root and the configuration file are identified relative to the invocation working directory — invocation input, exactly as existing conventions already treat `--config` resolution — and never as absolute paths. Configuration discovery thereby has one authority: a tool invoking xspec from an arbitrary directory can map the workspace-relative paths in every output to real files without re-implementing the upward search, which is an editing-safety requirement — a consumer that guesses the root wrong edits the wrong files; +- how the resolved workspace root anchors to the invocation: the workspace root and the configuration file are identified relative to the invocation working directory — invocation input, exactly as existing conventions already treat `--config` resolution — and, outside the one platform case stated at the end of this change, never as absolute paths. Configuration discovery thereby has one authority: a tool invoking xspec from an arbitrary directory can map the workspace-relative paths in every output to real files without re-implementing the upward search, which is an editing-safety requirement — a consumer that guesses the root wrong edits the wrong files; - the resolved configuration view: spec and code groups with their glob lists and kinds, Markdown emission state and destinations, and coverage profiles and policy rules, each name with its full definition — every profile and rule carried with its complete definition, never as a bare name. A group reference inside a profile or rule stays the configured group name, never its glob expansion: the name resolves against the group list this same view reports, so nothing is lost when two groups share one definition; - every discovered source file with its group memberships; - the derived-file map: per source file, the generated module and companion paths and the Markdown emit destination (when enabled), plus any other recorded derived paths; -- the graph-data area: the location under which graph data is kept, reported unconditionally — the recorded derived-file map can lag or be empty, but an editor must know the area before any build has run. The area's classification is a write reservation, not per-occupant ownership: the area is reserved for xspec's writes — per the existing derived-file rules, a derived-file write there replaces whatever occupies its path — so an external tool must never create, edit, or keep content of its own anywhere under it. Individual paths under the area are classified exactly as the inventory reports them: the durable paths reported below are durable, and a recorded derived path lying under the area is derived, like every recorded derived path. Every other path under the area falls under one rule, stated here once and holding everywhere in this inventory: it is unattributed. The graph data xspec keeps under the area is derived, rebuild-recoverable content, but its layout is deliberately not enumerated, so an unreported path may equally be xspec's graph data or foreign content: the inventory neither lists it, nor claims it for xspec, nor calls it rebuild-recoverable — rebuilding regenerates derived files and removes recorded ones no longer generated, but neither reproduces nor removes a path of which no record exists, so deleting an unattributed path is undone by nothing. For the same reasons the area is never presented as a deletable or wholesale-regenerable unit: the durable files inside it are neither, and an unattributed path is not recoverable at all; +- the graph-data area: the location under which graph data is kept, reported unconditionally — the recorded derived-file map can lag or be empty, but an editor must know the area before any build has run. The area's classification is a write reservation, not per-occupant ownership: the area is reserved for xspec's writes — per the existing derived-file rules, a derived-file write there replaces whatever occupies its path — so an external tool must never create, edit, or keep content of its own anywhere under it. Individual paths under the area are classified exactly as the inventory reports them: the durable paths reported below are durable, and a recorded derived path lying under the area is derived, like every recorded derived path. Every other path under the area falls under one rule, stated here once and holding everywhere in this inventory: it is unattributed. The graph data xspec keeps under the area is derived, rebuild-recoverable content, but its layout is deliberately not enumerated, so an unattributed path may equally be xspec's graph data or foreign content — and the two cases diverge exactly at deletion: deleted graph data returns with the next successful build or read-time refresh, per the existing rules, while foreign content is recorded nowhere, so nothing ever reproduces it. The inventory neither lists the path, nor claims it for xspec, nor says which case holds; telling them apart is precisely what it declines to enable, and the safety rule rests on that unknowability, not on any claim of irrecoverability: an external tool must treat every unattributed path as undeletable, because it cannot exclude the foreign case — the one whose deletion is undone by nothing. For the same reason the area is never presented as a deletable or wholesale-regenerable unit: the durable files inside it are neither, and whether any particular unattributed path would return is unknowable from the inventory; - the durable files: the journal path with whether anything presently occupies it — an absent journal is an empty journal, per existing journal semantics, and this datum surfaces that — and existing review-session files. The inventory contains no absolute paths and no environment-dependent content beyond the invocation anchoring above (a function of the invocation, like `--config` resolution — not of the machine), consistent with existing determinism and security conventions. When the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives), the anchoring is reported in the platform's absolute form — the one further case of the stated exception, still a pure function of invocation input. @@ -104,7 +104,7 @@ Sharpen the validation-error contract so an external tool can render findings in - Every error that locates inside a source file carries the file and a source range (byte offsets) for the offending construct, at the precision the condition allows; conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. For missing configuration with no `--config` given — the one condition where no configuration file exists to be concerned — the concerned path is the directory the failed upward search started from, the invocation working directory: invocation input, deterministic per invocation exactly as change 5's anchoring is, so the concerned-path datum is total over these conditions. - The JSON report form presents these fields for every finding, preserving the existing requirements that all conditions are reported together and that JSON carries the same information as the human report. - The same contract covers operation refusals: each distinct reason `rename` and `move` refuse — exactly what the previews of change 7 report — carries a stable machine-readable code and the file, source range, or identity it concerns, so a refusal renders as precisely as a finding. Refusals are findings under the existing exit-code partition, so the JSON report form above already carries them. -- Machine-readable delivery is closed over the outcome classes: under the JSON output convention, an invocation that fails with a usage or configuration error (exit 2) emits a single JSON document as its entire standard output reporting the error — carrying, for conditions with a defined code (configuration errors included), the stable code and the concerned file or path above — amending the existing rule that such an error leaves standard output empty. Exit codes, error precedence, and human-readable standard-error text are unchanged. Without this channel, configuration errors — the one class that precedes and blocks every surface of changes 1, 3, 5, and 7 — would be the one class an external tool cannot consume. +- Machine-readable delivery is closed over the outcome classes: whenever JSON output is in effect, an invocation that fails with a usage or configuration error (exit 2) emits a single JSON document as its entire standard output reporting the error — carrying, for conditions with a defined code (configuration errors included), the stable code and the concerned file or path above — amending the existing rule that such an error leaves standard output empty. JSON output is in effect in exactly two cases: `--json` appears among the invocation's arguments — governing error delivery even when the arguments are themselves the error, an unknown command or flag included — or the invoked surface is JSON-only, a single JSON document its only output form with or without `--json`, as the existing single-document surfaces are and added surfaces may be defined; no flag need be present there. Outside these two cases the existing empty-standard-output rule stands. Exit codes, error precedence, and human-readable standard-error text are unchanged. Without this channel, configuration errors — the one class that precedes and blocks every surface of changes 1, 3, 5, and 7 — would be the one class an external tool cannot consume. - Diagnostics are the locating surface for constructs that record nothing in the graph: an invalid, dynamic, or unresolved reference spelling has no occurrence (changes 1, 4), so its range reaches consumers here. ### 7. Refactoring previews @@ -112,7 +112,7 @@ Sharpen the validation-error contract so an external tool can render findings in `rename` and `move` gain a preview mode that performs the full validation and planning of the real operation and reports, without modifying anything: - the complete identity mapping the operation would journal; -- every file the operation would rewrite or relocate, with every edit the operation would make in it — each located by a range in current, pre-operation coordinates and classed by what it is. The classes cover everything the operations edit, not only reference occurrences: reference-occurrence rewrites (change 1's occurrences — `d` references, `text(...)` references, TypeScript markers); `id`-attribute rewrites (rename's and the section move's re-identification); import edits — specifier rewrites, import additions, and import removals; the section move's origin deletion — one range spanning every byte the origin edit removes: the construct's own characters, extended over the leftover whitespace and line terminator of a line the existing line-drop rule additionally drops (bytes contiguous with the construct by construction), so the adjunct drop lies inside this class's range rather than forming a class of its own — its target insertion point, and the self-closing-target-parent rewrite when one applies; and the file move's relocation of the file itself. A section move whose target file does not yet exist reports that file's creation as its own class, with the insertion point at the start of the new file — the one reported location without pre-operation coordinates; every range in a file that exists stays in current, pre-operation coordinates; +- every file the operation would rewrite or relocate, with every edit the operation would make in it — each located by a range in current, pre-operation coordinates and classed by what it is. An edit is reported without replacement text: its class and the identity mapping state what changes, and the resulting bytes are observable only by running the operation — the preview is a safety report, not an edit script for external application, which would bypass the journaled mapping. The classes cover everything the operations edit, not only reference occurrences: reference-occurrence rewrites (change 1's occurrences — `d` references, `text(...)` references, TypeScript markers); `id`-attribute rewrites (rename's and the section move's re-identification); import edits — specifier rewrites, import additions, and import removals; the section move's origin deletion — one range spanning every byte the origin edit removes: the construct's own characters, extended over the leftover whitespace and line terminator of a line the existing line-drop rule additionally drops (bytes contiguous with the construct by construction), so the adjunct drop lies inside this class's range rather than forming a class of its own — its target insertion point, and the self-closing-target-parent rewrite when one applies; and the file move's relocation of the file itself. A section move whose target file does not yet exist reports that file's creation as its own class, with the insertion point at the start of the new file — the one reported location without pre-operation coordinates; every range in a file that exists stays in current, pre-operation coordinates; - the derived-file consequences, in both directions, as the identity-relevant delta: the derived paths the operation would newly generate — paths where nothing is currently generated — and the recorded derived paths it would remove as no longer generated, the old module path after a file move included. The delta is the whole report: a successful operation finishes by regenerating every derived file, so the full regeneration set is workspace-constant, already named by the inventory of change 5, and carries no information about the operation — the delta is what the identity changes cause. A preview succeeds exactly when the real operation would proceed and is refused exactly when — and reporting what — the real operation would refuse, with the same exit-code classification — an equivalence over workspace state, validation and planning, not over scheduling: the refusal that meets a mutating command while another runs applies to the real operation only, never to its preview, which the concurrency rules class as non-mutating. A preview writes nothing (no sources, no journal, no derived files, no graph data) and is therefore a non-mutating command under the concurrency rules, safe to run while readers run. Preview output is byte-deterministic. @@ -130,7 +130,7 @@ Dependency visualization and change overlays already rest on: `query node`/`node ## Compatibility and rigor notes -- All additions obey the existing global conventions: single-JSON-document output, same-information JSON, byte-determinism, byte-wise ordering and comparison, workspace-relative paths (invocation-anchored content is the stated exception — change 5's anchoring and the configuration-error concerned paths of change 6 — itself deterministic per invocation), the exit-code partition, and configuration-error precedence — with the two amendments stated above: under the JSON convention, exit-2 errors now emit a JSON error document (change 6), and configuration-error precedence does not reach the workspace-independent identification surface (change 8). +- All additions obey the existing global conventions: single-JSON-document output, same-information JSON, byte-determinism, byte-wise ordering and comparison, workspace-relative paths (invocation-anchored content is the stated exception — change 5's anchoring and the configuration-error concerned paths of change 6 — itself deterministic per invocation), the exit-code partition, and configuration-error precedence — with the two amendments stated above: exit-2 errors now emit a JSON error document whenever JSON output is in effect, as change 6 delimits, and configuration-error precedence does not reach the workspace-independent identification surface (change 8). - The availability contract (change 4) and the syntactic target-filter acceptance of change 1 are deliberate, surface-scoped deltas from the existing commands' all-or-nothing read refusal and unknown-identity usage errors; the existing commands keep their semantics unchanged, and the contract's refinement must stay deterministic and free of partial-resolution fabrication. - New surfaces are reads (or, for previews, validated no-op plans); none introduces new durable state, none writes through any new path, and none weakens the security posture of test seams — exposed data is workspace-local content only. - Range data added for code, occurrences, tag decompositions, and comments follows the existing byte-offset range convention so consumers handle one range model everywhere. From 3643efad39d1a81e8b1c70bdf14c62394da7fad0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:00:08 +0000 Subject: [PATCH 012/226] sdg(phase-3): apply iteration-7 review to patch 0001 (diagnostics cardinality, view domain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: I1, I2, O1, O2, O3, O4. - I1: change 6 states the multi-construct location rule — one finding per condition instance carrying a location for every participating construct (duplicate-ID bearers, colliding import declarations, a cycle's full path), context entities as identity data; extended to refusal reasons (cycle a refused move would create). - I2: change 3 assigns the wrong-kind usage error to a discovered code source named directly and fixes the view glob's restriction universe as the discovered spec sources. - O1: change 2 scopes the absent-node clause to the range datum alone, leaving historical-text payload rules untouched. - O2: change 1 states that file and target filters combine conjunctively in one invocation. - O3: split the heaviest sentences in change 4's consulted-domain bullet and change 5's graph-data-area bullet. - O4: change 6 states the missing-configuration concerned path's reported form (change 5's anchoring; degenerate self-reference). Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index 10137b4..678e662 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -42,7 +42,7 @@ Introduce the concept of a **reference occurrence**: one textual spelling that r - Edges remain sets; occurrences are the positions behind them. Duplicate references that collapse to a single edge each remain distinct occurrences. - Occurrence spans are exact per kind: a `d` reference occurrence spans that one reference's own expression (each entry of an array separately — never the array or the prop); an MDX embedding occurrence spans the entire `{text(...)}` expression container, opening brace through closing brace — the whole construct Markdown compilation replaces, on which change 3's byte classification depends; a TypeScript occurrence spans the referencing expression itself (change 2). -- A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. The file filter follows the existing file-glob convention — the glob rules existing file filters use, their invalid-pattern usage errors included. It is a set restriction over discovered files, not an existence assertion: the enumeration's consulted domain is the discovered files it admits (change 4); a glob admitting none admits the empty set (an empty, finding-free answer, exit 0); and no unknown-file usage error exists on it. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included), and acceptance is syntactic: a well-formed identity of a targetable kind is a valid filter value whatever the workspace currently contains. The filter selects the occurrences whose resolved target it names; when it names no currently resolvable node — its file not discovered, its file masked, its bearer's identity undefined (change 4), or no such node in a parseable file — the selection is empty, since a spelling that does not resolve records no occurrence, and the consulted domain's findings (change 4) accompany the empty answer. An empty, finding-free answer (exit 0) is therefore definitive over the consulted domain: nothing in the files the enumeration consulted references the identity. When no file filter narrows the domain, the domain is the entire discovered set and the guarantee is absolute — nothing in the workspace references the identity; under a narrowing file filter it is exactly domain-wide — a file outside the admitted set can still hold a resolving occurrence, which the answer neither reports nor denies. On the target filter, only a malformed identity spelling is a usage error. This deliberately departs, for that filter, from the existing convention classing unknown node identities in arguments as usage errors: the mid-edit question "who references this section?" must answer, not refuse, exactly while the section's file is broken. Finding reporting and exit mapping cover the enumeration's whole consulted domain, defined in change 4. +- A query surface enumerates occurrences, filterable at least by source file and by target node, so "find all references to this requirement, with positions" and "list every reference this file makes" are single calls. The two filters combine conjunctively in one invocation, per the existing filter-combination convention: the file filter fixes the enumeration's consulted domain (change 4), and the target filter selects within whatever domain is in effect — "who in these files references this node" is likewise a single call. The file filter follows the existing file-glob convention — the glob rules existing file filters use, their invalid-pattern usage errors included. It is a set restriction over discovered files, not an existence assertion: the enumeration's consulted domain is the discovered files it admits (change 4); a glob admitting none admits the empty set (an empty, finding-free answer, exit 0); and no unknown-file usage error exists on it. Incoming enumeration must accept any graph-node identity a reference can target (root nodes included), and acceptance is syntactic: a well-formed identity of a targetable kind is a valid filter value whatever the workspace currently contains. The filter selects the occurrences whose resolved target it names; when it names no currently resolvable node — its file not discovered, its file masked, its bearer's identity undefined (change 4), or no such node in a parseable file — the selection is empty, since a spelling that does not resolve records no occurrence, and the consulted domain's findings (change 4) accompany the empty answer. An empty, finding-free answer (exit 0) is therefore definitive over the consulted domain: nothing in the files the enumeration consulted references the identity. When no file filter narrows the domain, the domain is the entire discovered set and the guarantee is absolute — nothing in the workspace references the identity; under a narrowing file filter it is exactly domain-wide — a file outside the admitted set can still hold a resolving occurrence, which the answer neither reports nor denies. On the target filter, only a malformed identity spelling is a usage error. This deliberately departs, for that filter, from the existing convention classing unknown node identities in arguments as usage errors: the mid-edit question "who references this section?" must answer, not refuse, exactly while the section's file is broken. Finding reporting and exit mapping cover the enumeration's whole consulted domain, defined in change 4. - Constructs that record no edge produce no occurrence (unused import bindings, type-only bindings, shadowed identifiers, and reference spellings that are dynamic or do not resolve — the invalid ones are located by diagnostics instead, change 6). - Availability follows change 4: occurrence positions and spellings are per-file, parse-local data — only a masked (unparseable) file loses them — while occurrence existence is resolution-dependent: a spelling that does not resolve to exactly one target records no edge and no occurrence. An occurrence inside a section whose identity change 4 leaves undefined keeps its position, kind, and resolved target, with its source graph node explicitly unavailable — identity and range withheld together, since the datum is the node; the enclosing construct's position stays on view through change 3. - Ordering is deterministic: by file path (byte order), then by range start, then by range end. Distinct occurrences are distinct spellings occupying distinct spans, so identical ranges do not occur and this order is total; no further tiebreak exists. @@ -52,7 +52,7 @@ Introduce the concept of a **reference occurrence**: one textual spelling that r Amend the source-range concept (currently: "code locations carry no source range"): - Every code location gains a source range: for a named code unit, the construct's own characters (analogous to a section's construct range); for a whole-file location, the entire file (analogous to a root node). Document-order-disambiguated units (`path#unit@N`) each carry their own occurrence's range. Where one declaration derives several named units, each unit's range is the construct that binds its own name: a function- or class-valued variable declaration's range spans its own name through its initializer, not the enclosing multi-declaration statement, while the nested units a dotted namespace name derives share the single namespace declaration's range, which is the one construct binding them all. A default export follows the same principle: when the exported construct is named, the unit's range is that construct's own range; the unit named `default` that an anonymous exported construct derives takes the whole export declaration's range — the construct that binds that name. -- Exactly two outputs gain code-location ranges, named here rather than left to a general rule. Occurrence enumeration: an occurrence record presents its source graph node with that node's own range (change 1) — the surface that makes every code unit's range reachable, since a code location enters the graph's edges only as a source and every edge from it is recorded by at least one occurrence. Review payloads: the present-node rule generalizes from requirement nodes to graph nodes, so a present code-location scope carries its source range exactly as a present requirement node does; an absent node still enters as identity and presence alone. No other output changes shape: edge endpoints in `query` results — `edges` rows, `reachable` witness paths, the per-node incoming and outgoing edge lists — remain bare identities, for code locations exactly as for requirement nodes today, and every other position presenting a graph node as a bare identity keeps that form. +- Exactly two outputs gain code-location ranges, named here rather than left to a general rule. Occurrence enumeration: an occurrence record presents its source graph node with that node's own range (change 1) — the surface that makes every code unit's range reachable, since a code location enters the graph's edges only as a source and every edge from it is recorded by at least one occurrence. Review payloads: the present-node range rule generalizes from requirement nodes to graph nodes, so a present code-location scope carries its source range exactly as a present requirement node does, and an absent node of either kind carries no range. The generalization touches the range datum alone; every other payload rule stands — the historical text an absent requirement node carries, and code locations' having no text value, included. No other output changes shape: edge endpoints in `query` results — `edges` rows, `reachable` witness paths, the per-node incoming and outgoing edge lists — remain bare identities, for code locations exactly as for requirement nodes today, and every other position presenting a graph node as a bare identity keeps that form. - TypeScript reference occurrences (markers, `text(...)` calls) carry the range of the referencing expression itself, distinct from the enclosing unit's range — exact per form, matching the explicitness of the `d` and MDX cases: a `text(...)` occurrence spans the entire call expression, callee through closing parenthesis, argument included — the expression that records the edge — and a marker occurrence spans the bare reference chain alone, exclusive of any statement terminator. ### 3. Whole-document structural view @@ -66,7 +66,7 @@ A query surface returns, for a spec source file, everything needed to overlay st - every MDX comment's source range. With tags, imports, comments, and embedding occurrences located (an embedding occurrence's span is its full braced container, change 1), every construct that Markdown compilation removes is positioned, so on a finding-free file a consumer can classify each byte as annotation or content without re-parsing the MDX. On an imperfect file the classification is joint with change 6: spellings and constructs that produce no occurrence or view entry (change 4) are located by their findings' ranges, and the two surfaces together still position every removable construct; - position resolution as a direct query: given the file and a byte offset, the innermost enclosing section and, when the offset lies within a reference occurrence, that occurrence and its resolved target. Resolution is by range containment and is total over the file: every within-file offset lies in the root's range, so bytes inside imports, comments, and content between sections resolve to the innermost section construct containing them — the root when none does; the offset equal to the file length (the caret position at end of file) resolves to the root; a greater offset is a usage error, per existing conventions. The same resolution must also be derivable from the view's data alone, so both index-building consumers and lightweight ones that keep no client-side index are served. -The view is defined for discovered spec sources: one file, a set restricted by the existing file-glob convention, or all of them — a multi-file request returns per-file views, ordered by byte order of workspace-relative path per existing conventions, in one deterministic JSON document, so a consumer can index an entire workspace in a single invocation. A file named outside the discovered set is a usage error, per existing conventions; the glob form is a set restriction exactly as in change 1 — a glob admitting no discovered file admits the empty set, an empty, finding-free answer, exit 0. +The view is defined for discovered spec sources: one file, a set restricted by the existing file-glob convention, or all of them — a multi-file request returns per-file views, ordered by byte order of workspace-relative path per existing conventions, in one deterministic JSON document, so a consumer can index an entire workspace in a single invocation. Naming a file directly asserts membership in the view's domain, and both failures of that assertion are usage errors: a file outside the discovered set, per existing conventions, and a discovered file the view is not defined for — a code source has no structural view, so naming one directly is the existing conventions' wrong-kind usage error, exactly as a code group's name is where a spec group's is required. The glob form is a set restriction exactly as in change 1, restricting over the view's domain — the discovered spec sources, not the whole discovered set: a glob admits the discovered spec sources it matches, and one admitting none — matching no discovered file, or only code sources — admits the empty set, an empty, finding-free answer, exit 0. ### 4. Availability on imperfect workspaces @@ -77,7 +77,7 @@ The structural surfaces of changes 1 and 3 exist to serve an editor while a pers - What findings make undefined is interpreted data, never structure. A section's node identity is defined exactly when its own `id` and each enclosing section's `id` are present and well-formed, each satisfies the structural-ID rules, and no other section of the file spells the same identity — duplicate spellings leave every bearer's identity undefined; no winner is picked. A section's interpreted tags and coverage attribute are defined exactly when its parsed props define them unambiguously (a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined; the raw spelling is still reported). A section whose identity is undefined still occupies its tree position with its ranges and raw spellings, and the occurrences inside it keep their positions, kinds, and resolved targets, with their source graph node — one datum, identity and range (change 1) — undefined. Invalid constructs outside change 3's inventory (stray elements, expression containers, exports) get no view entry; their findings' ranges locate them (change 6). - Data these rules or resolution leave undefined — section identities and occurrence source nodes as above, an import's resolved target when specifier form or discovery defines none, and expanded own and subtree text — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. Expanded text has an exact definedness rule: a node's own (respectively subtree) text is defined exactly when every embedding the expansion transitively reaches — each `text(...)` spelling in the node's own contribution (respectively anywhere in its subtree), and recursively each one inside every embedded target's subtree — records an occurrence (change 1), and the recursion re-enters no node already being expanded (an embedding cycle); one unresolved spelling or one cycle on the expansion path makes the value unavailable as a whole — partial expansion is fabrication and never occurs. - A reference occurrence, by contrast, never reports an unavailable target: a spelling that does not resolve to exactly one target — an unknown target; a unique bearer whose identity the definedness rule above leaves undefined; or an ambiguous one, every duplicate bearer's identity undefined — records no edge and therefore no occurrence, and its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid, in every parseable file; spellings inside an unparseable file are hidden with the rest of it, pointed to only by that file's parse-failure finding. -- Every answer has a consulted domain of files, and the findings of every file in that domain are reported alongside the answer — a masked file's parse-failure finding included. The domain is: for the view of change 3, the requested files and — when expanded text is requested — every further file those expansions consult (each embedded target's file the expansion transitively reaches), because the finding that blocks an expansion, an unresolved spelling or a cycle participation, can lie in a consulted file the request never named; for an occurrence enumeration of change 1, every discovered file its file filter admits — and, when no file filter narrows it, the entire discovered set, spec and code sources alike, because a masked file anywhere could conceal occurrences the enumeration would otherwise return. The mapping onto the existing exit-code partition is: an invocation whose answer reports any finding or any explicitly-unavailable datum exits 1; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their existing precedence. A possibly-incomplete answer is therefore never silent: the finding always accompanies it, and the exit code says so. The full answer document is emitted in the 0 and 1 cases alike — exit 1 signals imperfection and never withholds the answer. +- Every answer has a consulted domain of files, and the findings of every file in that domain are reported alongside the answer — a masked file's parse-failure finding included. The domain is defined per surface. For the view of change 3 it is the requested files plus, when expanded text is requested, every further file those expansions consult — each embedded target's file the expansion transitively reaches — because the finding that blocks an expansion, an unresolved spelling or a cycle participation, can lie in a consulted file the request never named. For an occurrence enumeration of change 1 it is every discovered file the file filter admits; when no file filter narrows it, it is the entire discovered set, spec and code sources alike, because a masked file anywhere could conceal occurrences the enumeration would otherwise return. The mapping onto the existing exit-code partition is: an invocation whose answer reports any finding or any explicitly-unavailable datum exits 1; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their existing precedence. A possibly-incomplete answer is therefore never silent: the finding always accompanies it, and the exit code says so. The full answer document is emitted in the 0 and 1 cases alike — exit 1 signals imperfection and never withholds the answer. Existing commands keep their current all-or-nothing read semantics; this availability contract governs the structural surfaces of changes 1 and 3 — the other added surfaces state their own: the inventory of change 5 is unconditional on its own terms, the previews of change 7 refuse exactly when the real operation would, and the identification surface of change 8 is workspace-independent. The relationship of changes 1 and 3 to stored graph data follows the same line: these query surfaces never answer from stale data — on a workspace that passes build validation they participate in read-time refresh through the existing path, exactly as the existing read commands do, and on a workspace that does not, their answers reflect the current sources and they modify nothing: no graph data, no derived files — just as a failed refresh modifies nothing today. The inventory of change 5 states its own relationship to stored state. @@ -89,7 +89,7 @@ A query surface reports the machine-readable shape of the workspace, so an exter - the resolved configuration view: spec and code groups with their glob lists and kinds, Markdown emission state and destinations, and coverage profiles and policy rules, each name with its full definition — every profile and rule carried with its complete definition, never as a bare name. A group reference inside a profile or rule stays the configured group name, never its glob expansion: the name resolves against the group list this same view reports, so nothing is lost when two groups share one definition; - every discovered source file with its group memberships; - the derived-file map: per source file, the generated module and companion paths and the Markdown emit destination (when enabled), plus any other recorded derived paths; -- the graph-data area: the location under which graph data is kept, reported unconditionally — the recorded derived-file map can lag or be empty, but an editor must know the area before any build has run. The area's classification is a write reservation, not per-occupant ownership: the area is reserved for xspec's writes — per the existing derived-file rules, a derived-file write there replaces whatever occupies its path — so an external tool must never create, edit, or keep content of its own anywhere under it. Individual paths under the area are classified exactly as the inventory reports them: the durable paths reported below are durable, and a recorded derived path lying under the area is derived, like every recorded derived path. Every other path under the area falls under one rule, stated here once and holding everywhere in this inventory: it is unattributed. The graph data xspec keeps under the area is derived, rebuild-recoverable content, but its layout is deliberately not enumerated, so an unattributed path may equally be xspec's graph data or foreign content — and the two cases diverge exactly at deletion: deleted graph data returns with the next successful build or read-time refresh, per the existing rules, while foreign content is recorded nowhere, so nothing ever reproduces it. The inventory neither lists the path, nor claims it for xspec, nor says which case holds; telling them apart is precisely what it declines to enable, and the safety rule rests on that unknowability, not on any claim of irrecoverability: an external tool must treat every unattributed path as undeletable, because it cannot exclude the foreign case — the one whose deletion is undone by nothing. For the same reason the area is never presented as a deletable or wholesale-regenerable unit: the durable files inside it are neither, and whether any particular unattributed path would return is unknowable from the inventory; +- the graph-data area: the location under which graph data is kept, reported unconditionally — the recorded derived-file map can lag or be empty, but an editor must know the area before any build has run. The area's classification is a write reservation, not per-occupant ownership: the area is reserved for xspec's writes — per the existing derived-file rules, a derived-file write there replaces whatever occupies its path — so an external tool must never create, edit, or keep content of its own anywhere under it. Individual paths under the area are classified exactly as the inventory reports them: the durable paths reported below are durable, and a recorded derived path lying under the area is derived, like every recorded derived path. Every other path under the area falls under one rule, stated here once and holding everywhere in this inventory: it is unattributed. The graph data xspec keeps under the area is derived, rebuild-recoverable content, but its layout is deliberately not enumerated, so an unattributed path may equally be xspec's graph data or foreign content. The two cases diverge exactly at deletion: deleted graph data returns with the next successful build or read-time refresh, per the existing rules, while foreign content is recorded nowhere, so nothing ever reproduces it. The inventory neither lists the path, nor claims it for xspec, nor says which case holds: telling them apart is precisely what it declines to enable. The safety rule rests on that unknowability, not on any claim of irrecoverability: an external tool must treat every unattributed path as undeletable, because it cannot exclude the foreign case — the one whose deletion is undone by nothing. For the same reason the area is never presented as a deletable or wholesale-regenerable unit: the durable files inside it are neither, and whether any particular unattributed path would return is unknowable from the inventory; - the durable files: the journal path with whether anything presently occupies it — an absent journal is an empty journal, per existing journal semantics, and this datum surfaces that — and existing review-session files. The inventory contains no absolute paths and no environment-dependent content beyond the invocation anchoring above (a function of the invocation, like `--config` resolution — not of the machine), consistent with existing determinism and security conventions. When the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives), the anchoring is reported in the platform's absolute form — the one further case of the stated exception, still a pure function of invocation input. @@ -101,9 +101,9 @@ Availability is unconditional: no part of the inventory requires parsing sources Sharpen the validation-error contract so an external tool can render findings inline: - Every reported error condition carries a stable machine-readable code identifying which numbered condition of the validation-errors section it is. -- Every error that locates inside a source file carries the file and a source range (byte offsets) for the offending construct, at the precision the condition allows; conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. For missing configuration with no `--config` given — the one condition where no configuration file exists to be concerned — the concerned path is the directory the failed upward search started from, the invocation working directory: invocation input, deterministic per invocation exactly as change 5's anchoring is, so the concerned-path datum is total over these conditions. +- Every error that locates in source carries a location — the file and a source range (byte offsets), the range at the precision the condition allows — for each offending construct. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders inline where it stands and no representative construct is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling that records a participating dependency edge or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call — is identity data on the finding, not a further range. Conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. For missing configuration with no `--config` given — the one condition where no configuration file exists to be concerned — the concerned path is the directory the failed upward search started from, the invocation working directory, reported in change 5's anchoring form (identified relative to the working directory — for this path the degenerate self-reference): invocation input, deterministic per invocation exactly as change 5's anchoring is, so the concerned-path datum is total over these conditions. - The JSON report form presents these fields for every finding, preserving the existing requirements that all conditions are reported together and that JSON carries the same information as the human report. -- The same contract covers operation refusals: each distinct reason `rename` and `move` refuse — exactly what the previews of change 7 report — carries a stable machine-readable code and the file, source range, or identity it concerns, so a refusal renders as precisely as a finding. Refusals are findings under the existing exit-code partition, so the JSON report form above already carries them. +- The same contract covers operation refusals: each distinct reason `rename` and `move` refuse — exactly what the previews of change 7 report — carries a stable machine-readable code and the file, source range, or identity it concerns — under the location-cardinality rule above when a reason involves several constructs, the cycle a refused move would create included — so a refusal renders as precisely as a finding. Refusals are findings under the existing exit-code partition, so the JSON report form above already carries them. - Machine-readable delivery is closed over the outcome classes: whenever JSON output is in effect, an invocation that fails with a usage or configuration error (exit 2) emits a single JSON document as its entire standard output reporting the error — carrying, for conditions with a defined code (configuration errors included), the stable code and the concerned file or path above — amending the existing rule that such an error leaves standard output empty. JSON output is in effect in exactly two cases: `--json` appears among the invocation's arguments — governing error delivery even when the arguments are themselves the error, an unknown command or flag included — or the invoked surface is JSON-only, a single JSON document its only output form with or without `--json`, as the existing single-document surfaces are and added surfaces may be defined; no flag need be present there. Outside these two cases the existing empty-standard-output rule stands. Exit codes, error precedence, and human-readable standard-error text are unchanged. Without this channel, configuration errors — the one class that precedes and blocks every surface of changes 1, 3, 5, and 7 — would be the one class an external tool cannot consume. - Diagnostics are the locating surface for constructs that record nothing in the graph: an invalid, dynamic, or unresolved reference spelling has no occurrence (changes 1, 4), so its range reaches consumers here. From b24d2aa323d2ccd2ea3fb2b437d9946cf970dd72 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:18:16 +0000 Subject: [PATCH 013/226] sdg(phase-3): decide text-value and identity-uniqueness rules on imperfect files (iter 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: - I1: change 4 now states the text-value principle for files with findings — Markdown compilation's removal rules classify constructs by syntactic form, never by validity or resolution (imports removed by form, tags removed with every spelled attribute, non-inventoried constructs preserved as content); resolution enters only via text(...) replacement, already the unavailable case. - I2: identity definedness disambiguated — chain conditions (presence, well-formedness, structural validity) are inherited; uniqueness constrains the section's own spelled identity alone, so a uniquely spelled descendant of duplicate-id ancestors keeps its defined identity; defined identity does not imply defined prefixes, and occurrence resolution / the target filter turn on the referenced identity's own definedness. - O1: spread attributes appear among the view's raw attribute spellings by form; invalidity is a located finding, never a view omission. - O2: position-resolution offset domain closed — a non-non-negative-integer offset value is the same usage error as a greater offset. - O3: stable-code scoping stated as deliberate — codes cover exactly the numbered conditions plus refusal reasons; plain usage errors carry no code but still get the JSON error document when JSON output is in effect. Rejected: - O4: the Branch header's mapping is deliberate harness bookkeeping — pushes go to the designated branch and the mapping is recorded in the patch header and PR #7; stripping it mid-process would name a branch nothing pushes to. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index 678e662..df47609 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -59,12 +59,12 @@ Amend the source-range concept (currently: "code locations carry no source range A query surface returns, for a spec source file, everything needed to overlay structure on the raw MDX bytes in a single call: -- the root node and the full section tree in document order. The tree is positional — defined by construct nesting alone — so it exists for every parseable file whatever findings the file carries (change 4). Each node carries its source range, its raw attribute spellings as parsed, and — each where defined, explicitly unavailable otherwise (change 4) — its node identity, tags, coverage attribute, and (on request) own and subtree text. A root's tags and coverage attribute are a distinct, defined case: structurally absent under existing conventions (absent for roots), they are reported as absent, never as unavailable — structural absence implies no finding and, unlike change 4 unavailability, carries no exit-1 consequence; +- the root node and the full section tree in document order. The tree is positional — defined by construct nesting alone — so it exists for every parseable file whatever findings the file carries (change 4). Each node carries its source range, its raw attribute spellings as parsed (attribute position is reported by form: every attribute the tag spells appears — repeated, unknown, and spread attributes included — its invalidity a located finding of change 6, never a view omission), and — each where defined, explicitly unavailable otherwise (change 4) — its node identity, tags, coverage attribute, and (on request) own and subtree text. A root's tags and coverage attribute are a distinct, defined case: structurally absent under existing conventions (absent for roots), they are reported as absent, never as unavailable — structural absence implies no finding and, unlike change 4 unavailability, carries no exit-1 consequence; - for each non-root node, the decomposition of its construct range: the opening tag's range and the closing tag's range (a self-closing section has an opening-tag range only; a root node spans the whole file and has neither). An interactive consumer needs the tags separately from the content they enclose — to render a section header in place of its opening tag, hide or fold what a tag pair encloses, and land navigation on a section's tag rather than selecting its entire construct; - every import declaration, valid or invalid, with its source range, its binding name where one is bound, and its resolved target file where specifier form and discovery define one — explicitly unavailable otherwise (change 4), with the invalidity itself a located finding of change 6; - every reference occurrence in the file (change 1), positioned in document order; - every MDX comment's source range. With tags, imports, comments, and embedding occurrences located (an embedding occurrence's span is its full braced container, change 1), every construct that Markdown compilation removes is positioned, so on a finding-free file a consumer can classify each byte as annotation or content without re-parsing the MDX. On an imperfect file the classification is joint with change 6: spellings and constructs that produce no occurrence or view entry (change 4) are located by their findings' ranges, and the two surfaces together still position every removable construct; -- position resolution as a direct query: given the file and a byte offset, the innermost enclosing section and, when the offset lies within a reference occurrence, that occurrence and its resolved target. Resolution is by range containment and is total over the file: every within-file offset lies in the root's range, so bytes inside imports, comments, and content between sections resolve to the innermost section construct containing them — the root when none does; the offset equal to the file length (the caret position at end of file) resolves to the root; a greater offset is a usage error, per existing conventions. The same resolution must also be derivable from the view's data alone, so both index-building consumers and lightweight ones that keep no client-side index are served. +- position resolution as a direct query: given the file and a byte offset, the innermost enclosing section and, when the offset lies within a reference occurrence, that occurrence and its resolved target. Resolution is by range containment and is total over the file: every within-file offset lies in the root's range, so bytes inside imports, comments, and content between sections resolve to the innermost section construct containing them — the root when none does; the offset equal to the file length (the caret position at end of file) resolves to the root; a greater offset, like an offset value that is not a non-negative integer, is a usage error, per existing conventions. The same resolution must also be derivable from the view's data alone, so both index-building consumers and lightweight ones that keep no client-side index are served. The view is defined for discovered spec sources: one file, a set restricted by the existing file-glob convention, or all of them — a multi-file request returns per-file views, ordered by byte order of workspace-relative path per existing conventions, in one deterministic JSON document, so a consumer can index an entire workspace in a single invocation. Naming a file directly asserts membership in the view's domain, and both failures of that assertion are usage errors: a file outside the discovered set, per existing conventions, and a discovered file the view is not defined for — a code source has no structural view, so naming one directly is the existing conventions' wrong-kind usage error, exactly as a code group's name is where a spec group's is required. The glob form is a set restriction exactly as in change 1, restricting over the view's domain — the discovered spec sources, not the whole discovered set: a glob admits the discovered spec sources it matches, and one admitting none — matching no discovered file, or only code sources — admits the empty set, an empty, finding-free answer, exit 0. @@ -74,8 +74,8 @@ The structural surfaces of changes 1 and 3 exist to serve an editor while a pers - Structure derived from one file's parse — the positional section tree (change 3), every construct's ranges and their decompositions, raw attribute and import spellings, comments, and reference-occurrence positions — remains available while other files are invalid and while the file itself carries findings of either level: resolution-level (unresolved references, cycle participation, and similar) and per-file structural (sections with missing, duplicate, or structurally invalid IDs; malformed segments; invalid props; invalid constructs) alike. - Only an unparseable file (or content the existing masking rules already hide) loses its structural view; masking is per file, and the surfaces still answer for every other requested file. -- What findings make undefined is interpreted data, never structure. A section's node identity is defined exactly when its own `id` and each enclosing section's `id` are present and well-formed, each satisfies the structural-ID rules, and no other section of the file spells the same identity — duplicate spellings leave every bearer's identity undefined; no winner is picked. A section's interpreted tags and coverage attribute are defined exactly when its parsed props define them unambiguously (a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined; the raw spelling is still reported). A section whose identity is undefined still occupies its tree position with its ranges and raw spellings, and the occurrences inside it keep their positions, kinds, and resolved targets, with their source graph node — one datum, identity and range (change 1) — undefined. Invalid constructs outside change 3's inventory (stray elements, expression containers, exports) get no view entry; their findings' ranges locate them (change 6). -- Data these rules or resolution leave undefined — section identities and occurrence source nodes as above, an import's resolved target when specifier form or discovery defines none, and expanded own and subtree text — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. Expanded text has an exact definedness rule: a node's own (respectively subtree) text is defined exactly when every embedding the expansion transitively reaches — each `text(...)` spelling in the node's own contribution (respectively anywhere in its subtree), and recursively each one inside every embedded target's subtree — records an occurrence (change 1), and the recursion re-enters no node already being expanded (an embedding cycle); one unresolved spelling or one cycle on the expansion path makes the value unavailable as a whole — partial expansion is fabrication and never occurs. +- What findings make undefined is interpreted data, never structure. A section's node identity is defined exactly when its own `id` and each enclosing section's `id` are present and well-formed, each satisfies the structural-ID rules, and no other section of the file spells the same identity as it does. The chain conditions are inherited — a descendant of a section whose `id` is missing, malformed, or structurally invalid has no defined identity — but uniqueness is not: it constrains the section's own spelled identity alone. Duplicate spellings leave every bearer of the duplicated identity undefined, no winner picked, while a uniquely spelled descendant of duplicate-`id` ancestors keeps its defined identity, its spelling chain intact and its own identity unambiguous. A defined identity therefore does not imply defined prefix identities; occurrence resolution and the target filter (change 1) turn on the definedness of the referenced identity itself — a reference to the one section spelling `a.b` resolves and records an occurrence even while duplicate spellings of `a` leave every bearer of `a` undefined. A section's interpreted tags and coverage attribute are defined exactly when its parsed props define them unambiguously (a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined; the raw spelling is still reported). A section whose identity is undefined still occupies its tree position with its ranges and raw spellings, and the occurrences inside it keep their positions, kinds, and resolved targets, with their source graph node — one datum, identity and range (change 1) — undefined. Invalid constructs outside change 3's inventory (stray elements, expression containers, exports) get no view entry; their findings' ranges locate them (change 6). +- Data these rules or resolution leave undefined — section identities and occurrence source nodes as above, an import's resolved target when specifier form or discovery defines none, and expanded own and subtree text — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. Expanded text has an exact definedness rule: a node's own (respectively subtree) text is defined exactly when every embedding the expansion transitively reaches — each `text(...)` spelling in the node's own contribution (respectively anywhere in its subtree), and recursively each one inside every embedded target's subtree — records an occurrence (change 1), and the recursion re-enters no node already being expanded (an embedding cycle); one unresolved spelling or one cycle on the expansion path makes the value unavailable as a whole — partial expansion is fabrication and never occurs. Where defined, the value is exact on imperfect files too. On a valid file construct form and construct validity coincide, so the existing text-value definition never had to say which of the two decides the removal rules of Markdown compilation; for these surfaces the classification is by syntactic form, never by validity or resolution: every import declaration is removed by form — binding shape, specifier, and target discovery notwithstanding, so the core mid-edit state, an import whose target file was deleted or renamed, perturbs no text value — a section tag is removed with every attribute it spells (unknown, repeated, or spread included), and a construct matching no removal rule's form (the stray elements, expression containers, and exports of the invalid-construct condition) is content under compilation's stated default, preserved byte-for-byte and located by its finding (change 6). Resolution reaches a text value in exactly one place — `text(...)` replacement — and an unresolved spelling is already the unavailable case above, so a defined value is a pure function of the consulted files' parses and the resolved expansions. - A reference occurrence, by contrast, never reports an unavailable target: a spelling that does not resolve to exactly one target — an unknown target; a unique bearer whose identity the definedness rule above leaves undefined; or an ambiguous one, every duplicate bearer's identity undefined — records no edge and therefore no occurrence, and its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid, in every parseable file; spellings inside an unparseable file are hidden with the rest of it, pointed to only by that file's parse-failure finding. - Every answer has a consulted domain of files, and the findings of every file in that domain are reported alongside the answer — a masked file's parse-failure finding included. The domain is defined per surface. For the view of change 3 it is the requested files plus, when expanded text is requested, every further file those expansions consult — each embedded target's file the expansion transitively reaches — because the finding that blocks an expansion, an unresolved spelling or a cycle participation, can lie in a consulted file the request never named. For an occurrence enumeration of change 1 it is every discovered file the file filter admits; when no file filter narrows it, it is the entire discovered set, spec and code sources alike, because a masked file anywhere could conceal occurrences the enumeration would otherwise return. The mapping onto the existing exit-code partition is: an invocation whose answer reports any finding or any explicitly-unavailable datum exits 1; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their existing precedence. A possibly-incomplete answer is therefore never silent: the finding always accompanies it, and the exit code says so. The full answer document is emitted in the 0 and 1 cases alike — exit 1 signals imperfection and never withholds the answer. @@ -100,7 +100,7 @@ Availability is unconditional: no part of the inventory requires parsing sources Sharpen the validation-error contract so an external tool can render findings inline: -- Every reported error condition carries a stable machine-readable code identifying which numbered condition of the validation-errors section it is. +- Every reported error condition carries a stable machine-readable code identifying which numbered condition of the validation-errors section it is. Stable codes deliberately cover exactly these conditions plus the refusal reasons below, and no more: a plain usage error — an unknown command or flag, an invalid flag value, and the rest of the existing usage class — describes the invocation the consuming tool itself composed, never workspace content to render inline, so it carries no stable code, while still arriving as the JSON error document of the delivery rule below whenever JSON output is in effect. - Every error that locates in source carries a location — the file and a source range (byte offsets), the range at the precision the condition allows — for each offending construct. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders inline where it stands and no representative construct is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling that records a participating dependency edge or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call — is identity data on the finding, not a further range. Conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. For missing configuration with no `--config` given — the one condition where no configuration file exists to be concerned — the concerned path is the directory the failed upward search started from, the invocation working directory, reported in change 5's anchoring form (identified relative to the working directory — for this path the degenerate self-reference): invocation input, deterministic per invocation exactly as change 5's anchoring is, so the concerned-path datum is total over these conditions. - The JSON report form presents these fields for every finding, preserving the existing requirements that all conditions are reported together and that JSON carries the same information as the human report. - The same contract covers operation refusals: each distinct reason `rename` and `move` refuse — exactly what the previews of change 7 report — carries a stable machine-readable code and the file, source range, or identity it concerns — under the location-cardinality rule above when a reason involves several constructs, the cycle a refused move would create included — so a refusal renders as precisely as a finding. Refusals are findings under the existing exit-code partition, so the JSON report form above already carries them. From 9c86b59fb559b1b9e5a8f5ec2a24a364014e9f1b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:34:10 +0000 Subject: [PATCH 014/226] sdg(phase-3): disambiguate attribute-view datum; seal stable-code delimitation (iter 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: - I1: change 3's attribute parenthetical now reads "attribute inclusion is by form" — it governs which attributes appear in the view (every spelled one, validity notwithstanding), not per-attribute ranges. Git history confirms the intent: the parenthetical entered in iteration 8 as an inclusion statement ("spread attributes appear among the view's raw attribute spellings by form"); "position" was a wording slip. Attributes are carried as raw spellings; ranges are granted explicitly where intended (tag decomposition, change 6 findings, change 7 id-rewrite edits). - O1: the stable-code delimitation now covers review-operation refusals explicitly — findings under the existing exit-code partition, neither numbered conditions nor rename/move refusal reasons, uncoded because review flows lie outside this proposal's UI scope (relied on unchanged). - O2: change 2's disambiguated-unit sentence now says "each carry the range of their own construct", ending the collision with change 1's coined "reference occurrence" (SPEC.md 4.6 uses "occurrence" for the Nth same-named unit). Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index df47609..052b2df 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -51,7 +51,7 @@ Introduce the concept of a **reference occurrence**: one textual spelling that r Amend the source-range concept (currently: "code locations carry no source range"): -- Every code location gains a source range: for a named code unit, the construct's own characters (analogous to a section's construct range); for a whole-file location, the entire file (analogous to a root node). Document-order-disambiguated units (`path#unit@N`) each carry their own occurrence's range. Where one declaration derives several named units, each unit's range is the construct that binds its own name: a function- or class-valued variable declaration's range spans its own name through its initializer, not the enclosing multi-declaration statement, while the nested units a dotted namespace name derives share the single namespace declaration's range, which is the one construct binding them all. A default export follows the same principle: when the exported construct is named, the unit's range is that construct's own range; the unit named `default` that an anonymous exported construct derives takes the whole export declaration's range — the construct that binds that name. +- Every code location gains a source range: for a named code unit, the construct's own characters (analogous to a section's construct range); for a whole-file location, the entire file (analogous to a root node). Document-order-disambiguated units (`path#unit@N`) each carry the range of their own construct. Where one declaration derives several named units, each unit's range is the construct that binds its own name: a function- or class-valued variable declaration's range spans its own name through its initializer, not the enclosing multi-declaration statement, while the nested units a dotted namespace name derives share the single namespace declaration's range, which is the one construct binding them all. A default export follows the same principle: when the exported construct is named, the unit's range is that construct's own range; the unit named `default` that an anonymous exported construct derives takes the whole export declaration's range — the construct that binds that name. - Exactly two outputs gain code-location ranges, named here rather than left to a general rule. Occurrence enumeration: an occurrence record presents its source graph node with that node's own range (change 1) — the surface that makes every code unit's range reachable, since a code location enters the graph's edges only as a source and every edge from it is recorded by at least one occurrence. Review payloads: the present-node range rule generalizes from requirement nodes to graph nodes, so a present code-location scope carries its source range exactly as a present requirement node does, and an absent node of either kind carries no range. The generalization touches the range datum alone; every other payload rule stands — the historical text an absent requirement node carries, and code locations' having no text value, included. No other output changes shape: edge endpoints in `query` results — `edges` rows, `reachable` witness paths, the per-node incoming and outgoing edge lists — remain bare identities, for code locations exactly as for requirement nodes today, and every other position presenting a graph node as a bare identity keeps that form. - TypeScript reference occurrences (markers, `text(...)` calls) carry the range of the referencing expression itself, distinct from the enclosing unit's range — exact per form, matching the explicitness of the `d` and MDX cases: a `text(...)` occurrence spans the entire call expression, callee through closing parenthesis, argument included — the expression that records the edge — and a marker occurrence spans the bare reference chain alone, exclusive of any statement terminator. @@ -59,7 +59,7 @@ Amend the source-range concept (currently: "code locations carry no source range A query surface returns, for a spec source file, everything needed to overlay structure on the raw MDX bytes in a single call: -- the root node and the full section tree in document order. The tree is positional — defined by construct nesting alone — so it exists for every parseable file whatever findings the file carries (change 4). Each node carries its source range, its raw attribute spellings as parsed (attribute position is reported by form: every attribute the tag spells appears — repeated, unknown, and spread attributes included — its invalidity a located finding of change 6, never a view omission), and — each where defined, explicitly unavailable otherwise (change 4) — its node identity, tags, coverage attribute, and (on request) own and subtree text. A root's tags and coverage attribute are a distinct, defined case: structurally absent under existing conventions (absent for roots), they are reported as absent, never as unavailable — structural absence implies no finding and, unlike change 4 unavailability, carries no exit-1 consequence; +- the root node and the full section tree in document order. The tree is positional — defined by construct nesting alone — so it exists for every parseable file whatever findings the file carries (change 4). Each node carries its source range, its raw attribute spellings as parsed (attribute inclusion is by form: every attribute the tag spells appears — repeated, unknown, and spread attributes included — its invalidity a located finding of change 6, never a view omission), and — each where defined, explicitly unavailable otherwise (change 4) — its node identity, tags, coverage attribute, and (on request) own and subtree text. A root's tags and coverage attribute are a distinct, defined case: structurally absent under existing conventions (absent for roots), they are reported as absent, never as unavailable — structural absence implies no finding and, unlike change 4 unavailability, carries no exit-1 consequence; - for each non-root node, the decomposition of its construct range: the opening tag's range and the closing tag's range (a self-closing section has an opening-tag range only; a root node spans the whole file and has neither). An interactive consumer needs the tags separately from the content they enclose — to render a section header in place of its opening tag, hide or fold what a tag pair encloses, and land navigation on a section's tag rather than selecting its entire construct; - every import declaration, valid or invalid, with its source range, its binding name where one is bound, and its resolved target file where specifier form and discovery define one — explicitly unavailable otherwise (change 4), with the invalidity itself a located finding of change 6; - every reference occurrence in the file (change 1), positioned in document order; @@ -100,7 +100,7 @@ Availability is unconditional: no part of the inventory requires parsing sources Sharpen the validation-error contract so an external tool can render findings inline: -- Every reported error condition carries a stable machine-readable code identifying which numbered condition of the validation-errors section it is. Stable codes deliberately cover exactly these conditions plus the refusal reasons below, and no more: a plain usage error — an unknown command or flag, an invalid flag value, and the rest of the existing usage class — describes the invocation the consuming tool itself composed, never workspace content to render inline, so it carries no stable code, while still arriving as the JSON error document of the delivery rule below whenever JSON output is in effect. +- Every reported error condition carries a stable machine-readable code identifying which numbered condition of the validation-errors section it is. Stable codes deliberately cover exactly these conditions plus the refusal reasons below, and no more: a plain usage error — an unknown command or flag, an invalid flag value, and the rest of the existing usage class — describes the invocation the consuming tool itself composed, never workspace content to render inline, so it carries no stable code, while still arriving as the JSON error document of the delivery rule below whenever JSON output is in effect. Review-operation refusals — findings under the existing exit-code partition, but neither numbered conditions nor the `rename`/`move` refusal reasons below — likewise carry no stable code: review flows lie outside this proposal's UI scope, relied on unchanged. - Every error that locates in source carries a location — the file and a source range (byte offsets), the range at the precision the condition allows — for each offending construct. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders inline where it stands and no representative construct is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling that records a participating dependency edge or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call — is identity data on the finding, not a further range. Conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. For missing configuration with no `--config` given — the one condition where no configuration file exists to be concerned — the concerned path is the directory the failed upward search started from, the invocation working directory, reported in change 5's anchoring form (identified relative to the working directory — for this path the degenerate self-reference): invocation input, deterministic per invocation exactly as change 5's anchoring is, so the concerned-path datum is total over these conditions. - The JSON report form presents these fields for every finding, preserving the existing requirements that all conditions are reported together and that JSON carries the same information as the human report. - The same contract covers operation refusals: each distinct reason `rename` and `move` refuse — exactly what the previews of change 7 report — carries a stable machine-readable code and the file, source range, or identity it concerns — under the location-cardinality rule above when a reason involves several constructs, the cycle a refused move would create included — so a refusal renders as precisely as a finding. Refusals are findings under the existing exit-code partition, so the JSON report form above already carries them. From d6f8c6efcc292c534d04219749850739c420247b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:55:10 +0000 Subject: [PATCH 015/226] sdg(phase-3): pin invalid-form id-prop identity semantics (iter 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: I1, O1, O2, O3. - I1: change 4 now defines when a section spells an identity (id prop exactly once, quoted attribute form); missing and invalid-form id props (repeated — spellings agreeing or not — braced or valueless values) spell no identity: own identity undefined, descendants' chain broken, and no participation in uniqueness — an invalid-form claimant never poisons a well-formed bearer. Resolution grounded in the document's own principles (no fabrication from invalid syntax, tags/coverage analog, mid-edit containment). - O1: review-refusal aside in change 6 scoped past change 2's range generalization (removes the one self-contradiction reading). - O2: change 5's corrupt-recorded-state finding explicitly joins the numbered validation-error conditions with a stable code. - O3: an occurrence-less MDX-embedding spelling's finding range pinned to the full braced container, keeping change 3's byte classification exact on imperfect files. Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index 052b2df..bf99a8d 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -74,7 +74,7 @@ The structural surfaces of changes 1 and 3 exist to serve an editor while a pers - Structure derived from one file's parse — the positional section tree (change 3), every construct's ranges and their decompositions, raw attribute and import spellings, comments, and reference-occurrence positions — remains available while other files are invalid and while the file itself carries findings of either level: resolution-level (unresolved references, cycle participation, and similar) and per-file structural (sections with missing, duplicate, or structurally invalid IDs; malformed segments; invalid props; invalid constructs) alike. - Only an unparseable file (or content the existing masking rules already hide) loses its structural view; masking is per file, and the surfaces still answer for every other requested file. -- What findings make undefined is interpreted data, never structure. A section's node identity is defined exactly when its own `id` and each enclosing section's `id` are present and well-formed, each satisfies the structural-ID rules, and no other section of the file spells the same identity as it does. The chain conditions are inherited — a descendant of a section whose `id` is missing, malformed, or structurally invalid has no defined identity — but uniqueness is not: it constrains the section's own spelled identity alone. Duplicate spellings leave every bearer of the duplicated identity undefined, no winner picked, while a uniquely spelled descendant of duplicate-`id` ancestors keeps its defined identity, its spelling chain intact and its own identity unambiguous. A defined identity therefore does not imply defined prefix identities; occurrence resolution and the target filter (change 1) turn on the definedness of the referenced identity itself — a reference to the one section spelling `a.b` resolves and records an occurrence even while duplicate spellings of `a` leave every bearer of `a` undefined. A section's interpreted tags and coverage attribute are defined exactly when its parsed props define them unambiguously (a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined; the raw spelling is still reported). A section whose identity is undefined still occupies its tree position with its ranges and raw spellings, and the occurrences inside it keep their positions, kinds, and resolved targets, with their source graph node — one datum, identity and range (change 1) — undefined. Invalid constructs outside change 3's inventory (stray elements, expression containers, exports) get no view entry; their findings' ranges locate them (change 6). +- What findings make undefined is interpreted data, never structure. A section spells an identity exactly when its `id` prop occurs exactly once with its value in the quoted attribute form the source syntax requires; that value, well-formed or not, is its spelled identity. A section with no `id` prop, and equally one whose `id` prop is invalid in form — repeated, its spellings agreeing or not, or its value in any other form, braced or valueless included — spells no identity: its own identity is undefined, and it contests no other section's, for uniqueness compares spelled identities only — a bearer whose spelled identity no other section spells keeps its defined identity whatever invalid-form `id` props the file holds beside it. A section's node identity is defined exactly when it and each enclosing section spell an identity, each spelled identity is well-formed and satisfies the structural-ID rules, and no other section of the file spells the same identity as it does. The chain conditions are inherited — a descendant of a section that spells no identity, or whose spelled identity is malformed or structurally invalid, has no defined identity — but uniqueness is not: it constrains the section's own spelled identity alone. Duplicate spellings leave every bearer of the duplicated identity undefined, no winner picked, while a uniquely spelled descendant of duplicate-`id` ancestors keeps its defined identity, its spelling chain intact and its own identity unambiguous. A defined identity therefore does not imply defined prefix identities; occurrence resolution and the target filter (change 1) turn on the definedness of the referenced identity itself — a reference to the one section spelling `a.b` resolves and records an occurrence even while duplicate spellings of `a` leave every bearer of `a` undefined. A section's interpreted tags and coverage attribute are defined exactly when its parsed props define them unambiguously (a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined; the raw spelling is still reported). A section whose identity is undefined still occupies its tree position with its ranges and raw spellings, and the occurrences inside it keep their positions, kinds, and resolved targets, with their source graph node — one datum, identity and range (change 1) — undefined. Invalid constructs outside change 3's inventory (stray elements, expression containers, exports) get no view entry; their findings' ranges locate them (change 6). - Data these rules or resolution leave undefined — section identities and occurrence source nodes as above, an import's resolved target when specifier form or discovery defines none, and expanded own and subtree text — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. Expanded text has an exact definedness rule: a node's own (respectively subtree) text is defined exactly when every embedding the expansion transitively reaches — each `text(...)` spelling in the node's own contribution (respectively anywhere in its subtree), and recursively each one inside every embedded target's subtree — records an occurrence (change 1), and the recursion re-enters no node already being expanded (an embedding cycle); one unresolved spelling or one cycle on the expansion path makes the value unavailable as a whole — partial expansion is fabrication and never occurs. Where defined, the value is exact on imperfect files too. On a valid file construct form and construct validity coincide, so the existing text-value definition never had to say which of the two decides the removal rules of Markdown compilation; for these surfaces the classification is by syntactic form, never by validity or resolution: every import declaration is removed by form — binding shape, specifier, and target discovery notwithstanding, so the core mid-edit state, an import whose target file was deleted or renamed, perturbs no text value — a section tag is removed with every attribute it spells (unknown, repeated, or spread included), and a construct matching no removal rule's form (the stray elements, expression containers, and exports of the invalid-construct condition) is content under compilation's stated default, preserved byte-for-byte and located by its finding (change 6). Resolution reaches a text value in exactly one place — `text(...)` replacement — and an unresolved spelling is already the unavailable case above, so a defined value is a pure function of the consulted files' parses and the resolved expansions. - A reference occurrence, by contrast, never reports an unavailable target: a spelling that does not resolve to exactly one target — an unknown target; a unique bearer whose identity the definedness rule above leaves undefined; or an ambiguous one, every duplicate bearer's identity undefined — records no edge and therefore no occurrence, and its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid, in every parseable file; spellings inside an unparseable file are hidden with the rest of it, pointed to only by that file's parse-failure finding. - Every answer has a consulted domain of files, and the findings of every file in that domain are reported alongside the answer — a masked file's parse-failure finding included. The domain is defined per surface. For the view of change 3 it is the requested files plus, when expanded text is requested, every further file those expansions consult — each embedded target's file the expansion transitively reaches — because the finding that blocks an expansion, an unresolved spelling or a cycle participation, can lie in a consulted file the request never named. For an occurrence enumeration of change 1 it is every discovered file the file filter admits; when no file filter narrows it, it is the entire discovered set, spec and code sources alike, because a masked file anywhere could conceal occurrences the enumeration would otherwise return. The mapping onto the existing exit-code partition is: an invocation whose answer reports any finding or any explicitly-unavailable datum exits 1; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their existing precedence. A possibly-incomplete answer is therefore never silent: the finding always accompanies it, and the exit code says so. The full answer document is emitted in the 0 and 1 cases alike — exit 1 signals imperfection and never withholds the answer. @@ -94,18 +94,18 @@ A query surface reports the machine-readable shape of the workspace, so an exter The inventory contains no absolute paths and no environment-dependent content beyond the invocation anchoring above (a function of the invocation, like `--config` resolution — not of the machine), consistent with existing determinism and security conventions. When the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives), the anchoring is reported in the platform's absolute form — the one further case of the stated exception, still a pure function of invocation input. -Availability is unconditional: no part of the inventory requires parsing sources, so it answers whatever the sources' validity; configuration errors keep their existing precedence. Its content has three provenances, each reported as what it is. Invocation, configuration, and discovery determine the anchoring, the configuration view, the discovered sources with their groups, the graph-data area, and the per-source generated-module and Markdown-emit-destination paths (the destinations exist exactly while emission is enabled, per existing rules). Recorded generation state supplies the remaining derived-file map entries — companion paths and any other recorded derived paths — reported as recorded: recorded state can lag configuration until a rebuild and is empty before any generation has run. Because the inventory alone among readers consults the record without refreshing it, it is the one surface that can meet recorded state that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise, a state the existing derived-file rules contemplate and a rebuild resolves — and that case has a defined outcome. The recorded entries are reported explicitly unavailable, never fabricated and never passed off as an empty map. The corruption accompanies the answer as a reported finding, and the invocation exits 1 under the existing partition. The full inventory, every other provenance's content unaffected, is still emitted. Finally, the filesystem supplies the durable entries. The journal path is fixed, and its occupancy datum is presence alone, whatever kind of filesystem object occupies it — the inventory reads no journal content. The review-session files are those present, selected by name alone: every directory entry directly under the review-session directory whose name is a well-formed session file name is listed, whatever kind of filesystem object occupies it — a session-named path holding anything but a plain file is a corrupt session, and corrupt or unparseable sessions are included, since the inventory reads no session content. A directory entry there with any other name is not a session and is never listed: it is an unattributed path under the area, governed by the one rule the graph-data-area entry states. The inventory reports recorded and durable state as it stands and never refreshes or writes anything. +Availability is unconditional: no part of the inventory requires parsing sources, so it answers whatever the sources' validity; configuration errors keep their existing precedence. Its content has three provenances, each reported as what it is. Invocation, configuration, and discovery determine the anchoring, the configuration view, the discovered sources with their groups, the graph-data area, and the per-source generated-module and Markdown-emit-destination paths (the destinations exist exactly while emission is enabled, per existing rules). Recorded generation state supplies the remaining derived-file map entries — companion paths and any other recorded derived paths — reported as recorded: recorded state can lag configuration until a rebuild and is empty before any generation has run. Because the inventory alone among readers consults the record without refreshing it, it is the one surface that can meet recorded state that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise, a state the existing derived-file rules contemplate and a rebuild resolves — and that case has a defined outcome. The recorded entries are reported explicitly unavailable, never fabricated and never passed off as an empty map. The corruption accompanies the answer as a reported finding — a reported error condition under change 6's contract, numbered in the validation-errors section and carrying its stable code — and the invocation exits 1 under the existing partition. The full inventory, every other provenance's content unaffected, is still emitted. Finally, the filesystem supplies the durable entries. The journal path is fixed, and its occupancy datum is presence alone, whatever kind of filesystem object occupies it — the inventory reads no journal content. The review-session files are those present, selected by name alone: every directory entry directly under the review-session directory whose name is a well-formed session file name is listed, whatever kind of filesystem object occupies it — a session-named path holding anything but a plain file is a corrupt session, and corrupt or unparseable sessions are included, since the inventory reads no session content. A directory entry there with any other name is not a session and is never listed: it is an unattributed path under the area, governed by the one rule the graph-data-area entry states. The inventory reports recorded and durable state as it stands and never refreshes or writes anything. ### 6. Structured diagnostics Sharpen the validation-error contract so an external tool can render findings inline: -- Every reported error condition carries a stable machine-readable code identifying which numbered condition of the validation-errors section it is. Stable codes deliberately cover exactly these conditions plus the refusal reasons below, and no more: a plain usage error — an unknown command or flag, an invalid flag value, and the rest of the existing usage class — describes the invocation the consuming tool itself composed, never workspace content to render inline, so it carries no stable code, while still arriving as the JSON error document of the delivery rule below whenever JSON output is in effect. Review-operation refusals — findings under the existing exit-code partition, but neither numbered conditions nor the `rename`/`move` refusal reasons below — likewise carry no stable code: review flows lie outside this proposal's UI scope, relied on unchanged. +- Every reported error condition carries a stable machine-readable code identifying which numbered condition of the validation-errors section it is. Stable codes deliberately cover exactly these conditions plus the refusal reasons below, and no more: a plain usage error — an unknown command or flag, an invalid flag value, and the rest of the existing usage class — describes the invocation the consuming tool itself composed, never workspace content to render inline, so it carries no stable code, while still arriving as the JSON error document of the delivery rule below whenever JSON output is in effect. Review-operation refusals — findings under the existing exit-code partition, but neither numbered conditions nor the `rename`/`move` refusal reasons below — likewise carry no stable code: review flows lie outside this proposal's UI scope, relied on unchanged beyond change 2's range generalization. - Every error that locates in source carries a location — the file and a source range (byte offsets), the range at the precision the condition allows — for each offending construct. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders inline where it stands and no representative construct is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling that records a participating dependency edge or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call — is identity data on the finding, not a further range. Conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. For missing configuration with no `--config` given — the one condition where no configuration file exists to be concerned — the concerned path is the directory the failed upward search started from, the invocation working directory, reported in change 5's anchoring form (identified relative to the working directory — for this path the degenerate self-reference): invocation input, deterministic per invocation exactly as change 5's anchoring is, so the concerned-path datum is total over these conditions. - The JSON report form presents these fields for every finding, preserving the existing requirements that all conditions are reported together and that JSON carries the same information as the human report. - The same contract covers operation refusals: each distinct reason `rename` and `move` refuse — exactly what the previews of change 7 report — carries a stable machine-readable code and the file, source range, or identity it concerns — under the location-cardinality rule above when a reason involves several constructs, the cycle a refused move would create included — so a refusal renders as precisely as a finding. Refusals are findings under the existing exit-code partition, so the JSON report form above already carries them. - Machine-readable delivery is closed over the outcome classes: whenever JSON output is in effect, an invocation that fails with a usage or configuration error (exit 2) emits a single JSON document as its entire standard output reporting the error — carrying, for conditions with a defined code (configuration errors included), the stable code and the concerned file or path above — amending the existing rule that such an error leaves standard output empty. JSON output is in effect in exactly two cases: `--json` appears among the invocation's arguments — governing error delivery even when the arguments are themselves the error, an unknown command or flag included — or the invoked surface is JSON-only, a single JSON document its only output form with or without `--json`, as the existing single-document surfaces are and added surfaces may be defined; no flag need be present there. Outside these two cases the existing empty-standard-output rule stands. Exit codes, error precedence, and human-readable standard-error text are unchanged. Without this channel, configuration errors — the one class that precedes and blocks every surface of changes 1, 3, 5, and 7 — would be the one class an external tool cannot consume. -- Diagnostics are the locating surface for constructs that record nothing in the graph: an invalid, dynamic, or unresolved reference spelling has no occurrence (changes 1, 4), so its range reaches consumers here. +- Diagnostics are the locating surface for constructs that record nothing in the graph: an invalid, dynamic, or unresolved reference spelling has no occurrence (changes 1, 4), so its range reaches consumers here. For a spelling of the MDX embedding form, that range is the full braced container, opening brace through closing brace — the span its occurrence would occupy (change 1) — so the byte classification of change 3 stays exact on imperfect files. ### 7. Refactoring previews From 937e36d141da330d3c864e62582403117af431d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 19:16:17 +0000 Subject: [PATCH 016/226] sdg(phase-3): define unreadable-record outcome for preview delta; drop exclusivity claim (iter 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: C1 — replaced change 5's false "alone among readers" claim with an accurate shared-reader statement (inventory + change 7's delta; existing check noted as covered by its staleness condition) and gave change 7's delta a defined outcome on recorded state that exists but cannot be read: the delta, both directions one datum, reported explicitly unavailable, same numbered condition and stable code as change 5's, exit 1, rest of the preview report emitted in full; stated as the succeed/refuse equivalence's one success-side exception (the real operation is not refused — corruption fails no build validation and finishing regeneration replaces it). Aligned change 4's summary pointer and change 6's refusal appositive with the new outcome. Applied: O1 — absent tags/coverage props define the existing defaults (no tags; coverage-required), closing the misreading that presence is required. Applied: O2 — the test seam tied to acquiring workspace exclusivity never engages on a preview, stated at behavior level (no flag prescription). Applied: O3 — reported edit ranges may nest: section-move re-identification rewrites locate inside the origin-deletion range in pre-operation coordinates. Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index bf99a8d..3bb9f87 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -74,12 +74,12 @@ The structural surfaces of changes 1 and 3 exist to serve an editor while a pers - Structure derived from one file's parse — the positional section tree (change 3), every construct's ranges and their decompositions, raw attribute and import spellings, comments, and reference-occurrence positions — remains available while other files are invalid and while the file itself carries findings of either level: resolution-level (unresolved references, cycle participation, and similar) and per-file structural (sections with missing, duplicate, or structurally invalid IDs; malformed segments; invalid props; invalid constructs) alike. - Only an unparseable file (or content the existing masking rules already hide) loses its structural view; masking is per file, and the surfaces still answer for every other requested file. -- What findings make undefined is interpreted data, never structure. A section spells an identity exactly when its `id` prop occurs exactly once with its value in the quoted attribute form the source syntax requires; that value, well-formed or not, is its spelled identity. A section with no `id` prop, and equally one whose `id` prop is invalid in form — repeated, its spellings agreeing or not, or its value in any other form, braced or valueless included — spells no identity: its own identity is undefined, and it contests no other section's, for uniqueness compares spelled identities only — a bearer whose spelled identity no other section spells keeps its defined identity whatever invalid-form `id` props the file holds beside it. A section's node identity is defined exactly when it and each enclosing section spell an identity, each spelled identity is well-formed and satisfies the structural-ID rules, and no other section of the file spells the same identity as it does. The chain conditions are inherited — a descendant of a section that spells no identity, or whose spelled identity is malformed or structurally invalid, has no defined identity — but uniqueness is not: it constrains the section's own spelled identity alone. Duplicate spellings leave every bearer of the duplicated identity undefined, no winner picked, while a uniquely spelled descendant of duplicate-`id` ancestors keeps its defined identity, its spelling chain intact and its own identity unambiguous. A defined identity therefore does not imply defined prefix identities; occurrence resolution and the target filter (change 1) turn on the definedness of the referenced identity itself — a reference to the one section spelling `a.b` resolves and records an occurrence even while duplicate spellings of `a` leave every bearer of `a` undefined. A section's interpreted tags and coverage attribute are defined exactly when its parsed props define them unambiguously (a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined; the raw spelling is still reported). A section whose identity is undefined still occupies its tree position with its ranges and raw spellings, and the occurrences inside it keep their positions, kinds, and resolved targets, with their source graph node — one datum, identity and range (change 1) — undefined. Invalid constructs outside change 3's inventory (stray elements, expression containers, exports) get no view entry; their findings' ranges locate them (change 6). +- What findings make undefined is interpreted data, never structure. A section spells an identity exactly when its `id` prop occurs exactly once with its value in the quoted attribute form the source syntax requires; that value, well-formed or not, is its spelled identity. A section with no `id` prop, and equally one whose `id` prop is invalid in form — repeated, its spellings agreeing or not, or its value in any other form, braced or valueless included — spells no identity: its own identity is undefined, and it contests no other section's, for uniqueness compares spelled identities only — a bearer whose spelled identity no other section spells keeps its defined identity whatever invalid-form `id` props the file holds beside it. A section's node identity is defined exactly when it and each enclosing section spell an identity, each spelled identity is well-formed and satisfies the structural-ID rules, and no other section of the file spells the same identity as it does. The chain conditions are inherited — a descendant of a section that spells no identity, or whose spelled identity is malformed or structurally invalid, has no defined identity — but uniqueness is not: it constrains the section's own spelled identity alone. Duplicate spellings leave every bearer of the duplicated identity undefined, no winner picked, while a uniquely spelled descendant of duplicate-`id` ancestors keeps its defined identity, its spelling chain intact and its own identity unambiguous. A defined identity therefore does not imply defined prefix identities; occurrence resolution and the target filter (change 1) turn on the definedness of the referenced identity itself — a reference to the one section spelling `a.b` resolves and records an occurrence even while duplicate spellings of `a` leave every bearer of `a` undefined. A section's interpreted tags and coverage attribute are defined exactly when its parsed props define them unambiguously — an absent prop defines them as the existing defaults, no tags and coverage-required, so a section spelling neither prop carries both values defined — while a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined (the raw spelling is still reported). A section whose identity is undefined still occupies its tree position with its ranges and raw spellings, and the occurrences inside it keep their positions, kinds, and resolved targets, with their source graph node — one datum, identity and range (change 1) — undefined. Invalid constructs outside change 3's inventory (stray elements, expression containers, exports) get no view entry; their findings' ranges locate them (change 6). - Data these rules or resolution leave undefined — section identities and occurrence source nodes as above, an import's resolved target when specifier form or discovery defines none, and expanded own and subtree text — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. Expanded text has an exact definedness rule: a node's own (respectively subtree) text is defined exactly when every embedding the expansion transitively reaches — each `text(...)` spelling in the node's own contribution (respectively anywhere in its subtree), and recursively each one inside every embedded target's subtree — records an occurrence (change 1), and the recursion re-enters no node already being expanded (an embedding cycle); one unresolved spelling or one cycle on the expansion path makes the value unavailable as a whole — partial expansion is fabrication and never occurs. Where defined, the value is exact on imperfect files too. On a valid file construct form and construct validity coincide, so the existing text-value definition never had to say which of the two decides the removal rules of Markdown compilation; for these surfaces the classification is by syntactic form, never by validity or resolution: every import declaration is removed by form — binding shape, specifier, and target discovery notwithstanding, so the core mid-edit state, an import whose target file was deleted or renamed, perturbs no text value — a section tag is removed with every attribute it spells (unknown, repeated, or spread included), and a construct matching no removal rule's form (the stray elements, expression containers, and exports of the invalid-construct condition) is content under compilation's stated default, preserved byte-for-byte and located by its finding (change 6). Resolution reaches a text value in exactly one place — `text(...)` replacement — and an unresolved spelling is already the unavailable case above, so a defined value is a pure function of the consulted files' parses and the resolved expansions. - A reference occurrence, by contrast, never reports an unavailable target: a spelling that does not resolve to exactly one target — an unknown target; a unique bearer whose identity the definedness rule above leaves undefined; or an ambiguous one, every duplicate bearer's identity undefined — records no edge and therefore no occurrence, and its position reaches consumers through the diagnostics of change 6, which carry ranges. The two surfaces jointly locate every reference spelling, valid or invalid, in every parseable file; spellings inside an unparseable file are hidden with the rest of it, pointed to only by that file's parse-failure finding. - Every answer has a consulted domain of files, and the findings of every file in that domain are reported alongside the answer — a masked file's parse-failure finding included. The domain is defined per surface. For the view of change 3 it is the requested files plus, when expanded text is requested, every further file those expansions consult — each embedded target's file the expansion transitively reaches — because the finding that blocks an expansion, an unresolved spelling or a cycle participation, can lie in a consulted file the request never named. For an occurrence enumeration of change 1 it is every discovered file the file filter admits; when no file filter narrows it, it is the entire discovered set, spec and code sources alike, because a masked file anywhere could conceal occurrences the enumeration would otherwise return. The mapping onto the existing exit-code partition is: an invocation whose answer reports any finding or any explicitly-unavailable datum exits 1; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their existing precedence. A possibly-incomplete answer is therefore never silent: the finding always accompanies it, and the exit code says so. The full answer document is emitted in the 0 and 1 cases alike — exit 1 signals imperfection and never withholds the answer. -Existing commands keep their current all-or-nothing read semantics; this availability contract governs the structural surfaces of changes 1 and 3 — the other added surfaces state their own: the inventory of change 5 is unconditional on its own terms, the previews of change 7 refuse exactly when the real operation would, and the identification surface of change 8 is workspace-independent. The relationship of changes 1 and 3 to stored graph data follows the same line: these query surfaces never answer from stale data — on a workspace that passes build validation they participate in read-time refresh through the existing path, exactly as the existing read commands do, and on a workspace that does not, their answers reflect the current sources and they modify nothing: no graph data, no derived files — just as a failed refresh modifies nothing today. The inventory of change 5 states its own relationship to stored state. +Existing commands keep their current all-or-nothing read semantics; this availability contract governs the structural surfaces of changes 1 and 3 — the other added surfaces state their own: the inventory of change 5 is unconditional on its own terms, the previews of change 7 refuse exactly when the real operation would — their one record-dependent datum, the delta, carrying change 5's unreadable-record outcome — and the identification surface of change 8 is workspace-independent. The relationship of changes 1 and 3 to stored graph data follows the same line: these query surfaces never answer from stale data — on a workspace that passes build validation they participate in read-time refresh through the existing path, exactly as the existing read commands do, and on a workspace that does not, their answers reflect the current sources and they modify nothing: no graph data, no derived files — just as a failed refresh modifies nothing today. The inventory of change 5 states its own relationship to stored state. ### 5. Workspace inventory @@ -94,7 +94,7 @@ A query surface reports the machine-readable shape of the workspace, so an exter The inventory contains no absolute paths and no environment-dependent content beyond the invocation anchoring above (a function of the invocation, like `--config` resolution — not of the machine), consistent with existing determinism and security conventions. When the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives), the anchoring is reported in the platform's absolute form — the one further case of the stated exception, still a pure function of invocation input. -Availability is unconditional: no part of the inventory requires parsing sources, so it answers whatever the sources' validity; configuration errors keep their existing precedence. Its content has three provenances, each reported as what it is. Invocation, configuration, and discovery determine the anchoring, the configuration view, the discovered sources with their groups, the graph-data area, and the per-source generated-module and Markdown-emit-destination paths (the destinations exist exactly while emission is enabled, per existing rules). Recorded generation state supplies the remaining derived-file map entries — companion paths and any other recorded derived paths — reported as recorded: recorded state can lag configuration until a rebuild and is empty before any generation has run. Because the inventory alone among readers consults the record without refreshing it, it is the one surface that can meet recorded state that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise, a state the existing derived-file rules contemplate and a rebuild resolves — and that case has a defined outcome. The recorded entries are reported explicitly unavailable, never fabricated and never passed off as an empty map. The corruption accompanies the answer as a reported finding — a reported error condition under change 6's contract, numbered in the validation-errors section and carrying its stable code — and the invocation exits 1 under the existing partition. The full inventory, every other provenance's content unaffected, is still emitted. Finally, the filesystem supplies the durable entries. The journal path is fixed, and its occupancy datum is presence alone, whatever kind of filesystem object occupies it — the inventory reads no journal content. The review-session files are those present, selected by name alone: every directory entry directly under the review-session directory whose name is a well-formed session file name is listed, whatever kind of filesystem object occupies it — a session-named path holding anything but a plain file is a corrupt session, and corrupt or unparseable sessions are included, since the inventory reads no session content. A directory entry there with any other name is not a session and is never listed: it is an unattributed path under the area, governed by the one rule the graph-data-area entry states. The inventory reports recorded and durable state as it stands and never refreshes or writes anything. +Availability is unconditional: no part of the inventory requires parsing sources, so it answers whatever the sources' validity; configuration errors keep their existing precedence. Its content has three provenances, each reported as what it is. Invocation, configuration, and discovery determine the anchoring, the configuration view, the discovered sources with their groups, the graph-data area, and the per-source generated-module and Markdown-emit-destination paths (the destinations exist exactly while emission is enabled, per existing rules). Recorded generation state supplies the remaining derived-file map entries — companion paths and any other recorded derived paths — reported as recorded: recorded state can lag configuration until a rebuild and is empty before any generation has run. A reader that consults the record without refreshing it can meet recorded state that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise, a state the existing derived-file rules contemplate and a rebuild resolves. This proposal adds two such readers — the inventory, which meets it in these recorded entries, and the preview delta of change 7 (existing `check` also reads the record without refreshing; its staleness condition already covers this state) — and defines one outcome for the case, stated here and adopted by change 7. The record-supplied datum is reported explicitly unavailable, never fabricated and never passed off as an empty map. The corruption accompanies the answer as a reported finding — a reported error condition under change 6's contract, numbered in the validation-errors section and carrying its stable code — and the invocation exits 1 under the existing partition. The full answer — for the inventory, every other provenance's content — is still emitted. Finally, the filesystem supplies the durable entries. The journal path is fixed, and its occupancy datum is presence alone, whatever kind of filesystem object occupies it — the inventory reads no journal content. The review-session files are those present, selected by name alone: every directory entry directly under the review-session directory whose name is a well-formed session file name is listed, whatever kind of filesystem object occupies it — a session-named path holding anything but a plain file is a corrupt session, and corrupt or unparseable sessions are included, since the inventory reads no session content. A directory entry there with any other name is not a session and is never listed: it is an unattributed path under the area, governed by the one rule the graph-data-area entry states. The inventory reports recorded and durable state as it stands and never refreshes or writes anything. ### 6. Structured diagnostics @@ -103,7 +103,7 @@ Sharpen the validation-error contract so an external tool can render findings in - Every reported error condition carries a stable machine-readable code identifying which numbered condition of the validation-errors section it is. Stable codes deliberately cover exactly these conditions plus the refusal reasons below, and no more: a plain usage error — an unknown command or flag, an invalid flag value, and the rest of the existing usage class — describes the invocation the consuming tool itself composed, never workspace content to render inline, so it carries no stable code, while still arriving as the JSON error document of the delivery rule below whenever JSON output is in effect. Review-operation refusals — findings under the existing exit-code partition, but neither numbered conditions nor the `rename`/`move` refusal reasons below — likewise carry no stable code: review flows lie outside this proposal's UI scope, relied on unchanged beyond change 2's range generalization. - Every error that locates in source carries a location — the file and a source range (byte offsets), the range at the precision the condition allows — for each offending construct. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders inline where it stands and no representative construct is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling that records a participating dependency edge or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call — is identity data on the finding, not a further range. Conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. For missing configuration with no `--config` given — the one condition where no configuration file exists to be concerned — the concerned path is the directory the failed upward search started from, the invocation working directory, reported in change 5's anchoring form (identified relative to the working directory — for this path the degenerate self-reference): invocation input, deterministic per invocation exactly as change 5's anchoring is, so the concerned-path datum is total over these conditions. - The JSON report form presents these fields for every finding, preserving the existing requirements that all conditions are reported together and that JSON carries the same information as the human report. -- The same contract covers operation refusals: each distinct reason `rename` and `move` refuse — exactly what the previews of change 7 report — carries a stable machine-readable code and the file, source range, or identity it concerns — under the location-cardinality rule above when a reason involves several constructs, the cycle a refused move would create included — so a refusal renders as precisely as a finding. Refusals are findings under the existing exit-code partition, so the JSON report form above already carries them. +- The same contract covers operation refusals: each distinct reason `rename` and `move` refuse — exactly what a refused preview of change 7 reports — carries a stable machine-readable code and the file, source range, or identity it concerns — under the location-cardinality rule above when a reason involves several constructs, the cycle a refused move would create included — so a refusal renders as precisely as a finding. Refusals are findings under the existing exit-code partition, so the JSON report form above already carries them. - Machine-readable delivery is closed over the outcome classes: whenever JSON output is in effect, an invocation that fails with a usage or configuration error (exit 2) emits a single JSON document as its entire standard output reporting the error — carrying, for conditions with a defined code (configuration errors included), the stable code and the concerned file or path above — amending the existing rule that such an error leaves standard output empty. JSON output is in effect in exactly two cases: `--json` appears among the invocation's arguments — governing error delivery even when the arguments are themselves the error, an unknown command or flag included — or the invoked surface is JSON-only, a single JSON document its only output form with or without `--json`, as the existing single-document surfaces are and added surfaces may be defined; no flag need be present there. Outside these two cases the existing empty-standard-output rule stands. Exit codes, error precedence, and human-readable standard-error text are unchanged. Without this channel, configuration errors — the one class that precedes and blocks every surface of changes 1, 3, 5, and 7 — would be the one class an external tool cannot consume. - Diagnostics are the locating surface for constructs that record nothing in the graph: an invalid, dynamic, or unresolved reference spelling has no occurrence (changes 1, 4), so its range reaches consumers here. For a spelling of the MDX embedding form, that range is the full braced container, opening brace through closing brace — the span its occurrence would occupy (change 1) — so the byte classification of change 3 stays exact on imperfect files. @@ -112,10 +112,10 @@ Sharpen the validation-error contract so an external tool can render findings in `rename` and `move` gain a preview mode that performs the full validation and planning of the real operation and reports, without modifying anything: - the complete identity mapping the operation would journal; -- every file the operation would rewrite or relocate, with every edit the operation would make in it — each located by a range in current, pre-operation coordinates and classed by what it is. An edit is reported without replacement text: its class and the identity mapping state what changes, and the resulting bytes are observable only by running the operation — the preview is a safety report, not an edit script for external application, which would bypass the journaled mapping. The classes cover everything the operations edit, not only reference occurrences: reference-occurrence rewrites (change 1's occurrences — `d` references, `text(...)` references, TypeScript markers); `id`-attribute rewrites (rename's and the section move's re-identification); import edits — specifier rewrites, import additions, and import removals; the section move's origin deletion — one range spanning every byte the origin edit removes: the construct's own characters, extended over the leftover whitespace and line terminator of a line the existing line-drop rule additionally drops (bytes contiguous with the construct by construction), so the adjunct drop lies inside this class's range rather than forming a class of its own — its target insertion point, and the self-closing-target-parent rewrite when one applies; and the file move's relocation of the file itself. A section move whose target file does not yet exist reports that file's creation as its own class, with the insertion point at the start of the new file — the one reported location without pre-operation coordinates; every range in a file that exists stays in current, pre-operation coordinates; -- the derived-file consequences, in both directions, as the identity-relevant delta: the derived paths the operation would newly generate — paths where nothing is currently generated — and the recorded derived paths it would remove as no longer generated, the old module path after a file move included. The delta is the whole report: a successful operation finishes by regenerating every derived file, so the full regeneration set is workspace-constant, already named by the inventory of change 5, and carries no information about the operation — the delta is what the identity changes cause. +- every file the operation would rewrite or relocate, with every edit the operation would make in it — each located by a range in current, pre-operation coordinates and classed by what it is. An edit is reported without replacement text: its class and the identity mapping state what changes, and the resulting bytes are observable only by running the operation — the preview is a safety report, not an edit script for external application, which would bypass the journaled mapping. The classes cover everything the operations edit, not only reference occurrences: reference-occurrence rewrites (change 1's occurrences — `d` references, `text(...)` references, TypeScript markers); `id`-attribute rewrites (rename's and the section move's re-identification); import edits — specifier rewrites, import additions, and import removals; the section move's origin deletion — one range spanning every byte the origin edit removes: the construct's own characters, extended over the leftover whitespace and line terminator of a line the existing line-drop rule additionally drops (bytes contiguous with the construct by construction), so the adjunct drop lies inside this class's range rather than forming a class of its own — its target insertion point, and the self-closing-target-parent rewrite when one applies; and the file move's relocation of the file itself. A section move whose target file does not yet exist reports that file's creation as its own class, with the insertion point at the start of the new file — the one reported location without pre-operation coordinates; every range in a file that exists stays in current, pre-operation coordinates. Reported ranges may nest: the section move's re-identification `id`-attribute rewrites locate, in those same pre-operation coordinates, inside its origin deletion's range — each edit reported under its own class, containment being geometry, not double-reporting; +- the derived-file consequences, in both directions, as the identity-relevant delta: the derived paths the operation would newly generate — paths where nothing is currently generated — and the recorded derived paths it would remove as no longer generated, the old module path after a file move included. The delta is the whole report: a successful operation finishes by regenerating every derived file, so the full regeneration set is workspace-constant, already named by the inventory of change 5, and carries no information about the operation — the delta is what the identity changes cause. Both directions consult the record — currently generated means recorded as generated: presence at a path cannot tell a generated occupant from a foreign one — and a preview, writing nothing, never refreshes it, so recorded state that exists but cannot be read as a record meets the delta exactly as it meets the inventory, with the outcome change 5 defines: the delta, both directions one datum, is reported explicitly unavailable — never fabricated, never read as empty — the corruption accompanies the report as the same reported finding, same numbered condition and stable code (changes 5, 6), the invocation exits 1 under the existing partition, and every other part of the preview report is emitted in full. -A preview succeeds exactly when the real operation would proceed and is refused exactly when — and reporting what — the real operation would refuse, with the same exit-code classification — an equivalence over workspace state, validation and planning, not over scheduling: the refusal that meets a mutating command while another runs applies to the real operation only, never to its preview, which the concurrency rules class as non-mutating. A preview writes nothing (no sources, no journal, no derived files, no graph data) and is therefore a non-mutating command under the concurrency rules, safe to run while readers run. Preview output is byte-deterministic. +A preview succeeds exactly when the real operation would proceed and is refused exactly when — and reporting what — the real operation would refuse, with the same exit-code classification — an equivalence over workspace state, validation and planning, not over scheduling: the refusal that meets a mutating command while another runs applies to the real operation only, never to its preview, which the concurrency rules class as non-mutating. The unreadable-record outcome of the delta is the equivalence's one stated exception, and it sits on the success side: the real operation is not refused there — a corrupt record fails no build validation, and the finishing regeneration replaces corrupt graph data — so the preview is not refused either; it succeeds carrying the finding and the unavailable delta, exiting 1 under the existing partition where the operation it previews would proceed. A preview writes nothing (no sources, no journal, no derived files, no graph data) and is therefore a non-mutating command under the concurrency rules, safe to run while readers run; the existing test seam tied to acquiring workspace exclusivity is a behavior of that acquisition — a preview, acquiring nothing, never engages it. Preview output is byte-deterministic. ### 8. Machine-interface identification From a72466283da8170d7ed192f523f48e4166101e4b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 19:35:43 +0000 Subject: [PATCH 017/226] sdg(phase-3): apply optional clarifications O2/O3, accept patch 0001 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration 12 review: no Critical or Important items — refinement converged. Applied O2: change 6 now states the invocation-anchored reporting form for every configuration-error concerned path (the found or --config-named configuration file, and the missing-configuration working-directory case), instead of leaving the non-missing cases to the compatibility note's plural. Applied O3: change 3's import entry now states the binding-name datum for a bindingless declaration is structural absence — reported as absent, never as unavailable — per the document's root tags/coverage taxonomy and change 4's closed unavailable-data list. Rejected O1: naming whether the unreadable-record finding becomes a new numbered condition or folds into the existing staleness condition is validation-section organization, which the patch's methodology preamble delegates to spec refinement; the information contract (reported finding, stable code, exit 1, full answer emitted, identical reporter set) is the same on both branches, as the review itself notes. Stage: Proposed -> Accepted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index 3bb9f87..a847940 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -1,7 +1,7 @@ # 0001 — Foundational machine surfaces for an external spec UI - **Type:** Improvement Proposal (IP) -- **Stage:** Proposed +- **Stage:** Accepted - **Branch:** `claude/xspec-ui-apis-4df8fa` (harness-designated for this session; stands in for `patch/external-ui-apis`) ## Motivation @@ -61,7 +61,7 @@ A query surface returns, for a spec source file, everything needed to overlay st - the root node and the full section tree in document order. The tree is positional — defined by construct nesting alone — so it exists for every parseable file whatever findings the file carries (change 4). Each node carries its source range, its raw attribute spellings as parsed (attribute inclusion is by form: every attribute the tag spells appears — repeated, unknown, and spread attributes included — its invalidity a located finding of change 6, never a view omission), and — each where defined, explicitly unavailable otherwise (change 4) — its node identity, tags, coverage attribute, and (on request) own and subtree text. A root's tags and coverage attribute are a distinct, defined case: structurally absent under existing conventions (absent for roots), they are reported as absent, never as unavailable — structural absence implies no finding and, unlike change 4 unavailability, carries no exit-1 consequence; - for each non-root node, the decomposition of its construct range: the opening tag's range and the closing tag's range (a self-closing section has an opening-tag range only; a root node spans the whole file and has neither). An interactive consumer needs the tags separately from the content they enclose — to render a section header in place of its opening tag, hide or fold what a tag pair encloses, and land navigation on a section's tag rather than selecting its entire construct; -- every import declaration, valid or invalid, with its source range, its binding name where one is bound, and its resolved target file where specifier form and discovery define one — explicitly unavailable otherwise (change 4), with the invalidity itself a located finding of change 6; +- every import declaration, valid or invalid, with its source range, its binding name where one is bound (for a declaration binding none the datum is structurally absent — reported as absent, never as unavailable, exactly as a root's tags and coverage attribute are), and its resolved target file where specifier form and discovery define one — explicitly unavailable otherwise (change 4), with the invalidity itself a located finding of change 6; - every reference occurrence in the file (change 1), positioned in document order; - every MDX comment's source range. With tags, imports, comments, and embedding occurrences located (an embedding occurrence's span is its full braced container, change 1), every construct that Markdown compilation removes is positioned, so on a finding-free file a consumer can classify each byte as annotation or content without re-parsing the MDX. On an imperfect file the classification is joint with change 6: spellings and constructs that produce no occurrence or view entry (change 4) are located by their findings' ranges, and the two surfaces together still position every removable construct; - position resolution as a direct query: given the file and a byte offset, the innermost enclosing section and, when the offset lies within a reference occurrence, that occurrence and its resolved target. Resolution is by range containment and is total over the file: every within-file offset lies in the root's range, so bytes inside imports, comments, and content between sections resolve to the innermost section construct containing them — the root when none does; the offset equal to the file length (the caret position at end of file) resolves to the root; a greater offset, like an offset value that is not a non-negative integer, is a usage error, per existing conventions. The same resolution must also be derivable from the view's data alone, so both index-building consumers and lightweight ones that keep no client-side index are served. @@ -101,7 +101,7 @@ Availability is unconditional: no part of the inventory requires parsing sources Sharpen the validation-error contract so an external tool can render findings inline: - Every reported error condition carries a stable machine-readable code identifying which numbered condition of the validation-errors section it is. Stable codes deliberately cover exactly these conditions plus the refusal reasons below, and no more: a plain usage error — an unknown command or flag, an invalid flag value, and the rest of the existing usage class — describes the invocation the consuming tool itself composed, never workspace content to render inline, so it carries no stable code, while still arriving as the JSON error document of the delivery rule below whenever JSON output is in effect. Review-operation refusals — findings under the existing exit-code partition, but neither numbered conditions nor the `rename`/`move` refusal reasons below — likewise carry no stable code: review flows lie outside this proposal's UI scope, relied on unchanged beyond change 2's range generalization. -- Every error that locates in source carries a location — the file and a source range (byte offsets), the range at the precision the condition allows — for each offending construct. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders inline where it stands and no representative construct is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling that records a participating dependency edge or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call — is identity data on the finding, not a further range. Conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. For missing configuration with no `--config` given — the one condition where no configuration file exists to be concerned — the concerned path is the directory the failed upward search started from, the invocation working directory, reported in change 5's anchoring form (identified relative to the working directory — for this path the degenerate self-reference): invocation input, deterministic per invocation exactly as change 5's anchoring is, so the concerned-path datum is total over these conditions. +- Every error that locates in source carries a location — the file and a source range (byte offsets), the range at the precision the condition allows — for each offending construct. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders inline where it stands and no representative construct is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling that records a participating dependency edge or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call — is identity data on the finding, not a further range. Conditions without an in-source location (configuration errors, path-level conditions, journal and session conditions) carry the file or path they concern. Configuration-error concerned paths are all reported in change 5's anchoring form (identified relative to the invocation working directory): configuration errors precede and block the inventory that reports the anchoring, so the concerned path must be mappable from invocation input alone. Where a configuration file is concerned — the file the upward search found or the path `--config` names — the concerned path is that file; for missing configuration with no `--config` given — the one condition where no configuration file exists to be concerned — it is the directory the failed upward search started from, the invocation working directory (for this path the degenerate self-reference). Both cases are invocation input, deterministic per invocation exactly as change 5's anchoring is, so the concerned-path datum is total over these conditions. - The JSON report form presents these fields for every finding, preserving the existing requirements that all conditions are reported together and that JSON carries the same information as the human report. - The same contract covers operation refusals: each distinct reason `rename` and `move` refuse — exactly what a refused preview of change 7 reports — carries a stable machine-readable code and the file, source range, or identity it concerns — under the location-cardinality rule above when a reason involves several constructs, the cycle a refused move would create included — so a refusal renders as precisely as a finding. Refusals are findings under the existing exit-code partition, so the JSON report form above already carries them. - Machine-readable delivery is closed over the outcome classes: whenever JSON output is in effect, an invocation that fails with a usage or configuration error (exit 2) emits a single JSON document as its entire standard output reporting the error — carrying, for conditions with a defined code (configuration errors included), the stable code and the concerned file or path above — amending the existing rule that such an error leaves standard output empty. JSON output is in effect in exactly two cases: `--json` appears among the invocation's arguments — governing error delivery even when the arguments are themselves the error, an unknown command or flag included — or the invoked surface is JSON-only, a single JSON document its only output form with or without `--json`, as the existing single-document surfaces are and added surfaces may be defined; no flag need be present there. Outside these two cases the existing empty-standard-output rule stands. Exit codes, error precedence, and human-readable standard-error text are unchanged. Without this channel, configuration errors — the one class that precedes and blocks every surface of changes 1, 3, 5, and 7 — would be the one class an external tool cannot consume. From 60409426b690eeedb881e023f1a294ba29fb98fa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 20:03:13 +0000 Subject: [PATCH 018/226] sdg(phase-4): apply IP 0001 external-UI machine surfaces to SPEC.md (iter 1) Integrates all eight accepted surfaces as timeless end-state requirements (review items C1-C9), with concrete invocations (I1): - 5.7 reference occurrences (concept, spans per kind, no-occurrence rule, total order); 11.3 `xspec occurrences [--file ] [--to ]` with syntactic --to acceptance and malformed-only usage errors. - 1.7 rewritten: code-location ranges defined; presented in exactly two outputs (occurrence records, review payloads); edge endpoints stay bare identities; 10.7 payload generalized to present graph nodes. - 11.4 `xspec view` (tree, tag decompositions, raw attributes, imports, occurrences, comments, byte-classification guarantee, domain forms) and 11.5 `xspec at ` position resolution. - 11.2 availability contract: parse-local structure, spelled/defined identity, tags/coverage and expanded-text definedness, explicit unavailability, consulted domains, exit mapping, stale-data rule; 13.3 scoped so occurrences/view/at answer mid-edit. - 11.6 `xspec inventory` (anchoring, configuration view, sources, derived map with provenances, graph-data area write reservation and unattributed-path rule, durable files, deterministic ordering). - 14: stable codes for all 23 conditions, location cardinality, concerned paths, refusal-reason codes; new condition 23 (unreadable recorded state); 12.0 JSON error-document delivery whenever JSON output is in effect. - 6.6 previews (`--preview` on rename/move): full plan report, edit classes with pinned ranges, derived delta with condition-23 outcome, refusal equivalence with 13.5 scheduling exception; --test-hold with --preview pinned as a usage error (I2). - 12.6 `xspec version`: machine-interface version 1, workspace-independent, outside configuration-error precedence (14.14 scoped). - 12.0/12.5/13.3/13.5 conventions absorbed the new outcomes (C9). I3: no IP/delta framing survives; internal cross-references only. I4 modularity assessment: no module split - every added portion is consumer-facing interface/contract that PROCESS.md requires in SPEC.md itself, and it is densely interlocked with 1.7/12.0/13.3/14; no clean seam exists in the pre-existing content either. Single file retained under the SHOULD. O1 placement suggestion followed. Rejected items: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/SPEC.md | 158 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 138 insertions(+), 20 deletions(-) diff --git a/specs/SPEC.md b/specs/SPEC.md index 6cd9a55..6acc211 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -76,7 +76,7 @@ Every requirement node has two text values, defined by the removal and replaceme * subtree text: the section construct's contribution to its file's compiled Markdown output (for the root, the entire output). Each child contributes its subtree text at the position it occupies in the source — interleaved with the node's own contribution in document order, not appended after it. * own text: the node's subtree text with every child's contribution excised: the runs that child constructs divide (its own-text runs), joined exactly at the excision points. N child constructs divide a node's contribution into exactly N + 1 runs in document order — one before the first child construct, one between each adjacent pair, one after the last. A run MAY be empty, and empty runs count, both here and in hashing (5.5). -Both text values are exact bytes; the rules of 3 leave no joining or separator choices, and `text(...)` replacement is one of them, so both values carry embedded text fully expanded. Every own or subtree text this specification outputs — documentation comments (4.2), review text payloads (10.2, 10.7), `query` (11), `show` (12.4) — is this expanded value, and `text(...)` always returns subtree text. +Both text values are exact bytes; the rules of 3 leave no joining or separator choices, and `text(...)` replacement is one of them, so both values carry embedded text fully expanded. Every own or subtree text this specification outputs — documentation comments (4.2), review text payloads (10.2, 10.7), `query` (11.1), structural views (11.4), `show` (12.4) — is this expanded value, and `text(...)` always returns subtree text. Hashing does not use the expanded values. For hashing (5.5), a node has an own content sequence, computed like its own-text runs but with `text(...)` replacement suspended: each `text(...)` expression is excised like a child construct — contributing no bytes and marking an excision point where its target node enters — rather than replaced by expanded text. For the line-drop rule of 3, the excised expression counts as remaining line content, so the empty-expansion drop never applies; all other removal rules of 3 apply unchanged. Own content thus alternates byte runs (empty runs included) with node references — the excised child at each child excision point, the target at each embedding excision point, the two kinds distinguished — and an embedded target's text is no part of the embedder's own content. This distinction drives hashing (5.5) and change categories (5.6). @@ -84,7 +84,13 @@ Source files are UTF-8: a discovered spec or code source that is not valid UTF-8 ### 1.7 Source ranges -Where this specification outputs a source range (10.7, 11, 12.4), the range locates a requirement node in its source file: a pair of byte offsets into the file's bytes, zero-based, start-inclusive and end-exclusive, spanning — for a non-root node — the section construct's own characters, from the first character of its opening tag through the last character of its closing tag, or the self-closing tag's own characters (1.1, 6.5), and — for a root node — the entire file. Code locations carry no source range: a code-location identity (4.6) already locates its construct. +A source range is a pair of byte offsets into a file's bytes, zero-based, start-inclusive and end-exclusive. Every source range this specification outputs — for requirement nodes (10.7, 11, 12.4), code locations, reference occurrences (5.7), findings and refusals (14), structural views (11.4), and preview edits (6.6) — uses this one convention. + +A requirement node's range spans — for a non-root node — the section construct's own characters, from the first character of its opening tag through the last character of its closing tag, or the self-closing tag's own characters (1.1, 6.5), and — for a root node — the entire file. + +A code location's range spans — for a whole-file location — the entire file, and — for a named code unit (4.6) — the construct that binds the unit's name. Where one declaration derives several named units, each unit's range is the construct binding its own name: a function- or class-valued variable declaration's unit spans its own name through its initializer, not the enclosing multi-declaration statement, while the nested units a dotted namespace name derives all share the single namespace declaration's range — the one construct binding them all. A default export whose exported construct is named takes that construct's own range; the unit named `default` that an anonymous exported construct derives takes the whole export declaration's range. A document-order-disambiguated unit (`path#unit@N`, 4.6) carries the range of its own occurrence's construct. + +A code location is presented with its source range in exactly two outputs: occurrence records (5.7, 11.3) — the surface that makes every code unit's range reachable, since a code location enters the graph's edges only as a source and every edge from it is recorded by at least one occurrence — and review payloads (10.7). Everywhere else a graph node appears as an edge endpoint — `edges` rows, `reachable` witness paths, and the per-node incoming and outgoing edge lists of `query` (11.1) — it is a bare identity, requirement node and code location alike. ## 2. Source Syntax @@ -235,7 +241,7 @@ The graph contains requirement nodes and code locations. * `embeds`: created by `{text(...)}` in MDX and `text(...)` in TypeScript * `references`: created by a bare TypeScript reference -`depends`, `embeds`, and `references` are the dependency edge kinds; an edge of these kinds means the source depends on the target. `contains` is structural. Edges of each kind form a set: duplicate declarations collapse to a single edge. Each feature states which kinds it interprets. +`depends`, `embeds`, and `references` are the dependency edge kinds; an edge of these kinds means the source depends on the target. `contains` is structural. Edges of each kind form a set: duplicate declarations collapse to a single edge; the textual spellings behind dependency edges are recorded as reference occurrences (5.7). Each feature states which kinds it interprets. ### 5.3 Cycles @@ -269,6 +275,16 @@ Baseline hash comparison is defined only for a node present on both sides: a nod Categories are independent flags; a node MAY carry several. The originating nodes of a change are the nodes where edits occurred — those carrying `changed` or `metadata-changed`; every category MUST be attributed to its originating nodes. For a single edit to a leaf's text: the leaf is `changed`; every ancestor is `descendant-changed` attributed to the leaf; sibling subtrees receive no category; dependents of any node on that path are `upstream-changed`, as are those dependents' ancestors — all attributed to the leaf. For an edit that only adds or removes a child C of parent P (no other text touched): C is `changed` — added or deleted; P is `changed` (its own content changed, 5.5) and `descendant-changed` attributed to C; P's ancestors are `descendant-changed` attributed to P and C; and the `upstream-changed` cascade follows as above. For an edit that only adds or removes `d` targets on a node D: D is `metadata-changed`, no node is `changed` or `descendant-changed`, and every other node whose effectiveHash changed — D's ancestors, dependents, dependents' ancestors, and so on transitively — is `upstream-changed` attributed to D. A metadata edit touching only `coverage` or `tags` changes no effectiveHash and propagates no category. +### 5.7 Reference occurrences + +A reference occurrence is one textual spelling that records a dependency-kind edge (5.2): one `d` reference — each entry of a `d` array separately, never the array or the prop (2.2) — one MDX `{text(...)}` embedding (2.3), one TypeScript `text(...)` call (4.3), or one TypeScript dependency marker (4.5). Edges are sets; occurrences are the positions behind them: duplicate references that collapse to a single edge each remain distinct occurrences. + +An occurrence carries: the referencing file; its own source range (1.7); its edge kind; its source graph node — one datum: the node's identity together with that node's own source range (1.7); and its resolved target's identity. Occurrence spans are exact per kind: a `d` reference occurrence spans that one reference's own expression; an MDX embedding occurrence spans the entire `{text(...)}` expression container, opening brace through closing brace — the whole construct Markdown compilation replaces (3); a TypeScript `text(...)` occurrence spans the entire call expression, callee through closing parenthesis, argument included; a marker occurrence spans the bare reference chain alone, exclusive of any statement terminator. + +A construct that records no edge records no occurrence: an import declaration (its binding used or not), a binding introduced type-only, a chain rooted at a shadowing local declaration (4.5), and a reference spelling that is dynamic or does not resolve (11.2) record none — invalid and unresolved spellings are located by their findings' ranges instead (14). + +Occurrence order is total and deterministic: by referencing file path (byte order), then by range start, then by range end. Distinct occurrences are distinct spellings occupying distinct spans, so identical ranges do not occur and no further tiebreak exists. + ## 6. Identity Continuity ### 6.1 The journal @@ -286,23 +302,31 @@ When a command takes a baseline git ref, the baseline graph is reconstructed fro ### 6.4 Rename ```sh -xspec rename +xspec rename [--preview] ``` -Renames a requirement ID, rewrites descendant IDs by prefix replacement, rewrites every reference to the affected identities across all configured spec and code sources (`id` attributes, `d` references, `text(...)` references, TypeScript markers), and appends the mapping to the journal. Rewrites are minimal in-place edits, preserving each reference's quote style and access form (2.4); where a form cannot be kept — a chain segment whose new name is not a valid TypeScript identifier, or a reference converted between local and imported form (6.5) — the rewritten part uses dot access for segments that are valid TypeScript identifiers, double-quoted computed access for segments that are not, and double-quoted string literals. Type-level TypeScript references record no edges (4.5) and are not rewritten: a rename or move can leave them naming vacated identities — a consumer type error outside xspec's validations — while the workspace stays valid. Validation MUST confirm: the new ID is valid; it differs from the old ID and collides with no existing ID; structural parent rules remain satisfied; all rewritten references resolve. A `` or old ID that does not exist is a usage error (12.0); every other validation failure refuses the rename (exit 1). Rename MUST also refuse (exit 1), before modifying anything, when the current workspace fails the validations of `xspec build` (12.1), so the operation only ever rewrites a valid workspace; the usage-error argument checks precede this refusal (12.0). A successful rename finishes by regenerating derived files exactly as `xspec build` does (12.1) — which cannot fail, per the precondition — so generated modules, Markdown output, and graph data match the rewritten sources and no stale output (14.10) remains. +Renames a requirement ID, rewrites descendant IDs by prefix replacement, rewrites every reference to the affected identities across all configured spec and code sources (`id` attributes, `d` references, `text(...)` references, TypeScript markers), and appends the mapping to the journal. Rewrites are minimal in-place edits, preserving each reference's quote style and access form (2.4); where a form cannot be kept — a chain segment whose new name is not a valid TypeScript identifier, or a reference converted between local and imported form (6.5) — the rewritten part uses dot access for segments that are valid TypeScript identifiers, double-quoted computed access for segments that are not, and double-quoted string literals. Type-level TypeScript references record no edges (4.5) and are not rewritten: a rename or move can leave them naming vacated identities — a consumer type error outside xspec's validations — while the workspace stays valid. Validation MUST confirm: the new ID is valid; it differs from the old ID and collides with no existing ID; structural parent rules remain satisfied; all rewritten references resolve. A `` or old ID that does not exist is a usage error (12.0); every other validation failure refuses the rename (exit 1), each distinct refusal reason carrying its stable code and location (14). With `--preview`, the operation is planned and reported but performed on nothing (6.6). Rename MUST also refuse (exit 1), before modifying anything, when the current workspace fails the validations of `xspec build` (12.1), so the operation only ever rewrites a valid workspace; the usage-error argument checks precede this refusal (12.0). A successful rename finishes by regenerating derived files exactly as `xspec build` does (12.1) — which cannot fail, per the precondition — so generated modules, Markdown output, and graph data match the rewritten sources and no stale output (14.10) remains. ### 6.5 Move ```sh -xspec move -xspec move # # +xspec move [--preview] +xspec move # # [--preview] ``` The first form relocates an entire source file; IDs are unchanged and every node's identity changes only in its file part. Relocation also rewrites the moved file's own import specifiers, and the paths by which other files import the moved file's generated module, so all references continue to resolve. The second form extracts a section subtree: the section and its descendants are removed from the origin, inserted as the last child of the target parent (or at the end of the file for a top-level `new-id`), and re-identified by prefix replacement of `` with ``. The target file is created if absent, empty before insertion. The second form's text edits are exact: the moved text is the section construct's own characters — from the first character of its opening tag through the last character of its closing tag, or the self-closing tag's own characters for a self-closing section (1.1). At the origin it is deleted in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3). It is inserted immediately before the target parent's closing tag — at the end of the file for a top-level `new-id` — followed by a U+000A line terminator, and preceded by one when the insertion point is not at the start of a line. A self-closing target parent (1.1) is first rewritten to the paired form: its `/` and any whitespace immediately before or after the `/` are deleted, and the closing tag matching the opening tag's name (`` or ``) is appended immediately after the tag's terminating `>`; the insertion rule then applies before that closing tag. Beyond these edits, the identity and reference rewrites of this section, and the finishing regeneration, a move changes no bytes. In both forms, all references across the workspace are rewritten to resolve to the new identities, converting between local and imported forms and adding or removing spec module imports as the rewrite requires — an import is added when a rewritten reference needs a module binding its file lacks, and an existing spec module import is removed exactly when its binding had references and the rewrite leaves it with none (an import whose binding was already unreferenced stays, 2.1) — and the full mapping is appended to the journal. An added import binds fresh identifiers colliding with no binding already in the file (2.1, 4); its identifier choice and placement, like every rewrite, are deterministic — rewritten file content is byte-deterministic for a given operation and workspace state (6.1). A successful move regenerates derived files as rename does (6.4). -Move validation mirrors rename validation, including the valid-workspace precondition (6.4) and the usage-error classification of a nonexistent origin file or ID (12.0); the mirrored checks read in identity terms: the new ID is valid, structural parent rules remain satisfied, all rewritten references resolve, the new identity differs from the old — a cross-file section move keeping its ID is therefore valid, while the exact self-move, `#` equal to `#`, is refused and appends no journal entry — and, in the section form, `` collides with no ID remaining in the target file after the removal. Move additionally MUST refuse: a move that would create an import cycle among spec source files or a dependency cycle; a file-form move whose destination file already exists; a section-form move whose target parent — the target file's section bearing `` minus its final segment, needed whenever `` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal; and a move whose destination file path (including a target file to be created) would not be a valid discovered spec source after the move — a path belonging to no configured spec group (a move never takes a node out of the workspace), belonging to a code group as well (14.14), containing `#`, not valid UTF-8, or lacking the `.mdx` extension (14.19). These refusals keep every successful move's finishing regeneration (6.4) on a valid workspace, so it cannot fail. +Move validation mirrors rename validation, including the valid-workspace precondition (6.4) and the usage-error classification of a nonexistent origin file or ID (12.0); the mirrored checks read in identity terms: the new ID is valid, structural parent rules remain satisfied, all rewritten references resolve, the new identity differs from the old — a cross-file section move keeping its ID is therefore valid, while the exact self-move, `#` equal to `#`, is refused and appends no journal entry — and, in the section form, `` collides with no ID remaining in the target file after the removal. Move additionally MUST refuse: a move that would create an import cycle among spec source files or a dependency cycle; a file-form move whose destination file already exists; a section-form move whose target parent — the target file's section bearing `` minus its final segment, needed whenever `` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal; and a move whose destination file path (including a target file to be created) would not be a valid discovered spec source after the move — a path belonging to no configured spec group (a move never takes a node out of the workspace), belonging to a code group as well (14.14), containing `#`, not valid UTF-8, or lacking the `.mdx` extension (14.19). These refusals keep every successful move's finishing regeneration (6.4) on a valid workspace, so it cannot fail. Each distinct refusal reason carries its stable code and location (14); with `--preview`, the operation is planned and reported but performed on nothing (6.6). -### 6.6 Manual restructuring +### 6.6 Previews + +`xspec rename … --preview` and `xspec move … --preview` perform the full validation and planning of the operation and report its consequences while modifying nothing: no sources, no journal, no derived files, no graph data. A preview is refused exactly when — reporting what, and exiting as — the real operation would be refused, and succeeds exactly when the real operation would proceed. The equivalence is over workspace state (validation and planning), never over scheduling: the mutual-exclusion refusal of 13.5 applies to the real operation only. A preview invocation is a non-mutating command under 13.5 — it acquires no workspace exclusivity and is safe to run while readers run — and does not take the acquisition-tied test seam: supplying `--test-hold` together with `--preview` is a usage error (12.0). Preview output is byte-deterministic (12.0), supports `--json` per 12.0, and reports: + +* the complete identity mapping the operation would journal; +* every file the operation would rewrite or relocate, with every edit the operation would make in it, each located by a source range (1.7) in current, pre-operation coordinates and classed as exactly one of: a reference-occurrence rewrite (5.7 — `d` references, `text(...)` references, TypeScript markers); an `id`-attribute rewrite (rename's and the section move's re-identification); an import-specifier rewrite; an import addition; an import removal (6.5); the section move's origin deletion — one range spanning every byte the origin edit removes: the construct's own characters, extended over the leftover whitespace and line terminator of each line the line-drop rule additionally drops (6.5, 3), bytes contiguous with the construct, so the adjunct drop lies inside this class's range rather than forming a class of its own; the section move's target insertion point; the self-closing-target-parent rewrite when one applies (6.5); the file move's relocation of the file itself; or target-file creation — reported, when the section form's target file does not yet exist, as its own class with the insertion point at the start of the new file, the one reported location without pre-operation coordinates. A rewrite's range is the construct it rewrites — a reference occurrence's span (5.7), the `id` attribute's own characters, the import specifier literal's characters, the target parent's self-closing tag; a removal's range spans every byte its edit removes, as the origin deletion's does; an import addition's insertion point, the section move's target insertion point, and the created target file's start are zero-length ranges at the insertion offset; the relocation's range is the entire moved file. An edit is reported without replacement text: its class and the identity mapping state what changes, and the resulting bytes are observable only by running the operation — the preview is a safety report, not an edit script whose external application would bypass the journaled mapping. Reported ranges MAY nest — the section move's re-identification rewrites locate, in the same pre-operation coordinates, inside its origin deletion's range — each edit reported under its own class, containment being geometry, not double-reporting; +* the derived-file delta, both directions one datum: the derived paths the operation would newly generate — paths where nothing is currently recorded as generated — and the recorded derived paths (13.3) the operation would leave no longer generated, the pre-move module path after a file move included. The delta is the identity-relevant consequence; the full regeneration set a successful operation rewrites is workspace-constant and reported by the inventory (11.6), so the preview does not repeat it. Both directions consult the recorded derived-file paths — presence at a path cannot tell a generated occupant from a foreign one — and a preview, writing nothing, never refreshes the record. Recorded state that exists but cannot be read as a record is condition 23 (14): the delta is reported explicitly unavailable — never fabricated, never read as an empty record — the finding accompanies the report with its stable code, the invocation exits 1 (12.0), and every other part of the report is emitted in full. The real operation is not refused in that state — a corrupt record fails no build validation, and the finishing regeneration (6.4) replaces corrupt graph data — so the preview is not refused either: the unreadable record lies on the success side of the refusal equivalence. + +### 6.7 Manual restructuring Renames or moves performed by editing files directly, without the commands, produce no journal entries and are treated as deletions plus additions. @@ -512,15 +536,19 @@ xspec review export --json `list` reports every session, in byte order of session name, with its name, strategy, and item counts by status — counted from stored statuses, without the read-time invalidation of 10.4 — and reports each corrupt session (14.21) by name as corrupt in place of those fields; `list` exits 1 when any session is corrupt and 0 otherwise. `status ` reports the session's items in item order — each with id, kind, scope, status, and blocked state — plus totals by status. `show ` reports the full item: every field of 10.2 plus the same self-contained text payload as `next --json`. `export ` emits the entire session as a single JSON document — its only output form, with or without `--json`: the session's name, strategy, recorded creation parameters, and recorded decompositions, plus every item in item order, each with every field of 10.2, its blocked state, and the same self-contained text payload as `next --json`, with read-time invalidation (10.4) applied. -`next` returns the first item in the session's item order (10.5, 10.6, or the coverage order below) that needs review (`unresolved` or `invalidated`, 10.3) and is unblocked. When no item qualifies, every item is resolved — with acyclic `blockedBy` (10.1) a minimal needing-review item is always unblocked, so no other case exists — and `next` exits 0 and reports the session fully resolved (a session with no items reports the same) in both human and `--json` output; the JSON payload then contains no item. With `--json`, the payload MUST be self-contained, so the item can be acted on without further reads: every scope, context, and origin node, under its current identity and presence (10.4) and — for a present requirement node — with its source range (1.7); the item's `baseline` and `current` hashes (10.2); and text per item kind. Scope text is the scope root's subtree text for `subtree-coherence`, the scope node's subtree text for `uncovered-requirement`, and the scope node's own text for `parent-consistency`, `dependency-consistency`, and `metadata-consistency`; a code location has no text value, so a `code-impact` scope enters as identity and presence alone. Context text is own text where the context is an ancestor chain (`subtree-coherence`, `uncovered-requirement`) and subtree text otherwise (`parent-consistency` branch children; `dependency-consistency`, `metadata-consistency`, and `code-impact` targets). Origin text is a before/after pair of the node's own text: before from the item's `baseline` state (10.2), after from the current graph. Every text value is the expanded value of 1.6. A present node's text is read from the current graph; an absent node's is its value in the most recent graph state that contained it, among the item's `baseline` state and the states under which mutating subcommands (13.5) derived the item with that node among its nodes (10.2, 10.5) — a node contained in none, and the absent side of a before/after pair, is presented absent, with no text. A `coverage` session's `uncovered-requirement` items are ordered by file path, then document order. +`next` returns the first item in the session's item order (10.5, 10.6, or the coverage order below) that needs review (`unresolved` or `invalidated`, 10.3) and is unblocked. When no item qualifies, every item is resolved — with acyclic `blockedBy` (10.1) a minimal needing-review item is always unblocked, so no other case exists — and `next` exits 0 and reports the session fully resolved (a session with no items reports the same) in both human and `--json` output; the JSON payload then contains no item. With `--json`, the payload MUST be self-contained, so the item can be acted on without further reads: every scope, context, and origin node, under its current identity and presence (10.4) and — for a present graph node, requirement node and code location alike — with its source range (1.7; an absent node carries none); the item's `baseline` and `current` hashes (10.2); and text per item kind. Scope text is the scope root's subtree text for `subtree-coherence`, the scope node's subtree text for `uncovered-requirement`, and the scope node's own text for `parent-consistency`, `dependency-consistency`, and `metadata-consistency`; a code location has no text value, so a `code-impact` scope enters as identity, presence, and — when present — source range, with no text. Context text is own text where the context is an ancestor chain (`subtree-coherence`, `uncovered-requirement`) and subtree text otherwise (`parent-consistency` branch children; `dependency-consistency`, `metadata-consistency`, and `code-impact` targets). Origin text is a before/after pair of the node's own text: before from the item's `baseline` state (10.2), after from the current graph. Every text value is the expanded value of 1.6. A present node's text is read from the current graph; an absent node's is its value in the most recent graph state that contained it, among the item's `baseline` state and the states under which mutating subcommands (13.5) derived the item with that node among its nodes (10.2, 10.5) — a node contained in none, and the absent side of a before/after pair, is presented absent, with no text. A `coverage` session's `uncovered-requirement` items are ordered by file path, then document order. `split` decomposes a `subtree-coherence` item whose scope root has children into one `subtree-coherence` item per child subtree — its context the child's ancestor chain, as in 10.5 and 10.6 — plus one `parent-consistency` item for the scope root's own text, whose context is the child subtrees and whose `blockedBy` is those child items. An item of the decomposition whose kind and scope node already exist in the session is not created: the existing item takes its place, keeping its `id`, status, and recorded state — so `split` in an `audit` session reuses the children's existing items. Each decomposition item's `origin` is the originating nodes (5.6) within its scope and context — empty in an `audit` session. Newly created decomposition items additionally inherit the original's `blockedBy`; every item that was blocked by the original becomes blocked by all items of the decomposition; the original item is removed from the session and its `id` is never reused. The decomposition — the original's kind and scope node, replaced by per-child `subtree-coherence` items and the scope node's `parent-consistency` item — is recorded durably in the session and governs re-derivation (10.5). `split` on an item of any other kind, or on a `subtree-coherence` item whose scope root has no children, is refused. `resolve` sets the status and records the current relevant state (10.4); it applies to any unblocked item regardless of current status, so an `invalidated` (or previously resolved) item is re-resolved the same way. `--status` accepts `updated`, `no-change`, and `skipped`; any other value is a usage error, as is an unknown session name or item ID in any `review` command's arguments (12.0). Resolving a blocked item is refused, as is `review create` with the name of an existing session. -## 11. Query +## 11. Query Surfaces + +Five commands give scripts, agents, and external tools machine access to the workspace: `query` (11.1) answers set-level graph questions; `occurrences` (11.3) enumerates reference occurrences; `view` (11.4) returns whole-document structural views; `at` (11.5) resolves byte positions; `inventory` (11.6) reports the workspace's shape. Each is JSON-only: a single JSON document is its only output form, with or without `--json` (12.0). `occurrences`, `view`, and `at` answer per file under the availability contract of 11.2; `query` reads only valid workspaces (13.3); `inventory` parses no sources and answers whatever their validity (11.6). -`xspec query` gives scripts and agents set-level, JSON-only access to the graph — a single JSON document is its only output form, with or without `--json` (12.0): +### 11.1 `xspec query` + +`xspec query` gives scripts and agents set-level access to the graph: ```sh xspec query node @@ -533,20 +561,95 @@ xspec query reachable --from --to [--kinds ] `` is a requirement-node identity: `path#id`, or a bare `path` for a file's root node (1.5). `` is any graph-node identity: a requirement node, or a code location (`path`, `path#unit`, or `path#unit@N`; 4.6); whether a bare path names a root node or a code file follows from the file's group (7), and a path in no configured group is unknown (12.0). `node` returns identity, source range (1.7), own and subtree text, all four hashes, tags, coverage attribute, and incoming and outgoing edges by kind; for a root node the coverage attribute is reported as absent (5.5), and `nodes --coverage` matches no root. `nodes` filters combine conjunctively, and its rows are requirement nodes: `--group` accepts only a configured spec group's name — a code group's name is an invalid flag value (12.0), the wrong-kind group reference of 14.14. `nodes`, `subtree`, and `ancestors` return one row per node: identity, source range, tags, and coverage attribute (absent for roots). `subtree ` returns the queried node and all its descendants, in document order; `ancestors ` returns the queried node's proper ancestors — itself excluded — nearest first, ending at the file root. `reachable` reports whether a dependency path — one or more edges; a zero-length path is not one — exists under the given kinds and, when one does, one shortest witness path (12.0); equal `--from` and `--to` therefore report that no path exists, since a nontrivial path from a node to itself would be a dependency cycle (5.3). `reachable`'s `--kinds` accepts only the three dependency edge kinds and defaults to all three — `contains` is an invalid flag value (12.0) — while `edges --kinds` filters over all four kinds and defaults to no kind filter. List-valued flags (`--kinds`) take a comma-separated list; `--file ` uses the glob rules of 7, the outside-root rule included — a `--file` pattern resolving outside the workspace root is an invalid flag value (12.0), exit 2 like its configuration-time counterpart (14.14). All results use stable, deterministic ordering. +### 11.2 Availability on imperfect files + +`occurrences` (11.3), `view` (11.4), and `at` (11.5) serve consumers — an external editor above all — while a workspace is mid-edit, when transiently invalid states are the norm. Their availability is defined per file, from parsing alone, never gated on workspace-wide validity; the read semantics of every other command (13.3) are untouched by this section. + +**Structure is parse-local.** Everything derived from one file's parse — the positional section tree (11.4), every construct's ranges and their decompositions, raw attribute and import spellings, comment ranges, and reference-occurrence positions — remains available while other files are invalid and while the file itself carries findings of either level: resolution-level (unresolved references, cycle participation) and per-file structural (missing, duplicate, or structurally invalid IDs; malformed segments; invalid props; invalid constructs) alike. Only an unparseable file (14.20), or content the masking rules of 14 already hide, loses its structural data; masking is per file, and these surfaces still answer for every other requested file. + +**Interpreted data is defined or explicitly unavailable.** A section spells an identity exactly when exactly one `id` attribute occurs on its tag with its value in the quoted static-string form of 2.7; that value, well-formed or not, is its spelled identity. Any other case — no `id` attribute, a repeated one (its spellings agreeing or not), or a value in any other form, braced or valueless included — spells no identity, and contests no other section's: identity uniqueness compares spelled identities only, so a bearer whose spelled identity no other section spells keeps its defined identity whatever invalid-form `id` attributes the file holds beside it. A section's node identity (1.5) is defined exactly when it and each enclosing section spell an identity, each spelled identity in the chain is well-formed (1.4) and satisfies the structural rules (1.3), and no other section of the file spells the same identity as it does. The chain conditions are inherited — a descendant of a section that spells no identity, or whose spelled identity is malformed or structurally invalid, has no defined identity — but uniqueness is not: it constrains the section's own spelled identity alone. Duplicate spellings leave every bearer of the duplicated identity undefined, no winner picked, while a uniquely spelled descendant of duplicate-`id` ancestors keeps its defined identity. A defined identity therefore does not imply defined prefix identities. A section's interpreted tags and coverage attribute are defined exactly when its parsed attributes define them unambiguously: an absent prop defines the defaults — no tags, coverage-required (2.5, 2.6) — while a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined, its raw spelling still reported (11.4). + +**Resolution.** A reference spelling resolves exactly when it names exactly one target under these rules: a section whose node identity is defined and equals the named identity, or — for a module reference with no segments (2.2) — the root of a discovered, parseable spec source. Resolution turns on the definedness of the referenced identity itself: a reference to the one section spelling `a.b` resolves — and records an occurrence — even while duplicate spellings of `a` leave every bearer of `a` undefined. A spelling that does not resolve to exactly one target — an unknown target; a unique bearer whose identity these rules leave undefined; an ambiguous one, every duplicate bearer undefined — records no edge and no occurrence, and never reports an unavailable target: its position reaches consumers through its finding's range (14). The two surfaces jointly locate every reference spelling in every parseable file; a spelling inside an unparseable file is hidden with the rest of it, pointed to only by that file's parse-failure finding. + +**Expanded text.** A node's own (respectively subtree) text (1.6) is defined exactly when every embedding the expansion transitively reaches — each `text(...)` spelling in the node's own contribution (respectively anywhere in its subtree), and recursively each one anywhere in every embedded target's subtree — records an occurrence, and the recursion re-enters no node already being expanded (an embedding cycle). One unresolved spelling or one cycle on the expansion path makes the whole value unavailable: partial expansion is fabrication and never occurs. Where defined, the value is exact on imperfect files too, and the removal classification of 3 is by syntactic form, never by validity or resolution: every import declaration is removed by form — binding shape, specifier validity, and target discovery notwithstanding, so an import whose target file was deleted or renamed perturbs no text value; a section tag is removed with every attribute it spells, unknown, repeated, and spread included; and a construct matching no removal rule's form (the stray elements, expression containers, and exports of 14.16) is content, preserved byte-for-byte and located by its finding. A defined value is thus a pure function of the consulted files' parses and the resolved expansions. + +**Unavailability is explicit.** A datum these rules leave undefined — a section's identity, tags, or coverage value; an occurrence's source graph node, identity and range withheld together as one datum (5.7), the enclosing construct's position staying on view through 11.4; an import's resolved target when specifier form or discovery defines none; an own- or subtree-text value — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. + +**Consulted domain, findings, exits.** Every answer of 11.3–11.5 has a consulted domain of files, defined per surface, and the findings (14) of every domain file accompany the answer — a masked file's parse-failure finding included. An invocation whose answer carries any finding or any explicitly-unavailable datum exits 1 with the full answer document still emitted — exit 1 signals imperfection and never withholds the answer; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their precedence (12.0, 14.14). A possibly-incomplete answer is therefore never silent. + +**Never stale, never writing.** These surfaces never answer from stale graph data: on a workspace that passes `build` validation they participate in read-time refresh exactly as the reads of 13.3 do; on one that does not, they answer from the current sources and modify nothing — no graph data, no derived files (13.3). + +### 11.3 `xspec occurrences` + +```sh +xspec occurrences [--file ] [--to ] +``` + +Enumerates reference occurrences (5.7) in occurrence order, one record per occurrence carrying every datum of 5.7 — the source graph node per 11.2 where its section's identity is undefined. The two filters combine conjunctively. + +`--file` admits the discovered source files — spec and code alike — that the glob matches, under the glob rules of 7 (a pattern resolving outside the workspace root is an invalid flag value, as in 11.1). It is a set restriction, not an existence assertion: the enumeration's consulted domain (11.2) is the discovered files it admits; a glob admitting none admits the empty set — an empty, finding-free answer, exit 0 — and no unknown-file usage error exists on this filter. Without `--file`, the consulted domain is the entire discovered set. + +`--to` selects the occurrences whose resolved target it names. It accepts any syntactically well-formed requirement-node identity — `path#id`, or a bare `path` for a root (1.5) — whatever the workspace contains: acceptance is syntactic, and only a malformed spelling is a usage error (12.0). A spelling is well-formed exactly when it contains at most one `#`, its path part — the whole spelling, or the part before the `#` — is non-empty, and, when a `#` is present, the part after it is one or more non-empty segments joined by `.`, each satisfying the segment rules of 1.4. When the named identity does not currently resolve — its file not discovered, its file masked (14.20), its bearer's identity undefined (11.2), or no such node — the selection is empty: a spelling that does not resolve records no occurrence. + +The consulted domain's findings accompany the answer (11.2), so an empty, finding-free answer (exit 0) is definitive over the domain: nothing in the consulted files references the identity. Without `--file` the guarantee is absolute — nothing in the workspace references it; under `--file` it is exactly domain-wide — a file outside the admitted set can still hold a resolving occurrence, which the answer neither reports nor denies. + +### 11.4 `xspec view` + +```sh +xspec view [ …] [--file ] [--text] +``` + +Returns, per requested file, everything needed to overlay structure on the raw MDX bytes. The view's domain is the discovered spec sources. Naming `` operands asserts membership: a file outside the discovered set is an unknown file (12.0), and a discovered code source, which has no structural view, is an invalid argument (12.0) — the wrong-kind reference of 14.14's pattern, as a code group's name is where a spec group's is required (11.1) — each exit 2. `--file` is instead a set restriction over the domain, under the glob rules of 7 (as in 11.3): it admits the discovered spec sources it matches, and a glob admitting none — matching no discovered file, or only code sources — admits the empty set, an empty, finding-free answer, exit 0. Combining `` operands with `--file` is a usage error; with neither, the request covers every discovered spec source. The requested files form a set; a multi-file request returns per-file views ordered by byte order of workspace-relative path, in one JSON document. The consulted domain (11.2) is the requested files plus, with `--text`, every further file the requested expansions transitively consult — where the finding that blocks an expansion can lie. An unparseable requested file contributes no view, its parse-failure finding reporting it (11.2). Each parseable requested file's view contains: + +* the root node and the full section tree in document order. The tree is positional — defined by construct nesting alone — and exists for every parseable file, whatever findings the file carries. Each node carries its construct range (1.7); its raw attribute spellings as parsed — inclusion is by form: every attribute the tag spells appears, repeated, unknown, and spread attributes included, their invalidity a located finding (14), never a view omission; and, each per 11.2 (defined, or explicitly unavailable), its node identity, interpreted tags, and coverage attribute, plus — with `--text` — its own and subtree text. A root's tags and coverage attribute are structurally absent (5.5, 11.1): reported as absent, never as unavailable — no finding, no exit-1 consequence; +* for each non-root node, the decomposition of its construct range: the opening tag's range and the closing tag's range — a self-closing section has an opening-tag range only, a root node neither; +* every import declaration, valid or invalid, with its source range, its binding name — structurally absent when the declaration binds none, reported as absent, never as unavailable — and its resolved target file where specifier form and discovery define one, explicitly unavailable otherwise (11.2), the invalidity itself a located finding (14); +* every reference occurrence in the file (5.7), in document order; +* every MDX comment's source range. + +With tags, imports, comments, and embedding occurrences located — an embedding occurrence's span is its full braced container (5.7) — every construct Markdown compilation removes (3) is positioned: on a finding-free file a consumer can classify each byte as annotation or content from the view alone, without re-parsing the MDX. On an imperfect file the classification is joint with the findings: constructs producing no occurrence and no view entry — the invalid constructs of 14.16 get no view entry — are located by their findings' ranges (14), and the two surfaces together still position every removable construct. + +### 11.5 `xspec at` + +```sh +xspec at +``` + +Resolves a byte position in a discovered spec source: the innermost section construct whose range (1.7) contains the offset — the root when no narrower section does — reported with its construct range and, per 11.2, its node identity; and, when the offset lies within a reference occurrence's range, that occurrence and its resolved target (5.7). `` asserts domain membership exactly as a `view` operand does (11.4). Resolution is by range containment and total over the file: every within-file offset resolves — bytes inside imports, comments, and content between sections resolve to the innermost enclosing section construct — and the offset equal to the file's byte length (the caret position at end of file) resolves to the root. A greater offset, like an offset that is not a non-negative integer, is a usage error (12.0). The same resolution is derivable from the view's data alone (11.4): `at` adds convenience, not information, serving consumers that keep no client-side index. The consulted domain (11.2) is the named file; on an unparseable file the resolution is reported explicitly unavailable, the parse-failure finding accompanying it (11.2). + +### 11.6 `xspec inventory` + +```sh +xspec inventory +``` + +Reports the machine-readable shape of the workspace, so an external tool never edits files xspec owns and never misses files xspec reads. The inventory parses no sources, so it answers whatever the sources' validity; configuration errors keep their precedence (14.14). It never refreshes or writes anything, and it reports: + +* **Anchoring.** The workspace root and the configuration file, identified relative to the invocation working directory — pure invocation input, exactly as `--config` resolution is (7, 12.0) — so a consumer can map the workspace-relative paths in every output to real files without re-implementing the upward search. Only when the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives) is the anchoring reported in the platform's absolute form — the sole absolute-path case (12.0), still a pure function of invocation input. +* **Configuration.** The resolved configuration view: the spec and code groups with their glob lists and kinds; Markdown emission state and destinations (7.3); and the coverage profiles and policy rules, each carried with its complete definition, never as a bare name. A group reference inside a profile or rule stays the configured group name, resolving against the group list this same view reports. +* **Sources.** Every discovered source file with its group memberships. +* **Derived-file map.** Per source file: the generated module path (13.1) and, while emission is enabled, the Markdown emit destination (7.3) — determined by configuration and discovery, existing whether or not emission has yet run — plus, from recorded generation state (13.3), the companion paths and any other recorded derived paths, reported as recorded: recorded state can lag configuration until a rebuild and is empty before any generation has run. +* **Graph-data area.** The location under which graph data is kept (13.3), reported unconditionally — the record can lag or be empty, but a consumer must know the area before any build has run. The area's classification is a write reservation, not per-occupant ownership: the area is reserved for xspec's writes — a derived-file write there replaces whatever occupies its path (13.4) — so an external tool must never create, edit, or keep content of its own anywhere under it. Individual paths under the area are classified exactly as the inventory reports them: the durable paths below are durable, and a recorded derived path lying under the area is derived. Every other path under the area is unattributed: it may equally be xspec's graph data — derived, rebuild-recoverable, returning with the next successful build or read-time refresh — or foreign content, recorded nowhere and reproduced by nothing. The inventory neither lists such a path, nor claims it for xspec, nor says which case holds: telling them apart is precisely what it declines to enable, and an external tool must treat every unattributed path as undeletable, because it cannot exclude the foreign case — the one whose deletion is undone by nothing. For the same reason the area is never presented as a deletable or wholesale-regenerable unit: the durable files inside it are neither, and whether any particular unattributed path would return is unknowable from the inventory. +* **Durable files.** The journal path (6.1), with whether anything presently occupies it — an absent journal is an empty journal (6.1), and occupancy is presence alone, whatever kind of filesystem object occupies the path: the inventory reads no journal content. And the review-session files: every directory entry directly under the review-session directory (10.1) whose name is a well-formed session file name, selected by name alone, whatever kind of filesystem object occupies it — a session-named path holding anything but a plain file is a corrupt session (10.1), and corrupt or unparseable sessions are listed, since the inventory reads no session content. An entry there with any other name is not a session and is never listed: it is an unattributed path under the area, governed by the rule above. + +Inventory lists are ordered deterministically: files and paths in byte order of workspace-relative path, groups, profiles, and rules in configuration order, and session files in byte order of file name. + +Recorded state that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise — is condition 23 (14): the record-supplied datum (the recorded derived-file entries) is reported explicitly unavailable, never fabricated and never read as an empty map; the finding accompanies the answer with its stable code; the invocation exits 1 (12.0); and every other provenance's content is emitted in full. + ## 12. Commands ### 12.0 Global conventions * Every command supports `--json`, emitting a single JSON document. Where this specification defines report content, the JSON form MUST contain the same information. -* The report — findings included: a failing `build`'s validation errors and `check` findings are reports — is standard-output content; usage and configuration error messages (exit 2) and all other diagnostic text are standard-error content. With `--json`, the single JSON document is the entire standard output; when an exit-2 error prevents emitting one, standard output is empty. +* The report — findings included: a failing `build`'s validation errors and `check` findings are reports — is standard-output content; usage and configuration error messages (exit 2) and all other diagnostic text are standard-error content. JSON output is in effect exactly when `--json` appears among the invocation's arguments — governing error delivery even when the arguments are themselves the error, an unknown command or flag included — or when the invoked surface is JSON-only, a single JSON document its only output form with or without `--json` (10.7, 11, 12.6). When JSON output is in effect, the single JSON document is the entire standard output, and an invocation that fails with a usage or configuration error (exit 2) emits as its entire standard output a single JSON document reporting the error — carrying the stable code and concerned file or path (14) where the condition defines them. When JSON output is not in effect, an exit-2 error leaves standard output empty. The output form never changes an exit code, the error-precedence rules, or standard-error content. * Every command supports `--config ` (7). * A flag MAY be given at most once per invocation; repeating a flag is a usage error. List-valued flags (`--kinds`) take one comma-separated value (11). * Arguments that name requirement nodes, graph nodes, workspace files, or file globs (``, ``, ``, `--file`) are workspace-relative in the form of 1.5, independent of the working directory. `--config ` and `--test-hold ` are filesystem paths resolved against the working directory. * Argument values are interpreted as UTF-8; an argument value that is not valid UTF-8 is a usage error. * IDs, tags, identities, session names, and paths compare byte-wise and case-sensitively; no Unicode normalization or case folding is applied anywhere (the create-time session-name restriction of 10.1 is the sole exception). -* All output, generated files, and stored data are byte-deterministic for identical input: no wall-clock values, no randomness, no absolute paths, no environment-dependent content. +* All output, generated files, and stored data are byte-deterministic for identical input: no wall-clock values, no randomness, no absolute paths, no environment-dependent content. Invocation-anchored content is the stated exception where a section calls for it — the inventory's anchoring (11.6) and configuration-error concerned paths (14), with 11.6's no-relative-path platform case the sole absolute form — itself a pure function of invocation input, deterministic per invocation. * Where this specification calls for one shortest path and several shortest paths qualify, the reported one is the least by element-wise byte comparison of the paths' node-identity sequences. -* Exit codes partition all outcomes; every defined failure belongs to exactly one class. `0` — success, including informational reports (`ids`, `show`, `impact`, `query`, the `review` read subcommands including `next` with nothing to review, `coverage` without `--check`). `1` — findings: source, workspace, and operation validation failures (`build` on invalid sources, `check` findings, `coverage --check` with uncovered requirements, refused `rename`/`move` (6.4, 6.5), refused review operations (10.7), `review` subcommands naming a corrupt session and `review list` reporting one (14.21)). `2` — usage and configuration errors: unknown commands or flags; missing required flags or arguments; invalid flag values; unknown profiles, sessions, groups, review items, node identities, or files named in arguments; invalid session names; missing or invalid configuration (14.14); a baseline that cannot be read or reconstructed (6.3); a mutating command refused because another is running (13.5). +* Exit codes partition all outcomes; every defined failure belongs to exactly one class. `0` — success, including informational reports (`ids`, `show`, `impact`, `query`, the `review` read subcommands including `next` with nothing to review, `coverage` without `--check`, `version`) and complete, finding-free answers (11.2, 11.6). `1` — findings: source, workspace, and operation validation failures (`build` on invalid sources, `check` findings, `coverage --check` with uncovered requirements, refused `rename`/`move` and their refused previews (6.4–6.6), refused review operations (10.7), `review` subcommands naming a corrupt session and `review list` reporting one (14.21)), and answers carrying findings or explicitly-unavailable data — emitted in full, with exit 1 (11.2, 11.6, 6.6). `2` — usage and configuration errors: unknown commands or flags; missing required flags or arguments; invalid flag values; unknown profiles, sessions, groups, review items, node identities, or files named in arguments — except on `occurrences --to`, where only a malformed identity spelling is a usage error and an unknown or unresolving one selects nothing (11.3); invalid session names; missing or invalid configuration (14.14), which never reaches `version` (12.6); a baseline that cannot be read or reconstructed (6.3); a mutating command refused because another is running (13.5). * The argument existence checks of `rename` and `move` (a nonexistent origin file or old ID, 6.4, 6.5) and baseline resolution (6.3) precede source validation: these usage errors are reported, and the command exits 2, even when the current sources also fail build validation (6.4, 13.3) — as configuration errors precede all source analysis (14.14). An old ID inside an unparseable origin file (14.20) is masked (14): there the validation findings are reported and the command exits 1. ### 12.1 `xspec build` @@ -569,10 +672,16 @@ xspec show Accepts `path#id`, or a bare `path` for a file's root node (1.5). Prints one requirement for human reading: identity, source range (1.7), own and subtree text, hashes, tags, coverage attribute (absent for a root node, 11), and edges by kind. `query node` is the machine-facing equivalent. -### 12.5 `xspec coverage`, `xspec impact`, `xspec review`, `xspec query`, `xspec rename`, `xspec move` +### 12.5 `xspec coverage`, `xspec impact`, `xspec review`, `xspec query`, `xspec occurrences`, `xspec view`, `xspec at`, `xspec inventory`, `xspec rename`, `xspec move` As specified in sections 8, 9, 10, 11, and 6. +### 12.6 `xspec version` + +Reports the product version and the machine-interface version. The surface is JSON-only: a single JSON document is its only output form, with or without `--json` (12.0). Both values are fixed per build. The product version is informational — reported for display and support, with no requirement beyond per-build fixedness. The machine-interface version is `1`, and the surface reports exactly this value. The value names the machine-facing JSON contract this specification defines — the JSON output of the product's commands under the universal-JSON and same-information conventions of 12.0 — so an external tool checks compatibility by comparing the reported value against the value its own interface knowledge targets. + +`xspec version` is workspace-independent: it consults no workspace and no configuration — `--config` is accepted (12.0) and not consulted — answers identically in any working directory, no discoverable workspace, missing configuration, and invalid configuration included, and cannot fail for workspace or configuration reasons: configuration-error precedence (14.14) does not reach it. Usage errors keep exit 2 (12.0). + ## 13. Workspace Files ### 13.1 Generated TypeScript @@ -585,7 +694,7 @@ As specified in sections 8, 9, 10, 11, and 6. ### 13.3 Graph data -xspec maintains graph data under `.xspec/`, containing requirement nodes, code locations, edges by kind, source ranges (1.7), all four hashes, coverage attributes, tags, and the paths of the derived files most recently generated (13.4). Graph data serves `check`, `ids`, `show`, `coverage`, `impact`, `review`, and `query`. Read results never come from stale data: when graph data is missing or does not match the current sources and configuration, `ids`, `show`, `coverage`, `impact`, `review`, and `query` refresh it — writing exactly what `xspec build` would write, except that no TypeScript or Markdown is generated or removed and the recorded derived-file paths are left unchanged — before answering. If the current sources fail `build` validation, these commands report the validation errors and exit 1 without answering and without modifying anything: a failed refresh, like a failed build (12.1), leaves every derived file and all graph data unmodified. `check` never refreshes; it reports staleness instead (14.10). Graph data is byte-deterministic for a given workspace (12.0); its content is otherwise opaque — graph data's observable contract is its location under `.xspec/`, its classification as a derived file (13.4), and the refresh, failure, and staleness behaviors above and in 14.10. +xspec maintains graph data under `.xspec/`, containing requirement nodes, code locations, edges by kind, source ranges (1.7), all four hashes, coverage attributes, tags, and the paths of the derived files most recently generated (13.4). Graph data serves `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at`. Read results never come from stale data: when graph data is missing or does not match the current sources and configuration, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at` refresh it — writing exactly what `xspec build` would write, except that no TypeScript or Markdown is generated or removed and the recorded derived-file paths are left unchanged — before answering. If the current sources fail `build` validation, `ids`, `show`, `coverage`, `impact`, `review`, and `query` report the validation errors and exit 1 without answering, while `occurrences`, `view`, and `at` answer from the current sources per 11.2; in either case nothing is modified: a failed refresh, like a failed build (12.1), leaves every derived file and all graph data unmodified. `check` never refreshes; it reports staleness instead (14.10). `inventory` neither refreshes nor writes (11.6), and a preview writes nothing (6.6). Graph data is byte-deterministic for a given workspace (12.0); its content is otherwise opaque — graph data's observable contract is its location under `.xspec/`, its classification as a derived file (13.4), and the refresh, failure, and staleness behaviors above and in 14.10. ### 13.4 Derived and durable files @@ -600,11 +709,17 @@ Writes never traverse symbolic links. A symbolic link at a derived file's path i ### 13.5 Concurrency and isolation -All state is workspace-local; instances operating on different workspaces MUST NOT interfere with each other. Within one workspace, file writes are atomic in their observable effect: at every moment — concurrent readers and interrupted commands included — a path xspec writes holds either its prior state (the previous content, or absence) or the complete new content, never a partial write. Commands that modify sources or durable files — `rename`, `move`, and the mutating `review` subcommands (`create`, `resolve`, `split`) — are mutually exclusive per workspace: while one runs, another MUST fail promptly with a usage error (12.0) without modifying anything, so concurrency never loses a journal append or a resolution. Exclusivity ends when the holding command's process terminates, normally or abnormally; a terminated holder MUST NOT block later commands. As a deterministic test seam for this exclusion, every mutating command accepts `--test-hold `: immediately after acquiring workspace exclusivity and before modifying anything, the command creates an empty file at the given path — creation MUST fail if anything, a symbolic link included, already exists at that path — then proceeds normally only once that file has been deleted. If the hold file cannot be created, the command fails with a usage error (12.0) without modifying anything. The seam changes no other behavior and grants no access beyond the invoking user's own file permissions. All other commands may run concurrently, with last-write-wins per file; any resulting derived-file inconsistency is resolved by rerunning `xspec build`. A mutating command interrupted before completion can leave sources and durable files inconsistent; `xspec check` reports such states (14). +All state is workspace-local; instances operating on different workspaces MUST NOT interfere with each other. Within one workspace, file writes are atomic in their observable effect: at every moment — concurrent readers and interrupted commands included — a path xspec writes holds either its prior state (the previous content, or absence) or the complete new content, never a partial write. Commands that modify sources or durable files — `rename` and `move`, their `--preview` invocations excepted (6.6), and the mutating `review` subcommands (`create`, `resolve`, `split`) — are mutually exclusive per workspace: while one runs, another MUST fail promptly with a usage error (12.0) without modifying anything, so concurrency never loses a journal append or a resolution. Exclusivity ends when the holding command's process terminates, normally or abnormally; a terminated holder MUST NOT block later commands. As a deterministic test seam for this exclusion, every mutating command accepts `--test-hold `: immediately after acquiring workspace exclusivity and before modifying anything, the command creates an empty file at the given path — creation MUST fail if anything, a symbolic link included, already exists at that path — then proceeds normally only once that file has been deleted. If the hold file cannot be created, the command fails with a usage error (12.0) without modifying anything. The seam changes no other behavior and grants no access beyond the invoking user's own file permissions. All other commands may run concurrently, with last-write-wins per file; any resulting derived-file inconsistency is resolved by rerunning `xspec build`. A mutating command interrupted before completion can leave sources and durable files inconsistent; `xspec check` reports such states (14). ## 14. Validation Errors -`xspec build` and `xspec check` MUST report actionable errors that identify the file, location, and correction. When several error conditions are present, they MUST report each of them, not only the first; a condition goes unreported only where another error makes it undetectable — an unparseable file (14.20) masks the conditions inside itself, and a reference into it reports as unresolved (14.5–14.7) — and a configuration error (14.14) precedes all source analysis. The defined error conditions, each reported by `build` and `check` unless its entry states otherwise: +`xspec build` and `xspec check` MUST report actionable errors that identify the file, location, and correction. When several error conditions are present, they MUST report each of them, not only the first; a condition goes unreported only where another error makes it undetectable — an unparseable file (14.20) masks the conditions inside itself, and a reference into it reports as unresolved (14.5–14.7) — and a configuration error (14.14) precedes all source analysis. + +Every reported condition carries a stable machine-readable code identifying which numbered condition it is: 1 `missing-id`, 2 `invalid-structural-id`, 3 `duplicate-id`, 4 `invalid-segment-or-tag`, 5 `unknown-dependency`, 6 `unknown-text-target`, 7 `unknown-ts-reference`, 8 `invalid-argument`, 9 `cycle`, 10 `stale-output`, 11 `cross-module-text`, 12 `policy-violation`, 13 `journal-error`, 14 `configuration-error`, 15 `invalid-import`, 16 `invalid-construct`, 17 `invalid-prop`, 18 `unsupported-node-usage`, 19 `invalid-source-path`, 20 `unparseable-source`, 21 `corrupt-session`, 22 `symlink-write-path`, 23 `unreadable-record`. Stable codes cover exactly these conditions and the refusal reasons below, and no more: a plain usage error (12.0) describes the invocation the consuming tool itself composed, never workspace content to render inline, and carries no stable code — while still arriving as the JSON error document of 12.0 whenever JSON output is in effect — and review-operation refusals (10.7) likewise carry none. + +Every condition that locates in source carries, for each offending construct, the containing file and a source range (1.7) at the precision the condition allows. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders in place and no representative is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling recording a participating dependency edge, or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call (14.11) — is identity data on the finding, not a further range. A reference spelling that records no occurrence (5.7, 11.2) is located here: for a spelling of the MDX embedding form, its finding's range is the full braced container, opening brace through closing brace — the span its occurrence would occupy (5.7) — keeping the byte classification of 11.4 exact on imperfect files. Conditions without an in-source location — configuration, path-level, journal, and session conditions — carry the file or path they concern. A configuration error's concerned path is reported in the anchoring form of 11.6, identified relative to the invocation working directory: where a configuration file is concerned — the file the upward search found, or the path `--config` names — it is that file; for missing configuration with no `--config` given, it is the directory the failed upward search started from, the invocation working directory. The JSON report form presents code, locations, and concerned path for every finding, all conditions reported together, with the same information as the human report (12.0). + +The defined error conditions — also the findings that accompany answers over a consulted domain (11.2, 11.6, 6.6) — each reported by `build` and `check` unless its entry states otherwise: 1. Missing ID: a non-root section without `id`. 2. Invalid structural ID: a child ID that does not equal the parent ID plus one segment, including IDs that skip levels; the error states the expected form. A top-level section is checked against the empty prefix (exactly one segment). The check needs the parent's ID: for the immediate children of a section lacking `id`, condition 1 masks this condition — their other conditions, and this condition for their own children, report normally. @@ -619,7 +734,7 @@ All state is workspace-local; instances operating on different workspaces MUST N 11. Cross-module text call: a node passed to the `text` export of a spec module other than its own; additionally a TypeScript type error and a runtime throw per 4.4. 12. Policy violation: rule name plus offending edge. Reported by `check` only: policy constrains the workspace graph, not source validity, and `build` regenerates output regardless of policy findings (7.5, 12.1). 13. Journal error: malformed, conflicting, or unreplayable entries, naming the lines; a journal path occupied by anything other than a plain file (13.4). -14. Configuration error: missing or invalid configuration — a configuration file that is not well-formed TypeScript or not in the declarative form of 7; missing required fields, unknown keys (7), or invalid profile, rule, or group shapes; group names referenced by profiles, rules, or selectors that are unknown or not of the kind the reference requires (7.4, 7.5); ambiguous kinds (7.4, 7.5); an empty `edgeKinds`, `targetTags`, rule `kinds`, or selector `tags` list; a capture violation (7.5); a glob or `markdown.outDir` resolving outside the workspace root (7, 7.3); a file matched by both a spec and a code group. Reported by every command when it loads the configuration and discovers sources, as a usage error (12.0), not a finding. +14. Configuration error: missing or invalid configuration — a configuration file that is not well-formed TypeScript or not in the declarative form of 7; missing required fields, unknown keys (7), or invalid profile, rule, or group shapes; group names referenced by profiles, rules, or selectors that are unknown or not of the kind the reference requires (7.4, 7.5); ambiguous kinds (7.4, 7.5); an empty `edgeKinds`, `targetTags`, rule `kinds`, or selector `tags` list; a capture violation (7.5); a glob or `markdown.outDir` resolving outside the workspace root (7, 7.3); a file matched by both a spec and a code group. Reported by every command that loads the configuration — every command but `version` (12.6), which loads none — when it loads the configuration and discovers sources, as a usage error (12.0), not a finding. 15. Invalid import: in an xspec source file, an import that is not a single default binding, does not designate an xspec source file belonging to a configured spec group, or binds the identifier `S`, `Spec`, or `text` (2.1); in a TypeScript file, a `.xspec` import that does not designate such a source, a spec-module binding other than the default and `text` exports, a dynamic `import()` whose static specifier ends in `.xspec`, an export declaration or an `import X = require(…)` declaration whose specifier ends in `.xspec` (4), or an import or export declaration, `import X = require(…)`, or dynamic `import()` whose relative specifier designates a derived-file path other than through a spec module import's `.xspec` specifier (4, 13.4); in either kind of file, an import binding an identifier already bound by another import in the same file, when either import is a spec module import. 16. Invalid construct: a JSX element other than ``/``, an expression container other than a `text(...)` embedding or an MDX comment (2.7), or an export statement in a source file. 17. Invalid prop: an unknown or repeated prop, or a spread attribute, on ``/`` (2.7), an `id`, `coverage`, or `tags` value that is not a quoted-form static string literal, a `d` value that is not a braced expression (2.7), or a `coverage` value other than `required` or `none`. @@ -628,6 +743,9 @@ All state is workspace-local; instances operating on different workspaces MUST N 20. Unparseable source: a spec-group file that is not well-formed MDX, a code-group file that is not well-formed TypeScript under the grammar its file name selects (`.tsx` parses as TSX, any other name as plain TypeScript), or a discovered source file of either kind that is not valid UTF-8 or begins with a byte-order mark (1.6); the error reports the location of the parse failure. 21. Corrupt review session: a session file that is not a plain file (13.4), cannot be parsed, or violates a session invariant (10.1). Reported by `check`, by any `review` subcommand naming the session, and by `review list` (exit 1); not reported by `build`, which does not read sessions. 22. Symbolic link in a write path: a workspace-relative directory component of a path xspec writes is a symbolic link (13.4). A command refuses the write and reports it before modifying anything; `check` reports it without writing. A symbolic link at a derived file's own path is not an error — writing replaces the link (13.4). +23. Unreadable recorded state: recorded generation state (13.3) that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise. Reported by `inventory` (11.6) and by `rename`/`move` previews (6.6), the surfaces that read the record without refreshing it: the record-supplied datum is reported explicitly unavailable, the rest of the answer is emitted in full, and the invocation exits 1. Not reported by `build`, whose rebuild replaces the record (12.1, 13.4); `check` reports the state as staleness (14.10). + +Each distinct reason `rename` and `move` refuse (6.4, 6.5) — exactly what a refused preview (6.6) reports — carries a stable code and, under the location-cardinality rule above, the file, source range, or identity it concerns, so a refusal renders as precisely as a finding; refusals are findings in the exit-code partition (12.0), and the JSON report form above carries them. The reasons and their codes: `refused-invalid-id` — the new ID, or an ID the prefix replacement produces, violates 1.3/1.4, concerning that identity; `refused-identity-unchanged` — the new identity equals the old (6.4; the exact self-move of 6.5), concerning it; `refused-id-collision` — the new ID collides with an existing ID (6.4) or with an ID remaining in the target file after the removal (6.5), locating every colliding bearer; `refused-structural-parent` — structural parent rules would not remain satisfied, concerning the violated identity; `refused-unresolvable-reference` — a rewritten reference would not resolve, locating each such reference spelling; `refused-cycle` — the move would create a spec import cycle or a dependency cycle (6.5), locating the would-be cycle's full path per the cardinality rule; `refused-destination-exists` — the file form's destination file already exists (6.5), concerning that path; `refused-missing-target-parent` — the section form's target parent is missing or lies within the moved subtree (6.5), concerning the target-parent identity; `refused-invalid-destination` — the destination file path would not be a valid discovered spec source (6.5), concerning that path. The invalid-workspace refusal (6.4) reports the workspace's findings themselves, each under its own numbered condition and stable code. ## 15. Example From f5b312e339c191520ec27ea0507d6625002f0b9f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 20:32:16 +0000 Subject: [PATCH 019/226] sdg(phase-4): define invalid-path behavior on UI surfaces; close refresh, import-edit, and view gaps (iter 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: - C1: node identities exist only over valid source paths — every node of a 14.19-invalid file (roots, sections, code units) has an undefined identity under 11.2; such files keep parse-local structure and their condition-19 finding accompanies every answer whose domain includes them; 1.5's #-unambiguity re-grounded on this rule; occurrences --to gains the invalid-path non-resolve case; 12.0 gains a marked byte-form representation rule for non-UTF-8 workspace-relative paths in outputs. - I1: read-time refresh leaves an unreadable record (14.23) unread, unrepaired, unreplaced; refreshing reads consult no record and report no finding for it; the state persists, reported by 11.6/6.6/14.10, until build or rename/move regeneration replaces the record (13.3, 14.23). - I2: import-removal extent pinned (declaration plus line-drop adjunct, as in 3); import addition inserted as a line of its own at a grammar-permitting, deterministic implementation-latitude offset equal to the previewed offset (6.5, 6.6). - I3: view --text consulted domain defined: exactly the files of resolved targets reachable through occurrence-recording embeddings; a non-resolving spelling is the expansion's boundary; a masked file is never consulted by expansion, its finding surfacing only when requested. - I4: argument checks of 11.3-11.5 precede answering — usage errors exit 2 whatever findings the workspace or named files carry (11.2). - I5: raw attribute spelling defined per attribute, in tag order: name as spelled (absent for spread), source range, and source text (name through value; a spread attribute's entire braced construct) (11.4). - I6: verified against pre-IP baseline (a724662): query was already JSON-only ("a single JSON document is its only output form"), so section 11's JSON-only statement alters nothing pre-existing — no change needed. - O2: 11.4 code-source operand rephrased "wrong-kind operand, a usage error" to avoid conflation with stable code invalid-argument. - O3: review export synopsis aligned to [--json] per its JSON-only text. - O4: confirmed intended, no edit — multiple view operands are deliberate refinement latitude (IP: exact surface shapes settled during spec refinement), consistent with the mandated per-file multi-view response in one document; removing the form would lose harmless capability an editor plausibly wants (view several dirty files at once). Rejected: - O1 (modularization): declined. The extraction candidates (10.4-10.6, 7.4-7.5, 8) are load-bearing contract surface: strategy derivation determines export/next payloads, item identity, and invalidation, and coverage/policy evaluation is itself the command contract; PROCESS requires the full interface/contract in SPEC.md and forbids referencing module internals, so extraction would duplicate contracts rather than shrink the document. The SHOULD yields to those MUSTs here. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/SPEC.md | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/specs/SPEC.md b/specs/SPEC.md index 6acc211..aec507f 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -67,7 +67,7 @@ A tag (2.6) follows the same rules as an ID segment, except that tags MAY contai ### 1.5 Node identity -A requirement node is identified by its source file path plus its requirement ID, written `path#id`. The root node of a file is identified by the path alone. File paths in identities, outputs, and stored data are always workspace-relative and always use `/` as the path separator, on every platform. A discovered source file whose path contains `#` is invalid (14.19), so the `#` in an identity is unambiguous. +A requirement node is identified by its source file path plus its requirement ID, written `path#id`. The root node of a file is identified by the path alone. File paths in identities, outputs, and stored data are always workspace-relative and always use `/` as the path separator, on every platform. A discovered source file whose path contains `#` is invalid (14.19), and no node of a file whose path is invalid has a defined identity (11.2) — identities are only ever formed, emitted, or resolved against over valid source paths — so the `#` in an identity is unambiguous. ### 1.6 Own text, subtree text, and own content @@ -314,7 +314,7 @@ xspec move [--preview] xspec move # # [--preview] ``` -The first form relocates an entire source file; IDs are unchanged and every node's identity changes only in its file part. Relocation also rewrites the moved file's own import specifiers, and the paths by which other files import the moved file's generated module, so all references continue to resolve. The second form extracts a section subtree: the section and its descendants are removed from the origin, inserted as the last child of the target parent (or at the end of the file for a top-level `new-id`), and re-identified by prefix replacement of `` with ``. The target file is created if absent, empty before insertion. The second form's text edits are exact: the moved text is the section construct's own characters — from the first character of its opening tag through the last character of its closing tag, or the self-closing tag's own characters for a self-closing section (1.1). At the origin it is deleted in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3). It is inserted immediately before the target parent's closing tag — at the end of the file for a top-level `new-id` — followed by a U+000A line terminator, and preceded by one when the insertion point is not at the start of a line. A self-closing target parent (1.1) is first rewritten to the paired form: its `/` and any whitespace immediately before or after the `/` are deleted, and the closing tag matching the opening tag's name (`` or ``) is appended immediately after the tag's terminating `>`; the insertion rule then applies before that closing tag. Beyond these edits, the identity and reference rewrites of this section, and the finishing regeneration, a move changes no bytes. In both forms, all references across the workspace are rewritten to resolve to the new identities, converting between local and imported forms and adding or removing spec module imports as the rewrite requires — an import is added when a rewritten reference needs a module binding its file lacks, and an existing spec module import is removed exactly when its binding had references and the rewrite leaves it with none (an import whose binding was already unreferenced stays, 2.1) — and the full mapping is appended to the journal. An added import binds fresh identifiers colliding with no binding already in the file (2.1, 4); its identifier choice and placement, like every rewrite, are deterministic — rewritten file content is byte-deterministic for a given operation and workspace state (6.1). A successful move regenerates derived files as rename does (6.4). +The first form relocates an entire source file; IDs are unchanged and every node's identity changes only in its file part. Relocation also rewrites the moved file's own import specifiers, and the paths by which other files import the moved file's generated module, so all references continue to resolve. The second form extracts a section subtree: the section and its descendants are removed from the origin, inserted as the last child of the target parent (or at the end of the file for a top-level `new-id`), and re-identified by prefix replacement of `` with ``. The target file is created if absent, empty before insertion. The second form's text edits are exact: the moved text is the section construct's own characters — from the first character of its opening tag through the last character of its closing tag, or the self-closing tag's own characters for a self-closing section (1.1). At the origin it is deleted in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3). It is inserted immediately before the target parent's closing tag — at the end of the file for a top-level `new-id` — followed by a U+000A line terminator, and preceded by one when the insertion point is not at the start of a line. A self-closing target parent (1.1) is first rewritten to the paired form: its `/` and any whitespace immediately before or after the `/` are deleted, and the closing tag matching the opening tag's name (`` or ``) is appended immediately after the tag's terminating `>`; the insertion rule then applies before that closing tag. Beyond these edits, the identity and reference rewrites of this section, and the finishing regeneration, a move changes no bytes. In both forms, all references across the workspace are rewritten to resolve to the new identities, converting between local and imported forms and adding or removing spec module imports as the rewrite requires — an import is added when a rewritten reference needs a module binding its file lacks, and an existing spec module import is removed exactly when its binding had references and the rewrite leaves it with none (an import whose binding was already unreferenced stays, 2.1) — and the full mapping is appended to the journal. Import edits are exact. An import removal deletes the declaration's own characters in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3): the removal's extent is the declaration plus any such adjunct drop. An added import binds fresh identifiers colliding with no binding already in the file (2.1, 4) and is inserted as a line of its own — the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — at an offset where the file's grammar permits an import declaration; the identifier choice and the insertion offset are implementation latitude, exercised deterministically: rewritten file content is byte-deterministic for a given operation and workspace state (6.1), and the offset is exactly the one the operation's preview reports (6.6). A successful move regenerates derived files as rename does (6.4). Move validation mirrors rename validation, including the valid-workspace precondition (6.4) and the usage-error classification of a nonexistent origin file or ID (12.0); the mirrored checks read in identity terms: the new ID is valid, structural parent rules remain satisfied, all rewritten references resolve, the new identity differs from the old — a cross-file section move keeping its ID is therefore valid, while the exact self-move, `#` equal to `#`, is refused and appends no journal entry — and, in the section form, `` collides with no ID remaining in the target file after the removal. Move additionally MUST refuse: a move that would create an import cycle among spec source files or a dependency cycle; a file-form move whose destination file already exists; a section-form move whose target parent — the target file's section bearing `` minus its final segment, needed whenever `` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal; and a move whose destination file path (including a target file to be created) would not be a valid discovered spec source after the move — a path belonging to no configured spec group (a move never takes a node out of the workspace), belonging to a code group as well (14.14), containing `#`, not valid UTF-8, or lacking the `.mdx` extension (14.19). These refusals keep every successful move's finishing regeneration (6.4) on a valid workspace, so it cannot fail. Each distinct refusal reason carries its stable code and location (14); with `--preview`, the operation is planned and reported but performed on nothing (6.6). @@ -323,7 +323,7 @@ Move validation mirrors rename validation, including the valid-workspace precond `xspec rename … --preview` and `xspec move … --preview` perform the full validation and planning of the operation and report its consequences while modifying nothing: no sources, no journal, no derived files, no graph data. A preview is refused exactly when — reporting what, and exiting as — the real operation would be refused, and succeeds exactly when the real operation would proceed. The equivalence is over workspace state (validation and planning), never over scheduling: the mutual-exclusion refusal of 13.5 applies to the real operation only. A preview invocation is a non-mutating command under 13.5 — it acquires no workspace exclusivity and is safe to run while readers run — and does not take the acquisition-tied test seam: supplying `--test-hold` together with `--preview` is a usage error (12.0). Preview output is byte-deterministic (12.0), supports `--json` per 12.0, and reports: * the complete identity mapping the operation would journal; -* every file the operation would rewrite or relocate, with every edit the operation would make in it, each located by a source range (1.7) in current, pre-operation coordinates and classed as exactly one of: a reference-occurrence rewrite (5.7 — `d` references, `text(...)` references, TypeScript markers); an `id`-attribute rewrite (rename's and the section move's re-identification); an import-specifier rewrite; an import addition; an import removal (6.5); the section move's origin deletion — one range spanning every byte the origin edit removes: the construct's own characters, extended over the leftover whitespace and line terminator of each line the line-drop rule additionally drops (6.5, 3), bytes contiguous with the construct, so the adjunct drop lies inside this class's range rather than forming a class of its own; the section move's target insertion point; the self-closing-target-parent rewrite when one applies (6.5); the file move's relocation of the file itself; or target-file creation — reported, when the section form's target file does not yet exist, as its own class with the insertion point at the start of the new file, the one reported location without pre-operation coordinates. A rewrite's range is the construct it rewrites — a reference occurrence's span (5.7), the `id` attribute's own characters, the import specifier literal's characters, the target parent's self-closing tag; a removal's range spans every byte its edit removes, as the origin deletion's does; an import addition's insertion point, the section move's target insertion point, and the created target file's start are zero-length ranges at the insertion offset; the relocation's range is the entire moved file. An edit is reported without replacement text: its class and the identity mapping state what changes, and the resulting bytes are observable only by running the operation — the preview is a safety report, not an edit script whose external application would bypass the journaled mapping. Reported ranges MAY nest — the section move's re-identification rewrites locate, in the same pre-operation coordinates, inside its origin deletion's range — each edit reported under its own class, containment being geometry, not double-reporting; +* every file the operation would rewrite or relocate, with every edit the operation would make in it, each located by a source range (1.7) in current, pre-operation coordinates and classed as exactly one of: a reference-occurrence rewrite (5.7 — `d` references, `text(...)` references, TypeScript markers); an `id`-attribute rewrite (rename's and the section move's re-identification); an import-specifier rewrite; an import addition; an import removal — import-edit extents and insertion offsets per 6.5; the section move's origin deletion — one range spanning every byte the origin edit removes: the construct's own characters, extended over the leftover whitespace and line terminator of each line the line-drop rule additionally drops (6.5, 3), bytes contiguous with the construct, so the adjunct drop lies inside this class's range rather than forming a class of its own; the section move's target insertion point; the self-closing-target-parent rewrite when one applies (6.5); the file move's relocation of the file itself; or target-file creation — reported, when the section form's target file does not yet exist, as its own class with the insertion point at the start of the new file, the one reported location without pre-operation coordinates. A rewrite's range is the construct it rewrites — a reference occurrence's span (5.7), the `id` attribute's own characters, the import specifier literal's characters, the target parent's self-closing tag; a removal's range spans every byte its edit removes, as the origin deletion's does; an import addition's insertion point, the section move's target insertion point, and the created target file's start are zero-length ranges at the insertion offset; the relocation's range is the entire moved file. An edit is reported without replacement text: its class and the identity mapping state what changes, and the resulting bytes are observable only by running the operation — the preview is a safety report, not an edit script whose external application would bypass the journaled mapping. Reported ranges MAY nest — the section move's re-identification rewrites locate, in the same pre-operation coordinates, inside its origin deletion's range — each edit reported under its own class, containment being geometry, not double-reporting; * the derived-file delta, both directions one datum: the derived paths the operation would newly generate — paths where nothing is currently recorded as generated — and the recorded derived paths (13.3) the operation would leave no longer generated, the pre-move module path after a file move included. The delta is the identity-relevant consequence; the full regeneration set a successful operation rewrites is workspace-constant and reported by the inventory (11.6), so the preview does not repeat it. Both directions consult the recorded derived-file paths — presence at a path cannot tell a generated occupant from a foreign one — and a preview, writing nothing, never refreshes the record. Recorded state that exists but cannot be read as a record is condition 23 (14): the delta is reported explicitly unavailable — never fabricated, never read as an empty record — the finding accompanies the report with its stable code, the invocation exits 1 (12.0), and every other part of the report is emitted in full. The real operation is not refused in that state — a corrupt record fails no build validation, and the finishing regeneration (6.4) replaces corrupt graph data — so the preview is not refused either: the unreadable record lies on the success side of the refusal equivalence. ### 6.7 Manual restructuring @@ -529,7 +529,7 @@ xspec review next [--json] xspec review show xspec review split xspec review resolve --status [--note ] -xspec review export --json +xspec review export [--json] ``` `review create` requires exactly one of `--base`, `--strategy audit`, or `--coverage`; supplying none, more than one, or any other `--strategy` value is a usage error (12.0). `create` records the session's creation parameters in the session file, fully resolved: a baseline session records the commit identity `--base` resolved to at creation, a `coverage` session records the named profile's definition — its 7.4 fields, with each group name replaced by that group's configured glob list and kind — and an audit session records none. Every later generator run (10.4, 10.5) uses the recorded parameters — the recorded commit as the baseline, the recorded globs matched against the currently discovered sources (7) — so renaming or editing refs, profiles, or groups after `create` never changes the recorded parameters the session runs with. Discovery itself still follows the current configuration: a file that no longer belongs to any configured group is out of the session's view, exactly as if deleted. A `review` command that cannot resolve or reconstruct the recorded (or, at `create`, the given) baseline fails per 6.3 as a usage error (12.0), modifying nothing. A `coverage` session contains one `uncovered-requirement` item per uncovered required node of the profile — scope: that node; context: its ancestor chain; origin and `blockedBy` empty. @@ -567,15 +567,15 @@ xspec query reachable --from --to [--kinds ] **Structure is parse-local.** Everything derived from one file's parse — the positional section tree (11.4), every construct's ranges and their decompositions, raw attribute and import spellings, comment ranges, and reference-occurrence positions — remains available while other files are invalid and while the file itself carries findings of either level: resolution-level (unresolved references, cycle participation) and per-file structural (missing, duplicate, or structurally invalid IDs; malformed segments; invalid props; invalid constructs) alike. Only an unparseable file (14.20), or content the masking rules of 14 already hide, loses its structural data; masking is per file, and these surfaces still answer for every other requested file. -**Interpreted data is defined or explicitly unavailable.** A section spells an identity exactly when exactly one `id` attribute occurs on its tag with its value in the quoted static-string form of 2.7; that value, well-formed or not, is its spelled identity. Any other case — no `id` attribute, a repeated one (its spellings agreeing or not), or a value in any other form, braced or valueless included — spells no identity, and contests no other section's: identity uniqueness compares spelled identities only, so a bearer whose spelled identity no other section spells keeps its defined identity whatever invalid-form `id` attributes the file holds beside it. A section's node identity (1.5) is defined exactly when it and each enclosing section spell an identity, each spelled identity in the chain is well-formed (1.4) and satisfies the structural rules (1.3), and no other section of the file spells the same identity as it does. The chain conditions are inherited — a descendant of a section that spells no identity, or whose spelled identity is malformed or structurally invalid, has no defined identity — but uniqueness is not: it constrains the section's own spelled identity alone. Duplicate spellings leave every bearer of the duplicated identity undefined, no winner picked, while a uniquely spelled descendant of duplicate-`id` ancestors keeps its defined identity. A defined identity therefore does not imply defined prefix identities. A section's interpreted tags and coverage attribute are defined exactly when its parsed attributes define them unambiguously: an absent prop defines the defaults — no tags, coverage-required (2.5, 2.6) — while a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined, its raw spelling still reported (11.4). +**Interpreted data is defined or explicitly unavailable.** A node identity (1.5) is formed over the file's path and requires a valid one: in a discovered file whose own path is invalid (14.19), no graph node — the root and every section of a spec source, the whole-file location and every named unit of a code source — has a defined identity, whatever the content spells. Such a file keeps its parse-local structure and positions; its condition-19 finding accompanies every answer whose consulted domain includes it (below); and no identity over an invalid path is ever emitted or resolved against (1.5). A root node's identity is defined exactly when its file's path is valid. A section spells an identity exactly when exactly one `id` attribute occurs on its tag with its value in the quoted static-string form of 2.7; that value, well-formed or not, is its spelled identity. Any other case — no `id` attribute, a repeated one (its spellings agreeing or not), or a value in any other form, braced or valueless included — spells no identity, and contests no other section's: identity uniqueness compares spelled identities only, so a bearer whose spelled identity no other section spells keeps its defined identity whatever invalid-form `id` attributes the file holds beside it. A section's node identity (1.5) is defined exactly when its file's path is valid, it and each enclosing section spell an identity, each spelled identity in the chain is well-formed (1.4) and satisfies the structural rules (1.3), and no other section of the file spells the same identity as it does. The chain conditions are inherited — a descendant of a section that spells no identity, or whose spelled identity is malformed or structurally invalid, has no defined identity — but uniqueness is not: it constrains the section's own spelled identity alone. Duplicate spellings leave every bearer of the duplicated identity undefined, no winner picked, while a uniquely spelled descendant of duplicate-`id` ancestors keeps its defined identity. A defined identity therefore does not imply defined prefix identities. A section's interpreted tags and coverage attribute are defined exactly when its parsed attributes define them unambiguously: an absent prop defines the defaults — no tags, coverage-required (2.5, 2.6) — while a repeated, malformed, or invalid-valued prop leaves the interpreted value undefined, its raw spelling still reported (11.4). -**Resolution.** A reference spelling resolves exactly when it names exactly one target under these rules: a section whose node identity is defined and equals the named identity, or — for a module reference with no segments (2.2) — the root of a discovered, parseable spec source. Resolution turns on the definedness of the referenced identity itself: a reference to the one section spelling `a.b` resolves — and records an occurrence — even while duplicate spellings of `a` leave every bearer of `a` undefined. A spelling that does not resolve to exactly one target — an unknown target; a unique bearer whose identity these rules leave undefined; an ambiguous one, every duplicate bearer undefined — records no edge and no occurrence, and never reports an unavailable target: its position reaches consumers through its finding's range (14). The two surfaces jointly locate every reference spelling in every parseable file; a spelling inside an unparseable file is hidden with the rest of it, pointed to only by that file's parse-failure finding. +**Resolution.** A reference spelling resolves exactly when it names exactly one target under these rules: a section whose node identity is defined and equals the named identity, or — for a module reference with no segments (2.2) — the root, its identity defined (a valid path, above), of a discovered, parseable spec source. Resolution turns on the definedness of the referenced identity itself: a reference to the one section spelling `a.b` resolves — and records an occurrence — even while duplicate spellings of `a` leave every bearer of `a` undefined. A spelling that does not resolve to exactly one target — an unknown target; a unique bearer whose identity these rules leave undefined; an ambiguous one, every duplicate bearer undefined — records no edge and no occurrence, and never reports an unavailable target: its position reaches consumers through its finding's range (14). The two surfaces jointly locate every reference spelling in every parseable file; a spelling inside an unparseable file is hidden with the rest of it, pointed to only by that file's parse-failure finding. **Expanded text.** A node's own (respectively subtree) text (1.6) is defined exactly when every embedding the expansion transitively reaches — each `text(...)` spelling in the node's own contribution (respectively anywhere in its subtree), and recursively each one anywhere in every embedded target's subtree — records an occurrence, and the recursion re-enters no node already being expanded (an embedding cycle). One unresolved spelling or one cycle on the expansion path makes the whole value unavailable: partial expansion is fabrication and never occurs. Where defined, the value is exact on imperfect files too, and the removal classification of 3 is by syntactic form, never by validity or resolution: every import declaration is removed by form — binding shape, specifier validity, and target discovery notwithstanding, so an import whose target file was deleted or renamed perturbs no text value; a section tag is removed with every attribute it spells, unknown, repeated, and spread included; and a construct matching no removal rule's form (the stray elements, expression containers, and exports of 14.16) is content, preserved byte-for-byte and located by its finding. A defined value is thus a pure function of the consulted files' parses and the resolved expansions. -**Unavailability is explicit.** A datum these rules leave undefined — a section's identity, tags, or coverage value; an occurrence's source graph node, identity and range withheld together as one datum (5.7), the enclosing construct's position staying on view through 11.4; an import's resolved target when specifier form or discovery defines none; an own- or subtree-text value — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. +**Unavailability is explicit.** A datum these rules leave undefined — a node's identity (a section's under the spelling rules above; every node's in a file whose path is invalid); a section's tags or coverage value; an occurrence's source graph node, identity and range withheld together as one datum (5.7), the enclosing construct's position staying on view through 11.4; an import's resolved target when specifier form or discovery defines none; an own- or subtree-text value — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. -**Consulted domain, findings, exits.** Every answer of 11.3–11.5 has a consulted domain of files, defined per surface, and the findings (14) of every domain file accompany the answer — a masked file's parse-failure finding included. An invocation whose answer carries any finding or any explicitly-unavailable datum exits 1 with the full answer document still emitted — exit 1 signals imperfection and never withholds the answer; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their precedence (12.0, 14.14). A possibly-incomplete answer is therefore never silent. +**Consulted domain, findings, exits.** Every answer of 11.3–11.5 has a consulted domain of files, defined per surface, and the findings (14) of every domain file accompany the answer — a masked file's parse-failure finding included. An invocation whose answer carries any finding or any explicitly-unavailable datum exits 1 with the full answer document still emitted — exit 1 signals imperfection and never withholds the answer; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their precedence (12.0, 14.14). The argument checks of 11.3–11.5 precede answering, as `rename`'s and `move`'s argument checks precede source validation (12.0): a malformed `--to` spelling or invalid glob pattern (11.3, 11.1), a `` operand outside the domain or of the wrong kind (11.4, 11.5), an ill-formed or out-of-range offset (11.5), and every other usage error of these surfaces exits 2, whatever findings the workspace or the named files carry. A possibly-incomplete answer is therefore never silent. **Never stale, never writing.** These surfaces never answer from stale graph data: on a workspace that passes `build` validation they participate in read-time refresh exactly as the reads of 13.3 do; on one that does not, they answer from the current sources and modify nothing — no graph data, no derived files (13.3). @@ -585,11 +585,11 @@ xspec query reachable --from --to [--kinds ] xspec occurrences [--file ] [--to ] ``` -Enumerates reference occurrences (5.7) in occurrence order, one record per occurrence carrying every datum of 5.7 — the source graph node per 11.2 where its section's identity is undefined. The two filters combine conjunctively. +Enumerates reference occurrences (5.7) in occurrence order, one record per occurrence carrying every datum of 5.7 — the source graph node per 11.2 where its source node's identity is undefined. The two filters combine conjunctively. `--file` admits the discovered source files — spec and code alike — that the glob matches, under the glob rules of 7 (a pattern resolving outside the workspace root is an invalid flag value, as in 11.1). It is a set restriction, not an existence assertion: the enumeration's consulted domain (11.2) is the discovered files it admits; a glob admitting none admits the empty set — an empty, finding-free answer, exit 0 — and no unknown-file usage error exists on this filter. Without `--file`, the consulted domain is the entire discovered set. -`--to` selects the occurrences whose resolved target it names. It accepts any syntactically well-formed requirement-node identity — `path#id`, or a bare `path` for a root (1.5) — whatever the workspace contains: acceptance is syntactic, and only a malformed spelling is a usage error (12.0). A spelling is well-formed exactly when it contains at most one `#`, its path part — the whole spelling, or the part before the `#` — is non-empty, and, when a `#` is present, the part after it is one or more non-empty segments joined by `.`, each satisfying the segment rules of 1.4. When the named identity does not currently resolve — its file not discovered, its file masked (14.20), its bearer's identity undefined (11.2), or no such node — the selection is empty: a spelling that does not resolve records no occurrence. +`--to` selects the occurrences whose resolved target it names. It accepts any syntactically well-formed requirement-node identity — `path#id`, or a bare `path` for a root (1.5) — whatever the workspace contains: acceptance is syntactic, and only a malformed spelling is a usage error (12.0). A spelling is well-formed exactly when it contains at most one `#`, its path part — the whole spelling, or the part before the `#` — is non-empty, and, when a `#` is present, the part after it is one or more non-empty segments joined by `.`, each satisfying the segment rules of 1.4. When the named identity does not currently resolve — its file not discovered, its file masked (14.20), its file's path invalid (14.19), its bearer's identity otherwise undefined (11.2), or no such node — the selection is empty: a spelling that does not resolve records no occurrence. The consulted domain's findings accompany the answer (11.2), so an empty, finding-free answer (exit 0) is definitive over the domain: nothing in the consulted files references the identity. Without `--file` the guarantee is absolute — nothing in the workspace references it; under `--file` it is exactly domain-wide — a file outside the admitted set can still hold a resolving occurrence, which the answer neither reports nor denies. @@ -599,9 +599,9 @@ The consulted domain's findings accompany the answer (11.2), so an empty, findin xspec view [ …] [--file ] [--text] ``` -Returns, per requested file, everything needed to overlay structure on the raw MDX bytes. The view's domain is the discovered spec sources. Naming `` operands asserts membership: a file outside the discovered set is an unknown file (12.0), and a discovered code source, which has no structural view, is an invalid argument (12.0) — the wrong-kind reference of 14.14's pattern, as a code group's name is where a spec group's is required (11.1) — each exit 2. `--file` is instead a set restriction over the domain, under the glob rules of 7 (as in 11.3): it admits the discovered spec sources it matches, and a glob admitting none — matching no discovered file, or only code sources — admits the empty set, an empty, finding-free answer, exit 0. Combining `` operands with `--file` is a usage error; with neither, the request covers every discovered spec source. The requested files form a set; a multi-file request returns per-file views ordered by byte order of workspace-relative path, in one JSON document. The consulted domain (11.2) is the requested files plus, with `--text`, every further file the requested expansions transitively consult — where the finding that blocks an expansion can lie. An unparseable requested file contributes no view, its parse-failure finding reporting it (11.2). Each parseable requested file's view contains: +Returns, per requested file, everything needed to overlay structure on the raw MDX bytes. The view's domain is the discovered spec sources. Naming `` operands asserts membership: a file outside the discovered set is an unknown file (12.0), and a discovered code source, which has no structural view, is a wrong-kind operand, a usage error (12.0) — the wrong-kind reference of 14.14's pattern, as a code group's name is where a spec group's is required (11.1) — each exit 2. `--file` is instead a set restriction over the domain, under the glob rules of 7 (as in 11.3): it admits the discovered spec sources it matches, and a glob admitting none — matching no discovered file, or only code sources — admits the empty set, an empty, finding-free answer, exit 0. Combining `` operands with `--file` is a usage error; with neither, the request covers every discovered spec source. The requested files form a set; a multi-file request returns per-file views ordered by byte order of workspace-relative path, in one JSON document. The consulted domain (11.2) is the requested files plus, with `--text`, every file the requested expansions transitively consult: exactly the files of the resolved targets reachable from the requested files' embeddings through resolved — occurrence-recording (5.7) — embeddings, an embedding cycle's participants included, whether or not any expansion completes. A spelling that records no occurrence is an expansion's boundary: it consults no further file — the finding blocking there is the spelling's own (11.2, 14.5–14.7), lying in a file already consulted — while the finding that blocks a deeper expansion, an unresolved spelling or a cycle participation, can lie in a consulted file the request never named. A masked file (14.20) is never consulted by an expansion — no spelling resolves into it (11.2) — so its parse-failure finding accompanies the answer only when it is itself a requested file. An unparseable requested file contributes no view, its parse-failure finding reporting it (11.2). A requested file whose path is invalid (14.19) keeps its view — structure is parse-local (11.2) — every node identity in it explicitly unavailable, its condition-19 finding accompanying. Each parseable requested file's view contains: -* the root node and the full section tree in document order. The tree is positional — defined by construct nesting alone — and exists for every parseable file, whatever findings the file carries. Each node carries its construct range (1.7); its raw attribute spellings as parsed — inclusion is by form: every attribute the tag spells appears, repeated, unknown, and spread attributes included, their invalidity a located finding (14), never a view omission; and, each per 11.2 (defined, or explicitly unavailable), its node identity, interpreted tags, and coverage attribute, plus — with `--text` — its own and subtree text. A root's tags and coverage attribute are structurally absent (5.5, 11.1): reported as absent, never as unavailable — no finding, no exit-1 consequence; +* the root node and the full section tree in document order. The tree is positional — defined by construct nesting alone — and exists for every parseable file, whatever findings the file carries. Each node carries its construct range (1.7); its raw attribute spellings as parsed, one entry per attribute the tag spells, in tag order — each entry the attribute's name as spelled (structurally absent for a spread attribute), its source range (1.7), and its source text: the attribute's own characters, for a named attribute its name through the last character of its value or the bare name where it spells no value, for a spread attribute (2.7) its entire braced construct — inclusion is by form: every attribute the tag spells appears, repeated, unknown, and spread attributes included, their invalidity a located finding (14), never a view omission; and, each per 11.2 (defined, or explicitly unavailable), its node identity, interpreted tags, and coverage attribute, plus — with `--text` — its own and subtree text. A root's tags and coverage attribute are structurally absent (5.5, 11.1): reported as absent, never as unavailable — no finding, no exit-1 consequence; * for each non-root node, the decomposition of its construct range: the opening tag's range and the closing tag's range — a self-closing section has an opening-tag range only, a root node neither; * every import declaration, valid or invalid, with its source range, its binding name — structurally absent when the declaration binds none, reported as absent, never as unavailable — and its resolved target file where specifier form and discovery define one, explicitly unavailable otherwise (11.2), the invalidity itself a located finding (14); * every reference occurrence in the file (5.7), in document order; @@ -648,6 +648,7 @@ Recorded state that exists but cannot be read as a record — corrupt graph data * Argument values are interpreted as UTF-8; an argument value that is not valid UTF-8 is a usage error. * IDs, tags, identities, session names, and paths compare byte-wise and case-sensitively; no Unicode normalization or case folding is applied anywhere (the create-time session-name restriction of 10.1 is the sole exception). * All output, generated files, and stored data are byte-deterministic for identical input: no wall-clock values, no randomness, no absolute paths, no environment-dependent content. Invocation-anchored content is the stated exception where a section calls for it — the inventory's anchoring (11.6) and configuration-error concerned paths (14), with 11.6's no-relative-path platform case the sole absolute form — itself a pure function of invocation input, deterministic per invocation. +* A workspace-relative path that is not valid UTF-8 (14.19) has no plain string form. Wherever an output carries one — a discovered source in the inventory (11.6), an occurrence's referencing file (11.3), a per-file view's file (11.4), a finding's location file or concerned path (14) — it is presented in an explicitly marked byte form that carries the path's exact bytes and is distinguishable from every plain path string, deterministically; a valid-UTF-8 path is never presented in the marked form. No identity carries such a path — no node of such a file has a defined identity (11.2) — and no argument value names one (argument values are UTF-8, above). * Where this specification calls for one shortest path and several shortest paths qualify, the reported one is the least by element-wise byte comparison of the paths' node-identity sequences. * Exit codes partition all outcomes; every defined failure belongs to exactly one class. `0` — success, including informational reports (`ids`, `show`, `impact`, `query`, the `review` read subcommands including `next` with nothing to review, `coverage` without `--check`, `version`) and complete, finding-free answers (11.2, 11.6). `1` — findings: source, workspace, and operation validation failures (`build` on invalid sources, `check` findings, `coverage --check` with uncovered requirements, refused `rename`/`move` and their refused previews (6.4–6.6), refused review operations (10.7), `review` subcommands naming a corrupt session and `review list` reporting one (14.21)), and answers carrying findings or explicitly-unavailable data — emitted in full, with exit 1 (11.2, 11.6, 6.6). `2` — usage and configuration errors: unknown commands or flags; missing required flags or arguments; invalid flag values; unknown profiles, sessions, groups, review items, node identities, or files named in arguments — except on `occurrences --to`, where only a malformed identity spelling is a usage error and an unknown or unresolving one selects nothing (11.3); invalid session names; missing or invalid configuration (14.14), which never reaches `version` (12.6); a baseline that cannot be read or reconstructed (6.3); a mutating command refused because another is running (13.5). * The argument existence checks of `rename` and `move` (a nonexistent origin file or old ID, 6.4, 6.5) and baseline resolution (6.3) precede source validation: these usage errors are reported, and the command exits 2, even when the current sources also fail build validation (6.4, 13.3) — as configuration errors precede all source analysis (14.14). An old ID inside an unparseable origin file (14.20) is masked (14): there the validation findings are reported and the command exits 1. @@ -694,7 +695,7 @@ Reports the product version and the machine-interface version. The surface is JS ### 13.3 Graph data -xspec maintains graph data under `.xspec/`, containing requirement nodes, code locations, edges by kind, source ranges (1.7), all four hashes, coverage attributes, tags, and the paths of the derived files most recently generated (13.4). Graph data serves `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at`. Read results never come from stale data: when graph data is missing or does not match the current sources and configuration, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at` refresh it — writing exactly what `xspec build` would write, except that no TypeScript or Markdown is generated or removed and the recorded derived-file paths are left unchanged — before answering. If the current sources fail `build` validation, `ids`, `show`, `coverage`, `impact`, `review`, and `query` report the validation errors and exit 1 without answering, while `occurrences`, `view`, and `at` answer from the current sources per 11.2; in either case nothing is modified: a failed refresh, like a failed build (12.1), leaves every derived file and all graph data unmodified. `check` never refreshes; it reports staleness instead (14.10). `inventory` neither refreshes nor writes (11.6), and a preview writes nothing (6.6). Graph data is byte-deterministic for a given workspace (12.0); its content is otherwise opaque — graph data's observable contract is its location under `.xspec/`, its classification as a derived file (13.4), and the refresh, failure, and staleness behaviors above and in 14.10. +xspec maintains graph data under `.xspec/`, containing requirement nodes, code locations, edges by kind, source ranges (1.7), all four hashes, coverage attributes, tags, and the paths of the derived files most recently generated (13.4). Graph data serves `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at`. Read results never come from stale data: when graph data is missing or does not match the current sources and configuration, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at` refresh it — writing exactly what `xspec build` would write, except that no TypeScript or Markdown is generated or removed and the recorded derived-file paths are left unchanged — before answering. The record is left unchanged in every state: recorded state that exists but cannot be read as a record (14.23) is neither read, repaired, nor replaced by a refresh — these reads never consult the record and report no finding for it — so the state persists, met by the surfaces that consult the record (11.6, 6.6) and reported as staleness by `check` (14.10), until a successful `build` (12.1) or the finishing regeneration of `rename`/`move` (6.4, 6.5) replaces the record. If the current sources fail `build` validation, `ids`, `show`, `coverage`, `impact`, `review`, and `query` report the validation errors and exit 1 without answering, while `occurrences`, `view`, and `at` answer from the current sources per 11.2; in either case nothing is modified: a failed refresh, like a failed build (12.1), leaves every derived file and all graph data unmodified. `check` never refreshes; it reports staleness instead (14.10). `inventory` neither refreshes nor writes (11.6), and a preview writes nothing (6.6). Graph data is byte-deterministic for a given workspace (12.0); its content is otherwise opaque — graph data's observable contract is its location under `.xspec/`, its classification as a derived file (13.4), and the refresh, failure, and staleness behaviors above and in 14.10. ### 13.4 Derived and durable files @@ -743,7 +744,7 @@ The defined error conditions — also the findings that accompany answers over a 20. Unparseable source: a spec-group file that is not well-formed MDX, a code-group file that is not well-formed TypeScript under the grammar its file name selects (`.tsx` parses as TSX, any other name as plain TypeScript), or a discovered source file of either kind that is not valid UTF-8 or begins with a byte-order mark (1.6); the error reports the location of the parse failure. 21. Corrupt review session: a session file that is not a plain file (13.4), cannot be parsed, or violates a session invariant (10.1). Reported by `check`, by any `review` subcommand naming the session, and by `review list` (exit 1); not reported by `build`, which does not read sessions. 22. Symbolic link in a write path: a workspace-relative directory component of a path xspec writes is a symbolic link (13.4). A command refuses the write and reports it before modifying anything; `check` reports it without writing. A symbolic link at a derived file's own path is not an error — writing replaces the link (13.4). -23. Unreadable recorded state: recorded generation state (13.3) that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise. Reported by `inventory` (11.6) and by `rename`/`move` previews (6.6), the surfaces that read the record without refreshing it: the record-supplied datum is reported explicitly unavailable, the rest of the answer is emitted in full, and the invocation exits 1. Not reported by `build`, whose rebuild replaces the record (12.1, 13.4); `check` reports the state as staleness (14.10). +23. Unreadable recorded state: recorded generation state (13.3) that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise. Reported by `inventory` (11.6) and by `rename`/`move` previews (6.6), the surfaces that read the record without refreshing it: the record-supplied datum is reported explicitly unavailable, the rest of the answer is emitted in full, and the invocation exits 1. Not reported by `build`, whose rebuild replaces the record (12.1, 13.4), nor by the refreshing reads of 13.3, which leave the record — unreadable state included — unchanged without consulting it (13.3); `check` reports the state as staleness (14.10). Each distinct reason `rename` and `move` refuse (6.4, 6.5) — exactly what a refused preview (6.6) reports — carries a stable code and, under the location-cardinality rule above, the file, source range, or identity it concerns, so a refusal renders as precisely as a finding; refusals are findings in the exit-code partition (12.0), and the JSON report form above carries them. The reasons and their codes: `refused-invalid-id` — the new ID, or an ID the prefix replacement produces, violates 1.3/1.4, concerning that identity; `refused-identity-unchanged` — the new identity equals the old (6.4; the exact self-move of 6.5), concerning it; `refused-id-collision` — the new ID collides with an existing ID (6.4) or with an ID remaining in the target file after the removal (6.5), locating every colliding bearer; `refused-structural-parent` — structural parent rules would not remain satisfied, concerning the violated identity; `refused-unresolvable-reference` — a rewritten reference would not resolve, locating each such reference spelling; `refused-cycle` — the move would create a spec import cycle or a dependency cycle (6.5), locating the would-be cycle's full path per the cardinality rule; `refused-destination-exists` — the file form's destination file already exists (6.5), concerning that path; `refused-missing-target-parent` — the section form's target parent is missing or lies within the moved subtree (6.5), concerning the target-parent identity; `refused-invalid-destination` — the destination file path would not be a valid discovered spec source (6.5), concerning that path. The invalid-workspace refusal (6.4) reports the workspace's findings themselves, each under its own numbered condition and stable code. From f97917874f79c06e435e948610003e9ad2948450 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 21:03:13 +0000 Subject: [PATCH 020/226] sdg(phase-4): define machine-facing JSON forms (12.7); pin finding concerns and file-operand parsing (iter 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: C1 (new 12.7 — value forms for ranges, paths and the marked byte form, the value/absent/unavailable three-state, finding and error documents, and document forms for occurrences/view/at/inventory/ previews/version, with pinned orderings; 11.6 derived-map bullet aligned with the record-supplied datum; pointers from 6.6, 11, 12.0, 12.6, 14). I1 (condition 22 concerns the offending symlink component, one finding per component; condition 23 concerns the graph-data area; record conditions added to 14's concerned-path rule). I2 (12.0: # splits only identity-form arguments, including # operands; bare operands and --file globs are whole paths with no delimiter role for #). O1 (10.1 "directory entry" replaces "file"). O2 (5.7 occurrence existence anchored to target resolution). O3 (13.4 orphan rule extended to a missing or unreadable record). O4 (12.0 intra-exit-2 precedence: syntax-only errors before configuration loading, configuration errors before workspace-consulting argument checks). O5 (finding order and preview-edit order pinned in 12.7). Partially applied: O6 — removed the one redundant clause in 11.6's unattributed-path rule ("the one whose deletion is undone by nothing"); the remaining statements carry distinct load (the foreign case's definition, the inventory's stance, the consumer rule), so further compression risks dropping content. Rejected: O7 — no modularization: nearly all of sections 8-10 is consumer-facing interface contract that PROCESS.md requires to remain in SPEC.md itself, which the review itself concedes makes single-file defensible; extracting mechanism prose mid-IP would churn cross- references for no requirement. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/SPEC.md | 54 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/specs/SPEC.md b/specs/SPEC.md index aec507f..441ab55 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -277,7 +277,7 @@ Categories are independent flags; a node MAY carry several. The originating node ### 5.7 Reference occurrences -A reference occurrence is one textual spelling that records a dependency-kind edge (5.2): one `d` reference — each entry of a `d` array separately, never the array or the prop (2.2) — one MDX `{text(...)}` embedding (2.3), one TypeScript `text(...)` call (4.3), or one TypeScript dependency marker (4.5). Edges are sets; occurrences are the positions behind them: duplicate references that collapse to a single edge each remain distinct occurrences. +A reference occurrence is one textual spelling that records a dependency-kind edge (5.2): one `d` reference — each entry of a `d` array separately, never the array or the prop (2.2) — one MDX `{text(...)}` embedding (2.3), one TypeScript `text(...)` call (4.3), or one TypeScript dependency marker (4.5). Edges are sets; occurrences are the positions behind them: duplicate references that collapse to a single edge each remain distinct occurrences. Occurrence existence turns on target resolution (11.2): a spelling that resolves records an occurrence even where 11.2 leaves its source graph node's identity undefined, the source datum then reported explicitly unavailable. An occurrence carries: the referencing file; its own source range (1.7); its edge kind; its source graph node — one datum: the node's identity together with that node's own source range (1.7); and its resolved target's identity. Occurrence spans are exact per kind: a `d` reference occurrence spans that one reference's own expression; an MDX embedding occurrence spans the entire `{text(...)}` expression container, opening brace through closing brace — the whole construct Markdown compilation replaces (3); a TypeScript `text(...)` occurrence spans the entire call expression, callee through closing parenthesis, argument included; a marker occurrence spans the bare reference chain alone, exclusive of any statement terminator. @@ -320,7 +320,7 @@ Move validation mirrors rename validation, including the valid-workspace precond ### 6.6 Previews -`xspec rename … --preview` and `xspec move … --preview` perform the full validation and planning of the operation and report its consequences while modifying nothing: no sources, no journal, no derived files, no graph data. A preview is refused exactly when — reporting what, and exiting as — the real operation would be refused, and succeeds exactly when the real operation would proceed. The equivalence is over workspace state (validation and planning), never over scheduling: the mutual-exclusion refusal of 13.5 applies to the real operation only. A preview invocation is a non-mutating command under 13.5 — it acquires no workspace exclusivity and is safe to run while readers run — and does not take the acquisition-tied test seam: supplying `--test-hold` together with `--preview` is a usage error (12.0). Preview output is byte-deterministic (12.0), supports `--json` per 12.0, and reports: +`xspec rename … --preview` and `xspec move … --preview` perform the full validation and planning of the operation and report its consequences while modifying nothing: no sources, no journal, no derived files, no graph data. A preview is refused exactly when — reporting what, and exiting as — the real operation would be refused, and succeeds exactly when the real operation would proceed. The equivalence is over workspace state (validation and planning), never over scheduling: the mutual-exclusion refusal of 13.5 applies to the real operation only. A preview invocation is a non-mutating command under 13.5 — it acquires no workspace exclusivity and is safe to run while readers run — and does not take the acquisition-tied test seam: supplying `--test-hold` together with `--preview` is a usage error (12.0). Preview output is byte-deterministic (12.0), supports `--json` per 12.0 — the preview document form of 12.7 — and reports: * the complete identity mapping the operation would journal; * every file the operation would rewrite or relocate, with every edit the operation would make in it, each located by a source range (1.7) in current, pre-operation coordinates and classed as exactly one of: a reference-occurrence rewrite (5.7 — `d` references, `text(...)` references, TypeScript markers); an `id`-attribute rewrite (rename's and the section move's re-identification); an import-specifier rewrite; an import addition; an import removal — import-edit extents and insertion offsets per 6.5; the section move's origin deletion — one range spanning every byte the origin edit removes: the construct's own characters, extended over the leftover whitespace and line terminator of each line the line-drop rule additionally drops (6.5, 3), bytes contiguous with the construct, so the adjunct drop lies inside this class's range rather than forming a class of its own; the section move's target insertion point; the self-closing-target-parent rewrite when one applies (6.5); the file move's relocation of the file itself; or target-file creation — reported, when the section form's target file does not yet exist, as its own class with the insertion point at the start of the new file, the one reported location without pre-operation coordinates. A rewrite's range is the construct it rewrites — a reference occurrence's span (5.7), the `id` attribute's own characters, the import specifier literal's characters, the target parent's self-closing tag; a removal's range spans every byte its edit removes, as the origin deletion's does; an import addition's insertion point, the section move's target insertion point, and the created target file's start are zero-length ranges at the insertion offset; the relocation's range is the entire moved file. An edit is reported without replacement text: its class and the identity mapping state what changes, and the resulting bytes are observable only by running the operation — the preview is a safety report, not an edit script whose external application would bypass the journaled mapping. Reported ranges MAY nest — the section move's re-identification rewrites locate, in the same pre-operation coordinates, inside its origin deletion's range — each edit reported under its own class, containment being geometry, not double-reporting; @@ -459,7 +459,7 @@ Review turns graph results into a staged checklist. xspec separates the review m ### 10.1 Sessions -A review session is stored at `.xspec/reviews/.json` as a plain, deterministic file. A session name MUST consist of one or more characters from `A–Z`, `a–z`, `0–9`, `.`, `_`, and `-`, and MUST NOT begin with `.`; any other name is a usage error (12.0). Session names are case-sensitive, but so that session files stay unambiguous on case-insensitive filesystems, a name that matches an existing session's name ignoring ASCII case is treated at `review create` as the name of an existing session and refused (10.7); every other subcommand matches names exactly. A session is a durable task ledger for a specific graph state (13.4), not a source of requirement identity. Only a file directly under `.xspec/reviews/` named `.json` with a valid session name is a session; any other file there is not a session and is ignored by every command, `check` included. A session file that exists but is not a plain file (13.4), cannot be parsed, or violates a session invariant — the fields of 10.2 present and well-formed, statuses drawn from 10.3, item `id`s unique within the session, `blockedBy` naming only item `id`s present in the session and containing no cycle (no item transitively blocks itself; every built-in strategy and `split` produce only acyclic blocking, so a cycle can only enter by external modification), at most one item per kind and scope node (10.5), and the recorded creation parameters and decompositions (10.7) well-formed — is corrupt (14.21): every `review` subcommand naming that session reports the corruption and exits 1, modifying nothing, and `list` reports it as corrupt (10.7). +A review session is stored at `.xspec/reviews/.json` as a plain, deterministic file. A session name MUST consist of one or more characters from `A–Z`, `a–z`, `0–9`, `.`, `_`, and `-`, and MUST NOT begin with `.`; any other name is a usage error (12.0). Session names are case-sensitive, but so that session files stay unambiguous on case-insensitive filesystems, a name that matches an existing session's name ignoring ASCII case is treated at `review create` as the name of an existing session and refused (10.7); every other subcommand matches names exactly. A session is a durable task ledger for a specific graph state (13.4), not a source of requirement identity. Only a directory entry directly under `.xspec/reviews/` named `.json` with a valid session name is a session; any other entry there is not a session and is ignored by every command, `check` included. A session file that exists but is not a plain file (13.4), cannot be parsed, or violates a session invariant — the fields of 10.2 present and well-formed, statuses drawn from 10.3, item `id`s unique within the session, `blockedBy` naming only item `id`s present in the session and containing no cycle (no item transitively blocks itself; every built-in strategy and `split` produce only acyclic blocking, so a cycle can only enter by external modification), at most one item per kind and scope node (10.5), and the recorded creation parameters and decompositions (10.7) well-formed — is corrupt (14.21): every `review` subcommand naming that session reports the corruption and exits 1, modifying nothing, and `list` reports it as corrupt (10.7). ### 10.2 Items @@ -544,7 +544,7 @@ xspec review export [--json] ## 11. Query Surfaces -Five commands give scripts, agents, and external tools machine access to the workspace: `query` (11.1) answers set-level graph questions; `occurrences` (11.3) enumerates reference occurrences; `view` (11.4) returns whole-document structural views; `at` (11.5) resolves byte positions; `inventory` (11.6) reports the workspace's shape. Each is JSON-only: a single JSON document is its only output form, with or without `--json` (12.0). `occurrences`, `view`, and `at` answer per file under the availability contract of 11.2; `query` reads only valid workspaces (13.3); `inventory` parses no sources and answers whatever their validity (11.6). +Five commands give scripts, agents, and external tools machine access to the workspace: `query` (11.1) answers set-level graph questions; `occurrences` (11.3) enumerates reference occurrences; `view` (11.4) returns whole-document structural views; `at` (11.5) resolves byte positions; `inventory` (11.6) reports the workspace's shape. Each is JSON-only: a single JSON document, in the document form of 12.7, is its only output form, with or without `--json` (12.0). `occurrences`, `view`, and `at` answer per file under the availability contract of 11.2; `query` reads only valid workspaces (13.3); `inventory` parses no sources and answers whatever their validity (11.6). ### 11.1 `xspec query` @@ -628,30 +628,30 @@ Reports the machine-readable shape of the workspace, so an external tool never e * **Anchoring.** The workspace root and the configuration file, identified relative to the invocation working directory — pure invocation input, exactly as `--config` resolution is (7, 12.0) — so a consumer can map the workspace-relative paths in every output to real files without re-implementing the upward search. Only when the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives) is the anchoring reported in the platform's absolute form — the sole absolute-path case (12.0), still a pure function of invocation input. * **Configuration.** The resolved configuration view: the spec and code groups with their glob lists and kinds; Markdown emission state and destinations (7.3); and the coverage profiles and policy rules, each carried with its complete definition, never as a bare name. A group reference inside a profile or rule stays the configured group name, resolving against the group list this same view reports. * **Sources.** Every discovered source file with its group memberships. -* **Derived-file map.** Per source file: the generated module path (13.1) and, while emission is enabled, the Markdown emit destination (7.3) — determined by configuration and discovery, existing whether or not emission has yet run — plus, from recorded generation state (13.3), the companion paths and any other recorded derived paths, reported as recorded: recorded state can lag configuration until a rebuild and is empty before any generation has run. -* **Graph-data area.** The location under which graph data is kept (13.3), reported unconditionally — the record can lag or be empty, but a consumer must know the area before any build has run. The area's classification is a write reservation, not per-occupant ownership: the area is reserved for xspec's writes — a derived-file write there replaces whatever occupies its path (13.4) — so an external tool must never create, edit, or keep content of its own anywhere under it. Individual paths under the area are classified exactly as the inventory reports them: the durable paths below are durable, and a recorded derived path lying under the area is derived. Every other path under the area is unattributed: it may equally be xspec's graph data — derived, rebuild-recoverable, returning with the next successful build or read-time refresh — or foreign content, recorded nowhere and reproduced by nothing. The inventory neither lists such a path, nor claims it for xspec, nor says which case holds: telling them apart is precisely what it declines to enable, and an external tool must treat every unattributed path as undeletable, because it cannot exclude the foreign case — the one whose deletion is undone by nothing. For the same reason the area is never presented as a deletable or wholesale-regenerable unit: the durable files inside it are neither, and whether any particular unattributed path would return is unknowable from the inventory. +* **Derived-file map.** Per discovered spec source: the generated module path (13.1) and, while emission is enabled, the Markdown emit destination (7.3) — determined by configuration and discovery, existing whether or not emission has yet run. Beside it, one record-supplied datum: the recorded derived-file paths (13.3) — the paths as last generated, companions included — reported as recorded: recorded state can lag configuration until a rebuild and is empty before any generation has run. +* **Graph-data area.** The location under which graph data is kept (13.3), reported unconditionally — the record can lag or be empty, but a consumer must know the area before any build has run. The area's classification is a write reservation, not per-occupant ownership: the area is reserved for xspec's writes — a derived-file write there replaces whatever occupies its path (13.4) — so an external tool must never create, edit, or keep content of its own anywhere under it. Individual paths under the area are classified exactly as the inventory reports them: the durable paths below are durable, and a recorded derived path lying under the area is derived. Every other path under the area is unattributed: it may equally be xspec's graph data — derived, rebuild-recoverable, returning with the next successful build or read-time refresh — or foreign content, recorded nowhere and reproduced by nothing. The inventory neither lists such a path, nor claims it for xspec, nor says which case holds: telling them apart is precisely what it declines to enable, and an external tool must treat every unattributed path as undeletable, because it cannot exclude the foreign case. For the same reason the area is never presented as a deletable or wholesale-regenerable unit: the durable files inside it are neither, and whether any particular unattributed path would return is unknowable from the inventory. * **Durable files.** The journal path (6.1), with whether anything presently occupies it — an absent journal is an empty journal (6.1), and occupancy is presence alone, whatever kind of filesystem object occupies the path: the inventory reads no journal content. And the review-session files: every directory entry directly under the review-session directory (10.1) whose name is a well-formed session file name, selected by name alone, whatever kind of filesystem object occupies it — a session-named path holding anything but a plain file is a corrupt session (10.1), and corrupt or unparseable sessions are listed, since the inventory reads no session content. An entry there with any other name is not a session and is never listed: it is an unattributed path under the area, governed by the rule above. Inventory lists are ordered deterministically: files and paths in byte order of workspace-relative path, groups, profiles, and rules in configuration order, and session files in byte order of file name. -Recorded state that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise — is condition 23 (14): the record-supplied datum (the recorded derived-file entries) is reported explicitly unavailable, never fabricated and never read as an empty map; the finding accompanies the answer with its stable code; the invocation exits 1 (12.0); and every other provenance's content is emitted in full. +Recorded state that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise — is condition 23 (14): the record-supplied datum (the recorded derived-file paths) is reported explicitly unavailable, never fabricated and never read as an empty map; the finding accompanies the answer with its stable code; the invocation exits 1 (12.0); and every other provenance's content is emitted in full. ## 12. Commands ### 12.0 Global conventions * Every command supports `--json`, emitting a single JSON document. Where this specification defines report content, the JSON form MUST contain the same information. -* The report — findings included: a failing `build`'s validation errors and `check` findings are reports — is standard-output content; usage and configuration error messages (exit 2) and all other diagnostic text are standard-error content. JSON output is in effect exactly when `--json` appears among the invocation's arguments — governing error delivery even when the arguments are themselves the error, an unknown command or flag included — or when the invoked surface is JSON-only, a single JSON document its only output form with or without `--json` (10.7, 11, 12.6). When JSON output is in effect, the single JSON document is the entire standard output, and an invocation that fails with a usage or configuration error (exit 2) emits as its entire standard output a single JSON document reporting the error — carrying the stable code and concerned file or path (14) where the condition defines them. When JSON output is not in effect, an exit-2 error leaves standard output empty. The output form never changes an exit code, the error-precedence rules, or standard-error content. +* The report — findings included: a failing `build`'s validation errors and `check` findings are reports — is standard-output content; usage and configuration error messages (exit 2) and all other diagnostic text are standard-error content. JSON output is in effect exactly when `--json` appears among the invocation's arguments — governing error delivery even when the arguments are themselves the error, an unknown command or flag included — or when the invoked surface is JSON-only, a single JSON document its only output form with or without `--json` (10.7, 11, 12.6). When JSON output is in effect, the single JSON document is the entire standard output, and an invocation that fails with a usage or configuration error (exit 2) emits as its entire standard output a single JSON document reporting the error — the error document of 12.7, carrying the stable code and concerned file or path (14) where the condition defines them. When JSON output is not in effect, an exit-2 error leaves standard output empty. The output form never changes an exit code, the error-precedence rules, or standard-error content. * Every command supports `--config ` (7). * A flag MAY be given at most once per invocation; repeating a flag is a usage error. List-valued flags (`--kinds`) take one comma-separated value (11). -* Arguments that name requirement nodes, graph nodes, workspace files, or file globs (``, ``, ``, `--file`) are workspace-relative in the form of 1.5, independent of the working directory. `--config ` and `--test-hold ` are filesystem paths resolved against the working directory. +* Arguments that name requirement nodes, graph nodes, workspace files, or file globs (``, ``, ``, `--file`) are workspace-relative, independent of the working directory. `` and `` values are identities in the form of 1.5, their `#` splitting path from id or unit; the split applies equally to an operand spelled `#` (6.5). A bare `` operand and a `--file` glob are a whole path or pattern: `#` has no delimiter role in them, so a `#`-containing spelling names the discovered file of that invalid path (14.19, 11.4), never a `path#id` pair. `--config ` and `--test-hold ` are filesystem paths resolved against the working directory. * Argument values are interpreted as UTF-8; an argument value that is not valid UTF-8 is a usage error. * IDs, tags, identities, session names, and paths compare byte-wise and case-sensitively; no Unicode normalization or case folding is applied anywhere (the create-time session-name restriction of 10.1 is the sole exception). * All output, generated files, and stored data are byte-deterministic for identical input: no wall-clock values, no randomness, no absolute paths, no environment-dependent content. Invocation-anchored content is the stated exception where a section calls for it — the inventory's anchoring (11.6) and configuration-error concerned paths (14), with 11.6's no-relative-path platform case the sole absolute form — itself a pure function of invocation input, deterministic per invocation. -* A workspace-relative path that is not valid UTF-8 (14.19) has no plain string form. Wherever an output carries one — a discovered source in the inventory (11.6), an occurrence's referencing file (11.3), a per-file view's file (11.4), a finding's location file or concerned path (14) — it is presented in an explicitly marked byte form that carries the path's exact bytes and is distinguishable from every plain path string, deterministically; a valid-UTF-8 path is never presented in the marked form. No identity carries such a path — no node of such a file has a defined identity (11.2) — and no argument value names one (argument values are UTF-8, above). +* A workspace-relative path that is not valid UTF-8 (14.19) has no plain string form. Wherever an output carries one — a discovered source in the inventory (11.6), an occurrence's referencing file (11.3), a per-file view's file (11.4), a finding's location file or concerned path (14) — it is presented in an explicitly marked byte form (12.7) that carries the path's exact bytes and is distinguishable from every plain path string, deterministically; a valid-UTF-8 path is never presented in the marked form. No identity carries such a path — no node of such a file has a defined identity (11.2) — and no argument value names one (argument values are UTF-8, above). * Where this specification calls for one shortest path and several shortest paths qualify, the reported one is the least by element-wise byte comparison of the paths' node-identity sequences. * Exit codes partition all outcomes; every defined failure belongs to exactly one class. `0` — success, including informational reports (`ids`, `show`, `impact`, `query`, the `review` read subcommands including `next` with nothing to review, `coverage` without `--check`, `version`) and complete, finding-free answers (11.2, 11.6). `1` — findings: source, workspace, and operation validation failures (`build` on invalid sources, `check` findings, `coverage --check` with uncovered requirements, refused `rename`/`move` and their refused previews (6.4–6.6), refused review operations (10.7), `review` subcommands naming a corrupt session and `review list` reporting one (14.21)), and answers carrying findings or explicitly-unavailable data — emitted in full, with exit 1 (11.2, 11.6, 6.6). `2` — usage and configuration errors: unknown commands or flags; missing required flags or arguments; invalid flag values; unknown profiles, sessions, groups, review items, node identities, or files named in arguments — except on `occurrences --to`, where only a malformed identity spelling is a usage error and an unknown or unresolving one selects nothing (11.3); invalid session names; missing or invalid configuration (14.14), which never reaches `version` (12.6); a baseline that cannot be read or reconstructed (6.3); a mutating command refused because another is running (13.5). -* The argument existence checks of `rename` and `move` (a nonexistent origin file or old ID, 6.4, 6.5) and baseline resolution (6.3) precede source validation: these usage errors are reported, and the command exits 2, even when the current sources also fail build validation (6.4, 13.3) — as configuration errors precede all source analysis (14.14). An old ID inside an unparseable origin file (14.20) is masked (14): there the validation findings are reported and the command exits 1. +* The argument existence checks of `rename` and `move` (a nonexistent origin file or old ID, 6.4, 6.5) and baseline resolution (6.3) precede source validation: these usage errors are reported, and the command exits 2, even when the current sources also fail build validation (6.4, 13.3) — as configuration errors precede all source analysis (14.14). An old ID inside an unparseable origin file (14.20) is masked (14): there the validation findings are reported and the command exits 1. Within exit class 2, an error the invocation's syntax alone determines — an unknown command or flag, a repeated flag, a missing required flag or argument, a malformed value — is reported without loading configuration; a configuration error (14.14) precedes every argument check that consults configuration, discovery, or the workspace, the unknown names and files of the usage class included. ### 12.1 `xspec build` @@ -679,10 +679,32 @@ As specified in sections 8, 9, 10, 11, and 6. ### 12.6 `xspec version` -Reports the product version and the machine-interface version. The surface is JSON-only: a single JSON document is its only output form, with or without `--json` (12.0). Both values are fixed per build. The product version is informational — reported for display and support, with no requirement beyond per-build fixedness. The machine-interface version is `1`, and the surface reports exactly this value. The value names the machine-facing JSON contract this specification defines — the JSON output of the product's commands under the universal-JSON and same-information conventions of 12.0 — so an external tool checks compatibility by comparing the reported value against the value its own interface knowledge targets. +Reports the product version and the machine-interface version. The surface is JSON-only: a single JSON document, in the form of 12.7, is its only output form, with or without `--json` (12.0). Both values are fixed per build. The product version is informational — reported for display and support, with no requirement beyond per-build fixedness. The machine-interface version is `1`, and the surface reports exactly this value (12.7). The value names the machine-facing JSON contract this specification defines — the JSON output of the product's commands under the universal-JSON and same-information conventions of 12.0 and the value and document forms of 12.7 — so an external tool checks compatibility by comparing the reported value against the value its own interface knowledge targets. `xspec version` is workspace-independent: it consults no workspace and no configuration — `--config` is accepted (12.0) and not consulted — answers identically in any working directory, no discoverable workspace, missing configuration, and invalid configuration included, and cannot fail for workspace or configuration reasons: configuration-error precedence (14.14) does not reach it. Usage errors keep exit 2 (12.0). +### 12.7 JSON document forms + +The machine-interface version (12.6) names the JSON contract this specification defines. This section fixes its observable forms: the value forms every JSON output uses, and the document forms of the finding report, the error document, and the surfaces of 6.6, 11.3–11.6, and 12.6; every other command's JSON output carries its defining section's information (12.0). Consumers locate every datum by the member names fixed here. Each object carries exactly the members its form names — where a member's datum does not arise (a flag not given, a datum a section defines as structurally absent, a refused preview's plan) the member is `null`, never omitted, except where a form states conditional presence — and no object of any form other than the unavailability marker carries a member named `unavailable`. + +Value forms: + +* A source range (1.7) is `{"start": …, "end": …}`, both non-negative integers. +* A path — workspace-relative, or in the anchoring form of 11.6 — is a string where its bytes are valid UTF-8, and otherwise the marked byte form of 12.0: `{"bytes": "…"}`, the path's exact bytes as lowercase hexadecimal, two digits per byte — an object, equal to no path string. Identities are strings (1.5); no identity carries a non-UTF-8 path (12.0). +* A datum reported explicitly unavailable (11.2, 11.6, 6.6) is `{"unavailable": true}`. A plain value, `null`, and `{"unavailable": true}` are the three observable states of a datum (11.4). +* A finding (14) is `{"code", "message", "locations", "path", "identities"}`: the stable code (`null` where 14 assigns none); the human-readable description; one `{"file", "range"}` per offending construct — ordered by file path bytes, then range start, then range end — empty for conditions without in-source locations; the concerned file or path (`null` for located conditions); and the identities or paths the condition names as context (14), empty where none. Wherever a document carries findings they form the array member `"findings"`, ordered by code — the numbered conditions in numeric order, then the refusal reasons in the order 14 lists them, then code-less findings — then by locations, compared element-wise, then by concerned path. A report whose defined content is findings alone — `build` and `check` reports, the validation errors of refusing reads (13.3), refused operations and their refused previews (6.4–6.6, 10.7) — is `{"findings": […]}`. +* The exit-2 error document (12.0) is `{"error": …}` holding one finding form: for a configuration error, its stable code and concerned path (14); for a plain usage error, `code` and `path` `null`. +* A reference occurrence record (5.7) is `{"file", "range", "kind", "source", "target"}`: the referencing file; the occurrence's own range; its edge kind, `"depends"`, `"embeds"`, or `"references"` (5.2); its source graph node, `{"identity", "range"}` or unavailable (11.2); and its resolved target's identity. + +Document forms — each a single JSON document whose top level is an object; every one below except `version`'s carries the consulted domain's findings (11.2, 11.6, 6.6) under `"findings"`: + +* `occurrences` (11.3): `{"findings", "occurrences"}` — occurrence records in occurrence order (5.7). +* `view` (11.4): `{"findings", "views"}` — one `{"file", "root", "imports", "occurrences", "comments"}` per parseable requested file, ordered by file path bytes; an unparseable requested file contributes no entry (11.4). Each node of the section tree is `{"identity", "range", "opening", "closing", "attributes", "tags", "coverage", "children"}` plus, exactly when `--text` is given, `"ownText"` and `"subtreeText"`: `identity`, `tags`, `coverage`, and the text members are each a plain value, `null` where 11.4 defines structural absence, or unavailable (11.2); `opening` and `closing` are the tag ranges of 11.4, `null` where none exists; `attributes` is one `{"name", "range", "text"}` per spelled attribute in tag order, `name` `null` for a spread attribute; `children` holds the child nodes in document order, and `root` the root node. `imports` is one `{"range", "name", "target"}` per import declaration in document order, `name` `null` where the declaration binds none, `target` a path or unavailable; `occurrences` holds the file's occurrence records and `comments` the comment ranges, each in document order. +* `at` (11.5): `{"findings", "resolution"}` — `resolution` is `{"section", "occurrence"}` or unavailable (11.5): `section` is `{"identity", "range"}` of the innermost enclosing section construct, its identity per 11.2; `occurrence` is the containing occurrence's record, `null` when the offset lies within none. +* `inventory` (11.6): `{"findings", "root", "config", "configuration", "sources", "derived", "recorded", "graphData", "journal", "sessions"}`. `root` and `config` are the anchoring paths (11.6). `configuration` is `{"specs", "code", "markdown", "coverage", "policy"}`, the resolved view (11.6) with every default and inferred kind explicit: `specs` and `code` one `{"name", "globs"}` per group; `markdown` `{"emit", "outDir"}`, `outDir` `null` where unset and an absent `markdown` key resolving to `{"emit": false, "outDir": null}` (7.3); `coverage` one `{"name", "target", "targetTags", "targets", "boundary", "boundaryKind", "mode", "edgeKinds"}` per profile, `targetTags` `null` where absent; `policy` one `{"name", "type", "from", "to", "kinds"}` per rule, each selector `{"group", "kind"}`, `{"files"}`, or `{"tags"}` (7.5). `sources` is one `{"path", "groups"}` per discovered file, `groups` one `{"name", "kind"}` each; `derived` one `{"source", "module", "markdown"}` per discovered spec source, `markdown` `null` while emission is disabled (7.3); `recorded` the record-supplied datum (11.6) — the recorded derived-file paths in byte order — or unavailable (14.23); `graphData` the graph-data area's path; `journal` `{"path", "occupied"}`, `occupied` a boolean; `sessions` the session file paths. List order follows 11.6. +* `rename`/`move` previews (6.6): `{"findings", "mapping", "files", "delta"}`; on refusal `mapping`, `files`, and `delta` are `null`. `mapping` is one `{"from", "to"}` per mapped identity, ordered by `from` bytes. `files` is one `{"file", "edits"}` per file the operation would rewrite, relocate, or create, ordered by file path bytes; each edit is `{"class", "range"}`, edits ordered by range start, then range end, then class; `class` names, in order, the classes of 6.6: `"reference-rewrite"`, `"id-rewrite"`, `"import-specifier-rewrite"`, `"import-addition"`, `"import-removal"`, `"origin-deletion"`, `"target-insertion"`, `"target-parent-rewrite"`, `"file-relocation"`, or `"file-creation"`. `delta` is `{"generated", "removed"}`, each direction's paths in byte order, or unavailable as one datum (6.6). +* `version` (12.6): `{"product", "interface"}` — the product version and the machine-interface version, both strings; the reported machine-interface value is the string form of 12.6's stated value, `"1"`. + ## 13. Workspace Files ### 13.1 Generated TypeScript @@ -701,7 +723,7 @@ xspec maintains graph data under `.xspec/`, containing requirement nodes, code l Every file xspec writes is a plain file suitable for committing, written with stable ordering and sorted keys. Files are classified: -* Derived: generated TypeScript modules and their companion files (13.1), emitted Markdown, and graph data. Derived files are fully reproducible from sources, configuration, and the journal (5.4) via `xspec build`; a conflicted, corrupted, deleted, or orphaned derived file is correctly resolved by rebuilding (12.1). Orphan removal relies on the recorded derived-file paths (13.3): a derived file orphaned while that record was itself missing is outside xspec's knowledge — xspec does not remove it, and it MAY be deleted manually. +* Derived: generated TypeScript modules and their companion files (13.1), emitted Markdown, and graph data. Derived files are fully reproducible from sources, configuration, and the journal (5.4) via `xspec build`; a conflicted, corrupted, deleted, or orphaned derived file is correctly resolved by rebuilding (12.1). Orphan removal relies on the recorded derived-file paths (13.3): a derived file orphaned while that record was itself missing or unreadable (14.23) is outside xspec's knowledge — xspec does not remove it, and it MAY be deleted manually. * Durable: the journal (6.1) and review sessions (10.1). Durable files record operations and resolutions; they are not reproducible, are never regenerated, and MUST NOT be modified except by their owning commands. They are line-oriented or stably keyed so that concurrent additions merge textually; `xspec check` validates their integrity and reports unresolvable states. Derived-file paths belong to xspec: writing a derived file replaces whatever exists at its path, whether or not xspec wrote it. Derived files are never sources: paths whose file name contains `.xspec.`, files under `.xspec/`, and files at the configured Markdown emit destinations (7.3) are excluded from every spec and code group (7). @@ -718,7 +740,7 @@ All state is workspace-local; instances operating on different workspaces MUST N Every reported condition carries a stable machine-readable code identifying which numbered condition it is: 1 `missing-id`, 2 `invalid-structural-id`, 3 `duplicate-id`, 4 `invalid-segment-or-tag`, 5 `unknown-dependency`, 6 `unknown-text-target`, 7 `unknown-ts-reference`, 8 `invalid-argument`, 9 `cycle`, 10 `stale-output`, 11 `cross-module-text`, 12 `policy-violation`, 13 `journal-error`, 14 `configuration-error`, 15 `invalid-import`, 16 `invalid-construct`, 17 `invalid-prop`, 18 `unsupported-node-usage`, 19 `invalid-source-path`, 20 `unparseable-source`, 21 `corrupt-session`, 22 `symlink-write-path`, 23 `unreadable-record`. Stable codes cover exactly these conditions and the refusal reasons below, and no more: a plain usage error (12.0) describes the invocation the consuming tool itself composed, never workspace content to render inline, and carries no stable code — while still arriving as the JSON error document of 12.0 whenever JSON output is in effect — and review-operation refusals (10.7) likewise carry none. -Every condition that locates in source carries, for each offending construct, the containing file and a source range (1.7) at the precision the condition allows. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders in place and no representative is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling recording a participating dependency edge, or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call (14.11) — is identity data on the finding, not a further range. A reference spelling that records no occurrence (5.7, 11.2) is located here: for a spelling of the MDX embedding form, its finding's range is the full braced container, opening brace through closing brace — the span its occurrence would occupy (5.7) — keeping the byte classification of 11.4 exact on imperfect files. Conditions without an in-source location — configuration, path-level, journal, and session conditions — carry the file or path they concern. A configuration error's concerned path is reported in the anchoring form of 11.6, identified relative to the invocation working directory: where a configuration file is concerned — the file the upward search found, or the path `--config` names — it is that file; for missing configuration with no `--config` given, it is the directory the failed upward search started from, the invocation working directory. The JSON report form presents code, locations, and concerned path for every finding, all conditions reported together, with the same information as the human report (12.0). +Every condition that locates in source carries, for each offending construct, the containing file and a source range (1.7) at the precision the condition allows. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders in place and no representative is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling recording a participating dependency edge, or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call (14.11) — is identity data on the finding, not a further range. A reference spelling that records no occurrence (5.7, 11.2) is located here: for a spelling of the MDX embedding form, its finding's range is the full braced container, opening brace through closing brace — the span its occurrence would occupy (5.7) — keeping the byte classification of 11.4 exact on imperfect files. Conditions without an in-source location — configuration, path-level, journal, session, and record conditions — carry the file or path they concern. A configuration error's concerned path is reported in the anchoring form of 11.6, identified relative to the invocation working directory: where a configuration file is concerned — the file the upward search found, or the path `--config` names — it is that file; for missing configuration with no `--config` given, it is the directory the failed upward search started from, the invocation working directory. The JSON report form presents code, locations, and concerned path for every finding — the finding form of 12.7 — all conditions reported together, with the same information as the human report (12.0). The defined error conditions — also the findings that accompany answers over a consulted domain (11.2, 11.6, 6.6) — each reported by `build` and `check` unless its entry states otherwise: @@ -743,8 +765,8 @@ The defined error conditions — also the findings that accompany answers over a 19. Invalid source path: a discovered spec or code source file whose workspace-relative path contains `#` or is not valid UTF-8 (7), or a spec-group file without the `.mdx` extension (7.1). 20. Unparseable source: a spec-group file that is not well-formed MDX, a code-group file that is not well-formed TypeScript under the grammar its file name selects (`.tsx` parses as TSX, any other name as plain TypeScript), or a discovered source file of either kind that is not valid UTF-8 or begins with a byte-order mark (1.6); the error reports the location of the parse failure. 21. Corrupt review session: a session file that is not a plain file (13.4), cannot be parsed, or violates a session invariant (10.1). Reported by `check`, by any `review` subcommand naming the session, and by `review list` (exit 1); not reported by `build`, which does not read sessions. -22. Symbolic link in a write path: a workspace-relative directory component of a path xspec writes is a symbolic link (13.4). A command refuses the write and reports it before modifying anything; `check` reports it without writing. A symbolic link at a derived file's own path is not an error — writing replaces the link (13.4). -23. Unreadable recorded state: recorded generation state (13.3) that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise. Reported by `inventory` (11.6) and by `rename`/`move` previews (6.6), the surfaces that read the record without refreshing it: the record-supplied datum is reported explicitly unavailable, the rest of the answer is emitted in full, and the invocation exits 1. Not reported by `build`, whose rebuild replaces the record (12.1, 13.4), nor by the refreshing reads of 13.3, which leave the record — unreadable state included — unchanged without consulting it (13.3); `check` reports the state as staleness (14.10). +22. Symbolic link in a write path: a workspace-relative directory component of a path xspec writes is a symbolic link (13.4). A command refuses the write and reports it before modifying anything; `check` reports it without writing. The concerned path is the offending symbolic-link component's workspace-relative path — one finding per distinct offending component, whatever write paths it refuses. A symbolic link at a derived file's own path is not an error — writing replaces the link (13.4). +23. Unreadable recorded state: recorded generation state (13.3) that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise. Reported by `inventory` (11.6) and by `rename`/`move` previews (6.6), the surfaces that read the record without refreshing it: the record-supplied datum is reported explicitly unavailable, the rest of the answer is emitted in full, and the invocation exits 1. The concerned path is the graph-data area (11.6): the record's layout is deliberately unenumerated (13.3), so no path inside the area is named. Not reported by `build`, whose rebuild replaces the record (12.1, 13.4), nor by the refreshing reads of 13.3, which leave the record — unreadable state included — unchanged without consulting it (13.3); `check` reports the state as staleness (14.10). Each distinct reason `rename` and `move` refuse (6.4, 6.5) — exactly what a refused preview (6.6) reports — carries a stable code and, under the location-cardinality rule above, the file, source range, or identity it concerns, so a refusal renders as precisely as a finding; refusals are findings in the exit-code partition (12.0), and the JSON report form above carries them. The reasons and their codes: `refused-invalid-id` — the new ID, or an ID the prefix replacement produces, violates 1.3/1.4, concerning that identity; `refused-identity-unchanged` — the new identity equals the old (6.4; the exact self-move of 6.5), concerning it; `refused-id-collision` — the new ID collides with an existing ID (6.4) or with an ID remaining in the target file after the removal (6.5), locating every colliding bearer; `refused-structural-parent` — structural parent rules would not remain satisfied, concerning the violated identity; `refused-unresolvable-reference` — a rewritten reference would not resolve, locating each such reference spelling; `refused-cycle` — the move would create a spec import cycle or a dependency cycle (6.5), locating the would-be cycle's full path per the cardinality rule; `refused-destination-exists` — the file form's destination file already exists (6.5), concerning that path; `refused-missing-target-parent` — the section form's target parent is missing or lies within the moved subtree (6.5), concerning the target-parent identity; `refused-invalid-destination` — the destination file path would not be a valid discovered spec source (6.5), concerning that path. The invalid-workspace refusal (6.4) reports the workspace's findings themselves, each under its own numbered condition and stable code. From 71f18e0aca416d8350c03488e7a4667bc5ca9759 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 21:35:22 +0000 Subject: [PATCH 021/226] sdg(phase-4): seal refused-preview form, unify build-validation gate, total finding order (iter 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: C1 (refused previews leave the findings-alone list; they keep the four-member preview form with null mapping/files/delta), C2 (one gate phrase — "the validations of \`xspec build\`" — in 6.4, 11.2, 12.0, 13.3; the refusal branch of the all-or-nothing reads covers journal errors (14.13) and refused writes (14.22); refresh runs only where build would succeed and cannot fail; conditions 13/22 accompany no 11.3-11.5 answer — findings are the domain files' alone, finding-free answers stay exit 0), I1 (11 intro corrected: only occurrences/view/at/inventory have 12.7 document forms; query — JSON-only already before the IP — carries its defining section's information), I2 (finding order made total: concerned path with null-first, identities, message; identical findings collapse to one), I3 (list members are [] when empty; null only where a form states the datum absent), O1 (companion attribution via 13.1 naming stated in 11.6), O2 (duplicate unresolved-spelling clauses trimmed from 5.7 and 11.3), O4 (offset spelling pinned to ASCII decimal digits), O5 (recorded paths excluded from the staleness comparison), O6 (marked-byte-form list extended with inventory derived paths and view import targets). Rejected: O3 — no modules extracted: the candidates (§10 review mechanism, §14 condition catalog) are consumer-facing interface/contract, which PROCESS.md requires defined in SPEC.md itself, so no loosely coupled non-contract component exists to extract at the current size. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/SPEC.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/specs/SPEC.md b/specs/SPEC.md index 441ab55..abae132 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -281,7 +281,7 @@ A reference occurrence is one textual spelling that records a dependency-kind ed An occurrence carries: the referencing file; its own source range (1.7); its edge kind; its source graph node — one datum: the node's identity together with that node's own source range (1.7); and its resolved target's identity. Occurrence spans are exact per kind: a `d` reference occurrence spans that one reference's own expression; an MDX embedding occurrence spans the entire `{text(...)}` expression container, opening brace through closing brace — the whole construct Markdown compilation replaces (3); a TypeScript `text(...)` occurrence spans the entire call expression, callee through closing parenthesis, argument included; a marker occurrence spans the bare reference chain alone, exclusive of any statement terminator. -A construct that records no edge records no occurrence: an import declaration (its binding used or not), a binding introduced type-only, a chain rooted at a shadowing local declaration (4.5), and a reference spelling that is dynamic or does not resolve (11.2) record none — invalid and unresolved spellings are located by their findings' ranges instead (14). +A construct that records no edge records no occurrence: an import declaration (its binding used or not), a binding introduced type-only, a chain rooted at a shadowing local declaration (4.5), and a reference spelling that is dynamic or does not resolve (11.2) record none. Occurrence order is total and deterministic: by referencing file path (byte order), then by range start, then by range end. Distinct occurrences are distinct spellings occupying distinct spans, so identical ranges do not occur and no further tiebreak exists. @@ -544,7 +544,7 @@ xspec review export [--json] ## 11. Query Surfaces -Five commands give scripts, agents, and external tools machine access to the workspace: `query` (11.1) answers set-level graph questions; `occurrences` (11.3) enumerates reference occurrences; `view` (11.4) returns whole-document structural views; `at` (11.5) resolves byte positions; `inventory` (11.6) reports the workspace's shape. Each is JSON-only: a single JSON document, in the document form of 12.7, is its only output form, with or without `--json` (12.0). `occurrences`, `view`, and `at` answer per file under the availability contract of 11.2; `query` reads only valid workspaces (13.3); `inventory` parses no sources and answers whatever their validity (11.6). +Five commands give scripts, agents, and external tools machine access to the workspace: `query` (11.1) answers set-level graph questions; `occurrences` (11.3) enumerates reference occurrences; `view` (11.4) returns whole-document structural views; `at` (11.5) resolves byte positions; `inventory` (11.6) reports the workspace's shape. Each is JSON-only: a single JSON document is its only output form, with or without `--json` (12.0) — `occurrences`, `view`, `at`, and `inventory` in the document forms of 12.7, `query` carrying its defining section's information (12.7). `occurrences`, `view`, and `at` answer per file under the availability contract of 11.2; `query` reads only valid workspaces (13.3); `inventory` parses no sources and answers whatever their validity (11.6). ### 11.1 `xspec query` @@ -577,7 +577,7 @@ xspec query reachable --from --to [--kinds ] **Consulted domain, findings, exits.** Every answer of 11.3–11.5 has a consulted domain of files, defined per surface, and the findings (14) of every domain file accompany the answer — a masked file's parse-failure finding included. An invocation whose answer carries any finding or any explicitly-unavailable datum exits 1 with the full answer document still emitted — exit 1 signals imperfection and never withholds the answer; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their precedence (12.0, 14.14). The argument checks of 11.3–11.5 precede answering, as `rename`'s and `move`'s argument checks precede source validation (12.0): a malformed `--to` spelling or invalid glob pattern (11.3, 11.1), a `` operand outside the domain or of the wrong kind (11.4, 11.5), an ill-formed or out-of-range offset (11.5), and every other usage error of these surfaces exits 2, whatever findings the workspace or the named files carry. A possibly-incomplete answer is therefore never silent. -**Never stale, never writing.** These surfaces never answer from stale graph data: on a workspace that passes `build` validation they participate in read-time refresh exactly as the reads of 13.3 do; on one that does not, they answer from the current sources and modify nothing — no graph data, no derived files (13.3). +**Never stale, never writing.** These surfaces never answer from stale graph data: on a workspace that passes the validations of `xspec build` (12.1) they participate in read-time refresh exactly as the reads of 13.3 do; on one that fails them — source validation errors, journal errors (14.13), and refused writes (14.22) alike (13.3) — they answer from the current sources and modify nothing: no graph data, no derived files. A gate condition that is a finding of no domain file — the journal's (14.13), a write path's (14.22) — accompanies no answer of these surfaces: on the failing side these answers consult no journal and no record and write nothing, and on the passing side no such finding exists. An answer's findings are its domain files' findings alone, and a complete, finding-free answer exits 0 (above) whatever journal or write-path state the workspace holds. ### 11.3 `xspec occurrences` @@ -589,7 +589,7 @@ Enumerates reference occurrences (5.7) in occurrence order, one record per occur `--file` admits the discovered source files — spec and code alike — that the glob matches, under the glob rules of 7 (a pattern resolving outside the workspace root is an invalid flag value, as in 11.1). It is a set restriction, not an existence assertion: the enumeration's consulted domain (11.2) is the discovered files it admits; a glob admitting none admits the empty set — an empty, finding-free answer, exit 0 — and no unknown-file usage error exists on this filter. Without `--file`, the consulted domain is the entire discovered set. -`--to` selects the occurrences whose resolved target it names. It accepts any syntactically well-formed requirement-node identity — `path#id`, or a bare `path` for a root (1.5) — whatever the workspace contains: acceptance is syntactic, and only a malformed spelling is a usage error (12.0). A spelling is well-formed exactly when it contains at most one `#`, its path part — the whole spelling, or the part before the `#` — is non-empty, and, when a `#` is present, the part after it is one or more non-empty segments joined by `.`, each satisfying the segment rules of 1.4. When the named identity does not currently resolve — its file not discovered, its file masked (14.20), its file's path invalid (14.19), its bearer's identity otherwise undefined (11.2), or no such node — the selection is empty: a spelling that does not resolve records no occurrence. +`--to` selects the occurrences whose resolved target it names. It accepts any syntactically well-formed requirement-node identity — `path#id`, or a bare `path` for a root (1.5) — whatever the workspace contains: acceptance is syntactic, and only a malformed spelling is a usage error (12.0). A spelling is well-formed exactly when it contains at most one `#`, its path part — the whole spelling, or the part before the `#` — is non-empty, and, when a `#` is present, the part after it is one or more non-empty segments joined by `.`, each satisfying the segment rules of 1.4. When the named identity does not currently resolve — its file not discovered, its file masked (14.20), its file's path invalid (14.19), its bearer's identity otherwise undefined (11.2), or no such node — the selection is empty (11.2). The consulted domain's findings accompany the answer (11.2), so an empty, finding-free answer (exit 0) is definitive over the domain: nothing in the consulted files references the identity. Without `--file` the guarantee is absolute — nothing in the workspace references it; under `--file` it is exactly domain-wide — a file outside the admitted set can still hold a resolving occurrence, which the answer neither reports nor denies. @@ -615,7 +615,7 @@ With tags, imports, comments, and embedding occurrences located — an embedding xspec at ``` -Resolves a byte position in a discovered spec source: the innermost section construct whose range (1.7) contains the offset — the root when no narrower section does — reported with its construct range and, per 11.2, its node identity; and, when the offset lies within a reference occurrence's range, that occurrence and its resolved target (5.7). `` asserts domain membership exactly as a `view` operand does (11.4). Resolution is by range containment and total over the file: every within-file offset resolves — bytes inside imports, comments, and content between sections resolve to the innermost enclosing section construct — and the offset equal to the file's byte length (the caret position at end of file) resolves to the root. A greater offset, like an offset that is not a non-negative integer, is a usage error (12.0). The same resolution is derivable from the view's data alone (11.4): `at` adds convenience, not information, serving consumers that keep no client-side index. The consulted domain (11.2) is the named file; on an unparseable file the resolution is reported explicitly unavailable, the parse-failure finding accompanying it (11.2). +Resolves a byte position in a discovered spec source: the innermost section construct whose range (1.7) contains the offset — the root when no narrower section does — reported with its construct range and, per 11.2, its node identity; and, when the offset lies within a reference occurrence's range, that occurrence and its resolved target (5.7). `` asserts domain membership exactly as a `view` operand does (11.4). Resolution is by range containment and total over the file: every within-file offset resolves — bytes inside imports, comments, and content between sections resolve to the innermost enclosing section construct — and the offset equal to the file's byte length (the caret position at end of file) resolves to the root. A greater offset is a usage error (12.0); so is an `` spelled as anything but one or more ASCII decimal digits, read in decimal — leading zeros permitted; a sign, whitespace, or any other character is not a non-negative integer's spelling. The same resolution is derivable from the view's data alone (11.4): `at` adds convenience, not information, serving consumers that keep no client-side index. The consulted domain (11.2) is the named file; on an unparseable file the resolution is reported explicitly unavailable, the parse-failure finding accompanying it (11.2). ### 11.6 `xspec inventory` @@ -628,7 +628,7 @@ Reports the machine-readable shape of the workspace, so an external tool never e * **Anchoring.** The workspace root and the configuration file, identified relative to the invocation working directory — pure invocation input, exactly as `--config` resolution is (7, 12.0) — so a consumer can map the workspace-relative paths in every output to real files without re-implementing the upward search. Only when the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives) is the anchoring reported in the platform's absolute form — the sole absolute-path case (12.0), still a pure function of invocation input. * **Configuration.** The resolved configuration view: the spec and code groups with their glob lists and kinds; Markdown emission state and destinations (7.3); and the coverage profiles and policy rules, each carried with its complete definition, never as a bare name. A group reference inside a profile or rule stays the configured group name, resolving against the group list this same view reports. * **Sources.** Every discovered source file with its group memberships. -* **Derived-file map.** Per discovered spec source: the generated module path (13.1) and, while emission is enabled, the Markdown emit destination (7.3) — determined by configuration and discovery, existing whether or not emission has yet run. Beside it, one record-supplied datum: the recorded derived-file paths (13.3) — the paths as last generated, companions included — reported as recorded: recorded state can lag configuration until a rebuild and is empty before any generation has run. +* **Derived-file map.** Per discovered spec source: the generated module path (13.1) and, while emission is enabled, the Markdown emit destination (7.3) — determined by configuration and discovery, existing whether or not emission has yet run. Beside it, one record-supplied datum: the recorded derived-file paths (13.3) — the paths as last generated, companions included, each companion attributable to its source through the naming scheme of 13.1 — reported as recorded: recorded state can lag configuration until a rebuild and is empty before any generation has run. * **Graph-data area.** The location under which graph data is kept (13.3), reported unconditionally — the record can lag or be empty, but a consumer must know the area before any build has run. The area's classification is a write reservation, not per-occupant ownership: the area is reserved for xspec's writes — a derived-file write there replaces whatever occupies its path (13.4) — so an external tool must never create, edit, or keep content of its own anywhere under it. Individual paths under the area are classified exactly as the inventory reports them: the durable paths below are durable, and a recorded derived path lying under the area is derived. Every other path under the area is unattributed: it may equally be xspec's graph data — derived, rebuild-recoverable, returning with the next successful build or read-time refresh — or foreign content, recorded nowhere and reproduced by nothing. The inventory neither lists such a path, nor claims it for xspec, nor says which case holds: telling them apart is precisely what it declines to enable, and an external tool must treat every unattributed path as undeletable, because it cannot exclude the foreign case. For the same reason the area is never presented as a deletable or wholesale-regenerable unit: the durable files inside it are neither, and whether any particular unattributed path would return is unknowable from the inventory. * **Durable files.** The journal path (6.1), with whether anything presently occupies it — an absent journal is an empty journal (6.1), and occupancy is presence alone, whatever kind of filesystem object occupies the path: the inventory reads no journal content. And the review-session files: every directory entry directly under the review-session directory (10.1) whose name is a well-formed session file name, selected by name alone, whatever kind of filesystem object occupies it — a session-named path holding anything but a plain file is a corrupt session (10.1), and corrupt or unparseable sessions are listed, since the inventory reads no session content. An entry there with any other name is not a session and is never listed: it is an unattributed path under the area, governed by the rule above. @@ -648,10 +648,10 @@ Recorded state that exists but cannot be read as a record — corrupt graph data * Argument values are interpreted as UTF-8; an argument value that is not valid UTF-8 is a usage error. * IDs, tags, identities, session names, and paths compare byte-wise and case-sensitively; no Unicode normalization or case folding is applied anywhere (the create-time session-name restriction of 10.1 is the sole exception). * All output, generated files, and stored data are byte-deterministic for identical input: no wall-clock values, no randomness, no absolute paths, no environment-dependent content. Invocation-anchored content is the stated exception where a section calls for it — the inventory's anchoring (11.6) and configuration-error concerned paths (14), with 11.6's no-relative-path platform case the sole absolute form — itself a pure function of invocation input, deterministic per invocation. -* A workspace-relative path that is not valid UTF-8 (14.19) has no plain string form. Wherever an output carries one — a discovered source in the inventory (11.6), an occurrence's referencing file (11.3), a per-file view's file (11.4), a finding's location file or concerned path (14) — it is presented in an explicitly marked byte form (12.7) that carries the path's exact bytes and is distinguishable from every plain path string, deterministically; a valid-UTF-8 path is never presented in the marked form. No identity carries such a path — no node of such a file has a defined identity (11.2) — and no argument value names one (argument values are UTF-8, above). +* A workspace-relative path that is not valid UTF-8 (14.19) has no plain string form. Wherever an output carries one — a discovered source or derived path in the inventory (11.6), an occurrence's referencing file (11.3), a per-file view's file or an import's resolved target (11.4), a finding's location file or concerned path (14) — it is presented in an explicitly marked byte form (12.7) that carries the path's exact bytes and is distinguishable from every plain path string, deterministically; a valid-UTF-8 path is never presented in the marked form. No identity carries such a path — no node of such a file has a defined identity (11.2) — and no argument value names one (argument values are UTF-8, above). * Where this specification calls for one shortest path and several shortest paths qualify, the reported one is the least by element-wise byte comparison of the paths' node-identity sequences. * Exit codes partition all outcomes; every defined failure belongs to exactly one class. `0` — success, including informational reports (`ids`, `show`, `impact`, `query`, the `review` read subcommands including `next` with nothing to review, `coverage` without `--check`, `version`) and complete, finding-free answers (11.2, 11.6). `1` — findings: source, workspace, and operation validation failures (`build` on invalid sources, `check` findings, `coverage --check` with uncovered requirements, refused `rename`/`move` and their refused previews (6.4–6.6), refused review operations (10.7), `review` subcommands naming a corrupt session and `review list` reporting one (14.21)), and answers carrying findings or explicitly-unavailable data — emitted in full, with exit 1 (11.2, 11.6, 6.6). `2` — usage and configuration errors: unknown commands or flags; missing required flags or arguments; invalid flag values; unknown profiles, sessions, groups, review items, node identities, or files named in arguments — except on `occurrences --to`, where only a malformed identity spelling is a usage error and an unknown or unresolving one selects nothing (11.3); invalid session names; missing or invalid configuration (14.14), which never reaches `version` (12.6); a baseline that cannot be read or reconstructed (6.3); a mutating command refused because another is running (13.5). -* The argument existence checks of `rename` and `move` (a nonexistent origin file or old ID, 6.4, 6.5) and baseline resolution (6.3) precede source validation: these usage errors are reported, and the command exits 2, even when the current sources also fail build validation (6.4, 13.3) — as configuration errors precede all source analysis (14.14). An old ID inside an unparseable origin file (14.20) is masked (14): there the validation findings are reported and the command exits 1. Within exit class 2, an error the invocation's syntax alone determines — an unknown command or flag, a repeated flag, a missing required flag or argument, a malformed value — is reported without loading configuration; a configuration error (14.14) precedes every argument check that consults configuration, discovery, or the workspace, the unknown names and files of the usage class included. +* The argument existence checks of `rename` and `move` (a nonexistent origin file or old ID, 6.4, 6.5) and baseline resolution (6.3) precede source validation: these usage errors are reported, and the command exits 2, even when the current workspace also fails the validations of `xspec build` (6.4, 13.3) — as configuration errors precede all source analysis (14.14). An old ID inside an unparseable origin file (14.20) is masked (14): there the validation findings are reported and the command exits 1. Within exit class 2, an error the invocation's syntax alone determines — an unknown command or flag, a repeated flag, a missing required flag or argument, a malformed value — is reported without loading configuration; a configuration error (14.14) precedes every argument check that consults configuration, discovery, or the workspace, the unknown names and files of the usage class included. ### 12.1 `xspec build` @@ -685,14 +685,14 @@ Reports the product version and the machine-interface version. The surface is JS ### 12.7 JSON document forms -The machine-interface version (12.6) names the JSON contract this specification defines. This section fixes its observable forms: the value forms every JSON output uses, and the document forms of the finding report, the error document, and the surfaces of 6.6, 11.3–11.6, and 12.6; every other command's JSON output carries its defining section's information (12.0). Consumers locate every datum by the member names fixed here. Each object carries exactly the members its form names — where a member's datum does not arise (a flag not given, a datum a section defines as structurally absent, a refused preview's plan) the member is `null`, never omitted, except where a form states conditional presence — and no object of any form other than the unavailability marker carries a member named `unavailable`. +The machine-interface version (12.6) names the JSON contract this specification defines. This section fixes its observable forms: the value forms every JSON output uses, and the document forms of the finding report, the error document, and the surfaces of 6.6, 11.3–11.6, and 12.6; every other command's JSON output carries its defining section's information (12.0). Consumers locate every datum by the member names fixed here. Each object carries exactly the members its form names — where a member's datum does not arise (a flag not given, a datum a section defines as structurally absent, a refused preview's plan) the member is `null`, never omitted, except where a form states conditional presence — and no object of any form other than the unavailability marker carries a member named `unavailable`. A list-valued member with no elements is the empty array: `null` never encodes emptiness — it marks a datum whose absence its form or defining section states — so a root node's `attributes`, a finding-free answer's `findings`, and an empty delta direction are each `[]`, while an absent `targetTags` (11.6) and a root's interpreted `tags` (11.4) are the stated `null`. Value forms: * A source range (1.7) is `{"start": …, "end": …}`, both non-negative integers. * A path — workspace-relative, or in the anchoring form of 11.6 — is a string where its bytes are valid UTF-8, and otherwise the marked byte form of 12.0: `{"bytes": "…"}`, the path's exact bytes as lowercase hexadecimal, two digits per byte — an object, equal to no path string. Identities are strings (1.5); no identity carries a non-UTF-8 path (12.0). * A datum reported explicitly unavailable (11.2, 11.6, 6.6) is `{"unavailable": true}`. A plain value, `null`, and `{"unavailable": true}` are the three observable states of a datum (11.4). -* A finding (14) is `{"code", "message", "locations", "path", "identities"}`: the stable code (`null` where 14 assigns none); the human-readable description; one `{"file", "range"}` per offending construct — ordered by file path bytes, then range start, then range end — empty for conditions without in-source locations; the concerned file or path (`null` for located conditions); and the identities or paths the condition names as context (14), empty where none. Wherever a document carries findings they form the array member `"findings"`, ordered by code — the numbered conditions in numeric order, then the refusal reasons in the order 14 lists them, then code-less findings — then by locations, compared element-wise, then by concerned path. A report whose defined content is findings alone — `build` and `check` reports, the validation errors of refusing reads (13.3), refused operations and their refused previews (6.4–6.6, 10.7) — is `{"findings": […]}`. +* A finding (14) is `{"code", "message", "locations", "path", "identities"}`: the stable code (`null` where 14 assigns none); the human-readable description; one `{"file", "range"}` per offending construct — ordered by file path bytes, then range start, then range end — empty for conditions without in-source locations; the concerned file or path (`null` for located conditions); and the identities or paths the condition names as context (14), empty where none. Wherever a document carries findings they form the array member `"findings"`, ordered by code — the numbered conditions in numeric order, then the refusal reasons in the order 14 lists them, then code-less findings — then by locations, compared element-wise, then by concerned path (`null` before any path), then by identities, compared element-wise, then by message; findings identical in every member collapse to one, so the order is total. A report whose defined content is findings alone — `build` and `check` reports, the findings of refusing reads (13.3), refused operations (6.4, 6.5, 10.7) — is `{"findings": […]}`; a refused preview instead keeps the preview document form, its `mapping`, `files`, and `delta` `null` (6.6). * The exit-2 error document (12.0) is `{"error": …}` holding one finding form: for a configuration error, its stable code and concerned path (14); for a plain usage error, `code` and `path` `null`. * A reference occurrence record (5.7) is `{"file", "range", "kind", "source", "target"}`: the referencing file; the occurrence's own range; its edge kind, `"depends"`, `"embeds"`, or `"references"` (5.2); its source graph node, `{"identity", "range"}` or unavailable (11.2); and its resolved target's identity. @@ -717,7 +717,7 @@ Document forms — each a single JSON document whose top level is an object; eve ### 13.3 Graph data -xspec maintains graph data under `.xspec/`, containing requirement nodes, code locations, edges by kind, source ranges (1.7), all four hashes, coverage attributes, tags, and the paths of the derived files most recently generated (13.4). Graph data serves `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at`. Read results never come from stale data: when graph data is missing or does not match the current sources and configuration, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at` refresh it — writing exactly what `xspec build` would write, except that no TypeScript or Markdown is generated or removed and the recorded derived-file paths are left unchanged — before answering. The record is left unchanged in every state: recorded state that exists but cannot be read as a record (14.23) is neither read, repaired, nor replaced by a refresh — these reads never consult the record and report no finding for it — so the state persists, met by the surfaces that consult the record (11.6, 6.6) and reported as staleness by `check` (14.10), until a successful `build` (12.1) or the finishing regeneration of `rename`/`move` (6.4, 6.5) replaces the record. If the current sources fail `build` validation, `ids`, `show`, `coverage`, `impact`, `review`, and `query` report the validation errors and exit 1 without answering, while `occurrences`, `view`, and `at` answer from the current sources per 11.2; in either case nothing is modified: a failed refresh, like a failed build (12.1), leaves every derived file and all graph data unmodified. `check` never refreshes; it reports staleness instead (14.10). `inventory` neither refreshes nor writes (11.6), and a preview writes nothing (6.6). Graph data is byte-deterministic for a given workspace (12.0); its content is otherwise opaque — graph data's observable contract is its location under `.xspec/`, its classification as a derived file (13.4), and the refresh, failure, and staleness behaviors above and in 14.10. +xspec maintains graph data under `.xspec/`, containing requirement nodes, code locations, edges by kind, source ranges (1.7), all four hashes, coverage attributes, tags, and the paths of the derived files most recently generated (13.4). Graph data serves `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at`. Read results never come from stale data. On a workspace that passes the validations of `xspec build` (12.1), when graph data is missing or does not match the current sources and configuration (a comparison from which the recorded derived-file paths are excluded — refresh leaves them unchanged, so a lagging record alone is never staleness), `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at` refresh it — writing exactly what `xspec build` would write, except that no TypeScript or Markdown is generated or removed and the recorded derived-file paths are left unchanged — before answering; running only where `build` would succeed, refresh, like the finishing regeneration of 6.4, cannot fail. The record is left unchanged in every state: recorded state that exists but cannot be read as a record (14.23) is neither read, repaired, nor replaced by a refresh — these reads never consult the record and report no finding for it — so the state persists, met by the surfaces that consult the record (11.6, 6.6) and reported as staleness by `check` (14.10), until a successful `build` (12.1) or the finishing regeneration of `rename`/`move` (6.4, 6.5) replaces the record. When the current workspace fails the validations of `xspec build` — source validation errors, journal errors (14.13), and refused writes (14.22) alike: the findings a `build` would now report — `ids`, `show`, `coverage`, `impact`, `review`, and `query` report exactly those findings and exit 1 without answering, while `occurrences`, `view`, and `at` answer from the current sources per 11.2, which states the findings accompanying their answers; in either case nothing is modified: every derived file and all graph data remain byte-for-byte as they were, as after a failed `build` (12.1). `check` never refreshes; it reports staleness instead (14.10). `inventory` neither refreshes nor writes (11.6), and a preview writes nothing (6.6). Graph data is byte-deterministic for a given workspace (12.0); its content is otherwise opaque — graph data's observable contract is its location under `.xspec/`, its classification as a derived file (13.4), and the refresh, failure, and staleness behaviors above and in 14.10. ### 13.4 Derived and durable files From 9cc71e83cd58b92c493a97cab8415ef0681765f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 22:02:05 +0000 Subject: [PATCH 022/226] sdg(phase-4): define .mdx-less derived map, import name datum, anchoring spelling, unreadable-record staleness (iter 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: C1 (derived-file map and emit destinations structurally absent for .mdx-less discovered spec sources — 13.1, 13.2 via 7.3, 11.6, 12.7), C2 (view import name = default binding only, absent otherwise — 11.4, 12.7), I1 (canonical anchoring spelling incl. "." self-reference and separator — 11.6, 14), I2 (check reports an unreadable record as a condition-10 finding concerning the graph-data area — 14.10, 12.2), O1 (5.7 definition by resolution), O2 (prefix-first finding order — 12.7), O3 (occurrences in 13.3 content list), O4 (.xspec spelling pinned — 11.6), O5 (exit-2 document holds one finding — 12.7), O6 (positional tree parents to innermost enclosing section — 11.4). Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/SPEC.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/specs/SPEC.md b/specs/SPEC.md index abae132..623bd79 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -277,7 +277,7 @@ Categories are independent flags; a node MAY carry several. The originating node ### 5.7 Reference occurrences -A reference occurrence is one textual spelling that records a dependency-kind edge (5.2): one `d` reference — each entry of a `d` array separately, never the array or the prop (2.2) — one MDX `{text(...)}` embedding (2.3), one TypeScript `text(...)` call (4.3), or one TypeScript dependency marker (4.5). Edges are sets; occurrences are the positions behind them: duplicate references that collapse to a single edge each remain distinct occurrences. Occurrence existence turns on target resolution (11.2): a spelling that resolves records an occurrence even where 11.2 leaves its source graph node's identity undefined, the source datum then reported explicitly unavailable. +A reference occurrence is one textual spelling of a dependency-kind reference (5.2) whose target resolves (11.2): one `d` reference — each entry of a `d` array separately, never the array or the prop (2.2) — one MDX `{text(...)}` embedding (2.3), one TypeScript `text(...)` call (4.3), or one TypeScript dependency marker (4.5). Edges are sets; occurrences are the positions behind them: duplicate references that collapse to a single edge each remain distinct occurrences. Occurrence existence turns on target resolution (11.2): a spelling that resolves records an occurrence even where 11.2 leaves its source graph node's identity undefined, the source datum then reported explicitly unavailable. An occurrence carries: the referencing file; its own source range (1.7); its edge kind; its source graph node — one datum: the node's identity together with that node's own source range (1.7); and its resolved target's identity. Occurrence spans are exact per kind: a `d` reference occurrence spans that one reference's own expression; an MDX embedding occurrence spans the entire `{text(...)}` expression container, opening brace through closing brace — the whole construct Markdown compilation replaces (3); a TypeScript `text(...)` occurrence spans the entire call expression, callee through closing parenthesis, argument included; a marker occurrence spans the bare reference chain alone, exclusive of any statement terminator. @@ -381,7 +381,7 @@ Named groups of TypeScript source files, each a list of globs. Code groups serve ### 7.3 `markdown` -The `markdown` key is optional; when it is absent, no Markdown is emitted. When present, `markdown.emit` (boolean, required) controls whether pure Markdown files are emitted, and `markdown.outDir` (optional path) redirects emitted files into a directory, preserving workspace-relative paths; the default emits next to each source file. `outDir` resolves relative to the workspace root and MUST resolve within it; a value resolving outside the workspace root is a configuration error (14.14). The configured Markdown emit destinations (4, 13.4) exist exactly while emission is enabled: with `markdown` present and `emit` `true`, they are the paths at which the discovered spec sources emit (13.2), whether or not emission has yet run; with `markdown` absent or `emit` `false`, no path is a Markdown emit destination — the exclusion of 13.4 and the import rule of 4 then have no Markdown component. +The `markdown` key is optional; when it is absent, no Markdown is emitted. When present, `markdown.emit` (boolean, required) controls whether pure Markdown files are emitted, and `markdown.outDir` (optional path) redirects emitted files into a directory, preserving workspace-relative paths; the default emits next to each source file. `outDir` resolves relative to the workspace root and MUST resolve within it; a value resolving outside the workspace root is a configuration error (14.14). The configured Markdown emit destinations (4, 13.4) exist exactly while emission is enabled: with `markdown` present and `emit` `true`, they are the paths at which the discovered spec sources emit (13.2) — a spec-group file without the `.mdx` extension emits nothing and contributes none (13.1) — whether or not emission has yet run; with `markdown` absent or `emit` `false`, no path is a Markdown emit destination — the exclusion of 13.4 and the import rule of 4 then have no Markdown component. ### 7.4 `coverage` @@ -601,9 +601,9 @@ xspec view [ …] [--file ] [--text] Returns, per requested file, everything needed to overlay structure on the raw MDX bytes. The view's domain is the discovered spec sources. Naming `` operands asserts membership: a file outside the discovered set is an unknown file (12.0), and a discovered code source, which has no structural view, is a wrong-kind operand, a usage error (12.0) — the wrong-kind reference of 14.14's pattern, as a code group's name is where a spec group's is required (11.1) — each exit 2. `--file` is instead a set restriction over the domain, under the glob rules of 7 (as in 11.3): it admits the discovered spec sources it matches, and a glob admitting none — matching no discovered file, or only code sources — admits the empty set, an empty, finding-free answer, exit 0. Combining `` operands with `--file` is a usage error; with neither, the request covers every discovered spec source. The requested files form a set; a multi-file request returns per-file views ordered by byte order of workspace-relative path, in one JSON document. The consulted domain (11.2) is the requested files plus, with `--text`, every file the requested expansions transitively consult: exactly the files of the resolved targets reachable from the requested files' embeddings through resolved — occurrence-recording (5.7) — embeddings, an embedding cycle's participants included, whether or not any expansion completes. A spelling that records no occurrence is an expansion's boundary: it consults no further file — the finding blocking there is the spelling's own (11.2, 14.5–14.7), lying in a file already consulted — while the finding that blocks a deeper expansion, an unresolved spelling or a cycle participation, can lie in a consulted file the request never named. A masked file (14.20) is never consulted by an expansion — no spelling resolves into it (11.2) — so its parse-failure finding accompanies the answer only when it is itself a requested file. An unparseable requested file contributes no view, its parse-failure finding reporting it (11.2). A requested file whose path is invalid (14.19) keeps its view — structure is parse-local (11.2) — every node identity in it explicitly unavailable, its condition-19 finding accompanying. Each parseable requested file's view contains: -* the root node and the full section tree in document order. The tree is positional — defined by construct nesting alone — and exists for every parseable file, whatever findings the file carries. Each node carries its construct range (1.7); its raw attribute spellings as parsed, one entry per attribute the tag spells, in tag order — each entry the attribute's name as spelled (structurally absent for a spread attribute), its source range (1.7), and its source text: the attribute's own characters, for a named attribute its name through the last character of its value or the bare name where it spells no value, for a spread attribute (2.7) its entire braced construct — inclusion is by form: every attribute the tag spells appears, repeated, unknown, and spread attributes included, their invalidity a located finding (14), never a view omission; and, each per 11.2 (defined, or explicitly unavailable), its node identity, interpreted tags, and coverage attribute, plus — with `--text` — its own and subtree text. A root's tags and coverage attribute are structurally absent (5.5, 11.1): reported as absent, never as unavailable — no finding, no exit-1 consequence; +* the root node and the full section tree in document order. The tree is positional — defined by construct nesting alone — and exists for every parseable file, whatever findings the file carries; its nodes are the root and every section construct of the file, wherever it stands — a section nested inside any non-section construct (an invalid element of 14.16 included) parents to the innermost enclosing section construct, the root when none encloses it: the same enclosure 11.2's chain conditions read. Each node carries its construct range (1.7); its raw attribute spellings as parsed, one entry per attribute the tag spells, in tag order — each entry the attribute's name as spelled (structurally absent for a spread attribute), its source range (1.7), and its source text: the attribute's own characters, for a named attribute its name through the last character of its value or the bare name where it spells no value, for a spread attribute (2.7) its entire braced construct — inclusion is by form: every attribute the tag spells appears, repeated, unknown, and spread attributes included, their invalidity a located finding (14), never a view omission; and, each per 11.2 (defined, or explicitly unavailable), its node identity, interpreted tags, and coverage attribute, plus — with `--text` — its own and subtree text. A root's tags and coverage attribute are structurally absent (5.5, 11.1): reported as absent, never as unavailable — no finding, no exit-1 consequence; * for each non-root node, the decomposition of its construct range: the opening tag's range and the closing tag's range — a self-closing section has an opening-tag range only, a root node neither; -* every import declaration, valid or invalid, with its source range, its binding name — structurally absent when the declaration binds none, reported as absent, never as unavailable — and its resolved target file where specifier form and discovery define one, explicitly unavailable otherwise (11.2), the invalidity itself a located finding (14); +* every import declaration, valid or invalid, with its source range, its binding name — the identifier the declaration binds as its default binding; structurally absent when it binds no default, the invalid side-effect-only, named-only, and namespace-only forms (2.1) alike, an identifier bound by any non-default clause never being this datum — reported as absent, never as unavailable — and its resolved target file where specifier form and discovery define one, explicitly unavailable otherwise (11.2), the invalidity itself a located finding (14); * every reference occurrence in the file (5.7), in document order; * every MDX comment's source range. @@ -625,11 +625,11 @@ xspec inventory Reports the machine-readable shape of the workspace, so an external tool never edits files xspec owns and never misses files xspec reads. The inventory parses no sources, so it answers whatever the sources' validity; configuration errors keep their precedence (14.14). It never refreshes or writes anything, and it reports: -* **Anchoring.** The workspace root and the configuration file, identified relative to the invocation working directory — pure invocation input, exactly as `--config` resolution is (7, 12.0) — so a consumer can map the workspace-relative paths in every output to real files without re-implementing the upward search. Only when the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives) is the anchoring reported in the platform's absolute form — the sole absolute-path case (12.0), still a pure function of invocation input. +* **Anchoring.** The workspace root and the configuration file, identified relative to the invocation working directory — pure invocation input, exactly as `--config` resolution is (7, 12.0) — so a consumer can map the workspace-relative paths in every output to real files without re-implementing the upward search. The spelling is canonical: the segments ascending from the working directory to the nearest common ancestor, each spelled `..`, then the segments descending to the identified file or directory, joined with `/` on every platform — no `.` segments, no trailing separator — and the working directory itself spelled `.`. Only when the platform admits no relative path between the working directory and the workspace root (roots on different Windows drives) is the anchoring reported in the platform's absolute form, drive-qualified in the platform's own spelling — the sole absolute-path case and the sole output spelling whose separator is the platform's (12.0), still a pure function of invocation input. * **Configuration.** The resolved configuration view: the spec and code groups with their glob lists and kinds; Markdown emission state and destinations (7.3); and the coverage profiles and policy rules, each carried with its complete definition, never as a bare name. A group reference inside a profile or rule stays the configured group name, resolving against the group list this same view reports. * **Sources.** Every discovered source file with its group memberships. -* **Derived-file map.** Per discovered spec source: the generated module path (13.1) and, while emission is enabled, the Markdown emit destination (7.3) — determined by configuration and discovery, existing whether or not emission has yet run. Beside it, one record-supplied datum: the recorded derived-file paths (13.3) — the paths as last generated, companions included, each companion attributable to its source through the naming scheme of 13.1 — reported as recorded: recorded state can lag configuration until a rebuild and is empty before any generation has run. -* **Graph-data area.** The location under which graph data is kept (13.3), reported unconditionally — the record can lag or be empty, but a consumer must know the area before any build has run. The area's classification is a write reservation, not per-occupant ownership: the area is reserved for xspec's writes — a derived-file write there replaces whatever occupies its path (13.4) — so an external tool must never create, edit, or keep content of its own anywhere under it. Individual paths under the area are classified exactly as the inventory reports them: the durable paths below are durable, and a recorded derived path lying under the area is derived. Every other path under the area is unattributed: it may equally be xspec's graph data — derived, rebuild-recoverable, returning with the next successful build or read-time refresh — or foreign content, recorded nowhere and reproduced by nothing. The inventory neither lists such a path, nor claims it for xspec, nor says which case holds: telling them apart is precisely what it declines to enable, and an external tool must treat every unattributed path as undeletable, because it cannot exclude the foreign case. For the same reason the area is never presented as a deletable or wholesale-regenerable unit: the durable files inside it are neither, and whether any particular unattributed path would return is unknowable from the inventory. +* **Derived-file map.** Per discovered spec source: the generated module path (13.1) and, while emission is enabled, the Markdown emit destination (7.3) — determined by configuration and discovery, existing whether or not emission has yet run; for a spec-group file without the `.mdx` extension (14.19), which generates and emits nothing (13.1), both are structurally absent (12.7). Beside it, one record-supplied datum: the recorded derived-file paths (13.3) — the paths as last generated, companions included, each companion attributable to its source through the naming scheme of 13.1 — reported as recorded: recorded state can lag configuration until a rebuild and is empty before any generation has run. +* **Graph-data area.** The location under which graph data is kept (13.3) — the `.xspec` directory, spelled as its workspace-relative path with no trailing separator — reported unconditionally: the record can lag or be empty, but a consumer must know the area before any build has run. The area's classification is a write reservation, not per-occupant ownership: the area is reserved for xspec's writes — a derived-file write there replaces whatever occupies its path (13.4) — so an external tool must never create, edit, or keep content of its own anywhere under it. Individual paths under the area are classified exactly as the inventory reports them: the durable paths below are durable, and a recorded derived path lying under the area is derived. Every other path under the area is unattributed: it may equally be xspec's graph data — derived, rebuild-recoverable, returning with the next successful build or read-time refresh — or foreign content, recorded nowhere and reproduced by nothing. The inventory neither lists such a path, nor claims it for xspec, nor says which case holds: telling them apart is precisely what it declines to enable, and an external tool must treat every unattributed path as undeletable, because it cannot exclude the foreign case. For the same reason the area is never presented as a deletable or wholesale-regenerable unit: the durable files inside it are neither, and whether any particular unattributed path would return is unknowable from the inventory. * **Durable files.** The journal path (6.1), with whether anything presently occupies it — an absent journal is an empty journal (6.1), and occupancy is presence alone, whatever kind of filesystem object occupies the path: the inventory reads no journal content. And the review-session files: every directory entry directly under the review-session directory (10.1) whose name is a well-formed session file name, selected by name alone, whatever kind of filesystem object occupies it — a session-named path holding anything but a plain file is a corrupt session (10.1), and corrupt or unparseable sessions are listed, since the inventory reads no session content. An entry there with any other name is not a session and is never listed: it is an unattributed path under the area, governed by the rule above. Inventory lists are ordered deterministically: files and paths in byte order of workspace-relative path, groups, profiles, and rules in configuration order, and session files in byte order of file name. @@ -659,7 +659,7 @@ Parses configured sources; validates section structure, IDs, tags, and reference ### 12.2 `xspec check` -Performs all build validations without accepting stale outputs, and additionally verifies: generated files are content-identical to what the current sources and configuration generate, and no recorded derived file remains at a path no longer generated (14.10); all dependency and text references resolve and are static; all TypeScript spec references resolve; no dependency cycles and no spec import cycles exist; the journal is well-formed and replayable with no conflicting mappings; no policy violations exist; review sessions are not internally corrupt. Exits 1 on any finding. Configuration validity is enforced at load by every command (14.14) and is a usage error, not a `check` finding. +Performs all build validations without accepting stale outputs, and additionally verifies: generated files are content-identical to what the current sources and configuration generate, no recorded derived file remains at a path no longer generated, and the recorded generation state is readable as a record (14.10, 14.23); all dependency and text references resolve and are static; all TypeScript spec references resolve; no dependency cycles and no spec import cycles exist; the journal is well-formed and replayable with no conflicting mappings; no policy violations exist; review sessions are not internally corrupt. Exits 1 on any finding. Configuration validity is enforced at load by every command (14.14) and is a usage error, not a `check` finding. ### 12.3 `xspec ids` @@ -692,16 +692,16 @@ Value forms: * A source range (1.7) is `{"start": …, "end": …}`, both non-negative integers. * A path — workspace-relative, or in the anchoring form of 11.6 — is a string where its bytes are valid UTF-8, and otherwise the marked byte form of 12.0: `{"bytes": "…"}`, the path's exact bytes as lowercase hexadecimal, two digits per byte — an object, equal to no path string. Identities are strings (1.5); no identity carries a non-UTF-8 path (12.0). * A datum reported explicitly unavailable (11.2, 11.6, 6.6) is `{"unavailable": true}`. A plain value, `null`, and `{"unavailable": true}` are the three observable states of a datum (11.4). -* A finding (14) is `{"code", "message", "locations", "path", "identities"}`: the stable code (`null` where 14 assigns none); the human-readable description; one `{"file", "range"}` per offending construct — ordered by file path bytes, then range start, then range end — empty for conditions without in-source locations; the concerned file or path (`null` for located conditions); and the identities or paths the condition names as context (14), empty where none. Wherever a document carries findings they form the array member `"findings"`, ordered by code — the numbered conditions in numeric order, then the refusal reasons in the order 14 lists them, then code-less findings — then by locations, compared element-wise, then by concerned path (`null` before any path), then by identities, compared element-wise, then by message; findings identical in every member collapse to one, so the order is total. A report whose defined content is findings alone — `build` and `check` reports, the findings of refusing reads (13.3), refused operations (6.4, 6.5, 10.7) — is `{"findings": […]}`; a refused preview instead keeps the preview document form, its `mapping`, `files`, and `delta` `null` (6.6). -* The exit-2 error document (12.0) is `{"error": …}` holding one finding form: for a configuration error, its stable code and concerned path (14); for a plain usage error, `code` and `path` `null`. +* A finding (14) is `{"code", "message", "locations", "path", "identities"}`: the stable code (`null` where 14 assigns none); the human-readable description; one `{"file", "range"}` per offending construct — ordered by file path bytes, then range start, then range end — empty for conditions without in-source locations; the concerned file or path (`null` for located conditions); and the identities or paths the condition names as context (14), empty where none. Wherever a document carries findings they form the array member `"findings"`, ordered by code — the numbered conditions in numeric order, then the refusal reasons in the order 14 lists them, then code-less findings — then by locations, compared element-wise — a sequence that is a proper prefix of another sorts first — then by concerned path (`null` before any path), then by identities, compared the same way, then by message; findings identical in every member collapse to one, so the order is total. A report whose defined content is findings alone — `build` and `check` reports, the findings of refusing reads (13.3), refused operations (6.4, 6.5, 10.7) — is `{"findings": […]}`; a refused preview instead keeps the preview document form, its `mapping`, `files`, and `delta` `null` (6.6). +* The exit-2 error document (12.0) is `{"error": …}` holding one finding form: for a configuration error, its stable code and concerned path (14); for a plain usage error, `code` and `path` `null`. One invocation reports one error: the document holds a single finding however many defects are present — a configuration file with several distinct defects is one condition-14 finding, its message deterministic (12.0) but otherwise unpinned. * A reference occurrence record (5.7) is `{"file", "range", "kind", "source", "target"}`: the referencing file; the occurrence's own range; its edge kind, `"depends"`, `"embeds"`, or `"references"` (5.2); its source graph node, `{"identity", "range"}` or unavailable (11.2); and its resolved target's identity. Document forms — each a single JSON document whose top level is an object; every one below except `version`'s carries the consulted domain's findings (11.2, 11.6, 6.6) under `"findings"`: * `occurrences` (11.3): `{"findings", "occurrences"}` — occurrence records in occurrence order (5.7). -* `view` (11.4): `{"findings", "views"}` — one `{"file", "root", "imports", "occurrences", "comments"}` per parseable requested file, ordered by file path bytes; an unparseable requested file contributes no entry (11.4). Each node of the section tree is `{"identity", "range", "opening", "closing", "attributes", "tags", "coverage", "children"}` plus, exactly when `--text` is given, `"ownText"` and `"subtreeText"`: `identity`, `tags`, `coverage`, and the text members are each a plain value, `null` where 11.4 defines structural absence, or unavailable (11.2); `opening` and `closing` are the tag ranges of 11.4, `null` where none exists; `attributes` is one `{"name", "range", "text"}` per spelled attribute in tag order, `name` `null` for a spread attribute; `children` holds the child nodes in document order, and `root` the root node. `imports` is one `{"range", "name", "target"}` per import declaration in document order, `name` `null` where the declaration binds none, `target` a path or unavailable; `occurrences` holds the file's occurrence records and `comments` the comment ranges, each in document order. +* `view` (11.4): `{"findings", "views"}` — one `{"file", "root", "imports", "occurrences", "comments"}` per parseable requested file, ordered by file path bytes; an unparseable requested file contributes no entry (11.4). Each node of the section tree is `{"identity", "range", "opening", "closing", "attributes", "tags", "coverage", "children"}` plus, exactly when `--text` is given, `"ownText"` and `"subtreeText"`: `identity`, `tags`, `coverage`, and the text members are each a plain value, `null` where 11.4 defines structural absence, or unavailable (11.2); `opening` and `closing` are the tag ranges of 11.4, `null` where none exists; `attributes` is one `{"name", "range", "text"}` per spelled attribute in tag order, `name` `null` for a spread attribute; `children` holds the child nodes in document order, and `root` the root node. `imports` is one `{"range", "name", "target"}` per import declaration in document order, `name` `null` where the declaration binds no default binding (11.4), `target` a path or unavailable; `occurrences` holds the file's occurrence records and `comments` the comment ranges, each in document order. * `at` (11.5): `{"findings", "resolution"}` — `resolution` is `{"section", "occurrence"}` or unavailable (11.5): `section` is `{"identity", "range"}` of the innermost enclosing section construct, its identity per 11.2; `occurrence` is the containing occurrence's record, `null` when the offset lies within none. -* `inventory` (11.6): `{"findings", "root", "config", "configuration", "sources", "derived", "recorded", "graphData", "journal", "sessions"}`. `root` and `config` are the anchoring paths (11.6). `configuration` is `{"specs", "code", "markdown", "coverage", "policy"}`, the resolved view (11.6) with every default and inferred kind explicit: `specs` and `code` one `{"name", "globs"}` per group; `markdown` `{"emit", "outDir"}`, `outDir` `null` where unset and an absent `markdown` key resolving to `{"emit": false, "outDir": null}` (7.3); `coverage` one `{"name", "target", "targetTags", "targets", "boundary", "boundaryKind", "mode", "edgeKinds"}` per profile, `targetTags` `null` where absent; `policy` one `{"name", "type", "from", "to", "kinds"}` per rule, each selector `{"group", "kind"}`, `{"files"}`, or `{"tags"}` (7.5). `sources` is one `{"path", "groups"}` per discovered file, `groups` one `{"name", "kind"}` each; `derived` one `{"source", "module", "markdown"}` per discovered spec source, `markdown` `null` while emission is disabled (7.3); `recorded` the record-supplied datum (11.6) — the recorded derived-file paths in byte order — or unavailable (14.23); `graphData` the graph-data area's path; `journal` `{"path", "occupied"}`, `occupied` a boolean; `sessions` the session file paths. List order follows 11.6. +* `inventory` (11.6): `{"findings", "root", "config", "configuration", "sources", "derived", "recorded", "graphData", "journal", "sessions"}`. `root` and `config` are the anchoring paths (11.6). `configuration` is `{"specs", "code", "markdown", "coverage", "policy"}`, the resolved view (11.6) with every default and inferred kind explicit: `specs` and `code` one `{"name", "globs"}` per group; `markdown` `{"emit", "outDir"}`, `outDir` `null` where unset and an absent `markdown` key resolving to `{"emit": false, "outDir": null}` (7.3); `coverage` one `{"name", "target", "targetTags", "targets", "boundary", "boundaryKind", "mode", "edgeKinds"}` per profile, `targetTags` `null` where absent; `policy` one `{"name", "type", "from", "to", "kinds"}` per rule, each selector `{"group", "kind"}`, `{"files"}`, or `{"tags"}` (7.5). `sources` is one `{"path", "groups"}` per discovered file, `groups` one `{"name", "kind"}` each; `derived` one `{"source", "module", "markdown"}` per discovered spec source — `module` and `markdown` `null` for a spec-group file without the `.mdx` extension (11.6, 13.1), `markdown` `null` also while emission is disabled (7.3); `recorded` the record-supplied datum (11.6) — the recorded derived-file paths in byte order — or unavailable (14.23); `graphData` the graph-data area's path; `journal` `{"path", "occupied"}`, `occupied` a boolean; `sessions` the session file paths. List order follows 11.6. * `rename`/`move` previews (6.6): `{"findings", "mapping", "files", "delta"}`; on refusal `mapping`, `files`, and `delta` are `null`. `mapping` is one `{"from", "to"}` per mapped identity, ordered by `from` bytes. `files` is one `{"file", "edits"}` per file the operation would rewrite, relocate, or create, ordered by file path bytes; each edit is `{"class", "range"}`, edits ordered by range start, then range end, then class; `class` names, in order, the classes of 6.6: `"reference-rewrite"`, `"id-rewrite"`, `"import-specifier-rewrite"`, `"import-addition"`, `"import-removal"`, `"origin-deletion"`, `"target-insertion"`, `"target-parent-rewrite"`, `"file-relocation"`, or `"file-creation"`. `delta` is `{"generated", "removed"}`, each direction's paths in byte order, or unavailable as one datum (6.6). * `version` (12.6): `{"product", "interface"}` — the product version and the machine-interface version, both strings; the reported machine-interface value is the string form of 12.6's stated value, `"1"`. @@ -709,7 +709,7 @@ Document forms — each a single JSON document whose top level is an object; eve ### 13.1 Generated TypeScript -`NAME.mdx` generates, in the source file's directory, the TypeScript module `NAME.xspec.ts`, beginning with the generated-file header (4), together with whatever companion files beside it are needed so that the specifier `./NAME.xspec` resolves for consumers: type checking (4.1), hover documentation and go-to-definition into the source `.mdx` (4.2), and runtime behavior (4.3–4.5) MUST all hold under standard TypeScript tooling with no xspec runtime dependency. Every companion file is named `NAME.xspec.` plus a suffix, so the module and all companions carry `.xspec.` in their names and are derived files under the source-discovery exclusion (13.4). +`NAME.mdx` generates, in the source file's directory, the TypeScript module `NAME.xspec.ts`, beginning with the generated-file header (4), together with whatever companion files beside it are needed so that the specifier `./NAME.xspec` resolves for consumers: type checking (4.1), hover documentation and go-to-definition into the source `.mdx` (4.2), and runtime behavior (4.3–4.5) MUST all hold under standard TypeScript tooling with no xspec runtime dependency. Every companion file is named `NAME.xspec.` plus a suffix, so the module and all companions carry `.xspec.` in their names and are derived files under the source-discovery exclusion (13.4). Per-source derived paths are defined by this `NAME.mdx` name shape alone: a spec-group file without the `.mdx` extension (14.19) generates no module and emits no Markdown (13.2) — it has no generated-module path and no Markdown emit destination (7.3, 11.6). ### 13.2 Markdown output @@ -717,7 +717,7 @@ Document forms — each a single JSON document whose top level is an object; eve ### 13.3 Graph data -xspec maintains graph data under `.xspec/`, containing requirement nodes, code locations, edges by kind, source ranges (1.7), all four hashes, coverage attributes, tags, and the paths of the derived files most recently generated (13.4). Graph data serves `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at`. Read results never come from stale data. On a workspace that passes the validations of `xspec build` (12.1), when graph data is missing or does not match the current sources and configuration (a comparison from which the recorded derived-file paths are excluded — refresh leaves them unchanged, so a lagging record alone is never staleness), `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at` refresh it — writing exactly what `xspec build` would write, except that no TypeScript or Markdown is generated or removed and the recorded derived-file paths are left unchanged — before answering; running only where `build` would succeed, refresh, like the finishing regeneration of 6.4, cannot fail. The record is left unchanged in every state: recorded state that exists but cannot be read as a record (14.23) is neither read, repaired, nor replaced by a refresh — these reads never consult the record and report no finding for it — so the state persists, met by the surfaces that consult the record (11.6, 6.6) and reported as staleness by `check` (14.10), until a successful `build` (12.1) or the finishing regeneration of `rename`/`move` (6.4, 6.5) replaces the record. When the current workspace fails the validations of `xspec build` — source validation errors, journal errors (14.13), and refused writes (14.22) alike: the findings a `build` would now report — `ids`, `show`, `coverage`, `impact`, `review`, and `query` report exactly those findings and exit 1 without answering, while `occurrences`, `view`, and `at` answer from the current sources per 11.2, which states the findings accompanying their answers; in either case nothing is modified: every derived file and all graph data remain byte-for-byte as they were, as after a failed `build` (12.1). `check` never refreshes; it reports staleness instead (14.10). `inventory` neither refreshes nor writes (11.6), and a preview writes nothing (6.6). Graph data is byte-deterministic for a given workspace (12.0); its content is otherwise opaque — graph data's observable contract is its location under `.xspec/`, its classification as a derived file (13.4), and the refresh, failure, and staleness behaviors above and in 14.10. +xspec maintains graph data under `.xspec/`, containing requirement nodes, code locations, edges by kind, reference occurrences (5.7), source ranges (1.7), all four hashes, coverage attributes, tags, and the paths of the derived files most recently generated (13.4). Graph data serves `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at`. Read results never come from stale data. On a workspace that passes the validations of `xspec build` (12.1), when graph data is missing or does not match the current sources and configuration (a comparison from which the recorded derived-file paths are excluded — refresh leaves them unchanged, so a lagging record alone is never staleness), `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at` refresh it — writing exactly what `xspec build` would write, except that no TypeScript or Markdown is generated or removed and the recorded derived-file paths are left unchanged — before answering; running only where `build` would succeed, refresh, like the finishing regeneration of 6.4, cannot fail. The record is left unchanged in every state: recorded state that exists but cannot be read as a record (14.23) is neither read, repaired, nor replaced by a refresh — these reads never consult the record and report no finding for it — so the state persists, met by the surfaces that consult the record (11.6, 6.6) and reported as staleness by `check` (14.10), until a successful `build` (12.1) or the finishing regeneration of `rename`/`move` (6.4, 6.5) replaces the record. When the current workspace fails the validations of `xspec build` — source validation errors, journal errors (14.13), and refused writes (14.22) alike: the findings a `build` would now report — `ids`, `show`, `coverage`, `impact`, `review`, and `query` report exactly those findings and exit 1 without answering, while `occurrences`, `view`, and `at` answer from the current sources per 11.2, which states the findings accompanying their answers; in either case nothing is modified: every derived file and all graph data remain byte-for-byte as they were, as after a failed `build` (12.1). `check` never refreshes; it reports staleness instead (14.10). `inventory` neither refreshes nor writes (11.6), and a preview writes nothing (6.6). Graph data is byte-deterministic for a given workspace (12.0); its content is otherwise opaque — graph data's observable contract is its location under `.xspec/`, its classification as a derived file (13.4), and the refresh, failure, and staleness behaviors above and in 14.10. ### 13.4 Derived and durable files @@ -740,7 +740,7 @@ All state is workspace-local; instances operating on different workspaces MUST N Every reported condition carries a stable machine-readable code identifying which numbered condition it is: 1 `missing-id`, 2 `invalid-structural-id`, 3 `duplicate-id`, 4 `invalid-segment-or-tag`, 5 `unknown-dependency`, 6 `unknown-text-target`, 7 `unknown-ts-reference`, 8 `invalid-argument`, 9 `cycle`, 10 `stale-output`, 11 `cross-module-text`, 12 `policy-violation`, 13 `journal-error`, 14 `configuration-error`, 15 `invalid-import`, 16 `invalid-construct`, 17 `invalid-prop`, 18 `unsupported-node-usage`, 19 `invalid-source-path`, 20 `unparseable-source`, 21 `corrupt-session`, 22 `symlink-write-path`, 23 `unreadable-record`. Stable codes cover exactly these conditions and the refusal reasons below, and no more: a plain usage error (12.0) describes the invocation the consuming tool itself composed, never workspace content to render inline, and carries no stable code — while still arriving as the JSON error document of 12.0 whenever JSON output is in effect — and review-operation refusals (10.7) likewise carry none. -Every condition that locates in source carries, for each offending construct, the containing file and a source range (1.7) at the precision the condition allows. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders in place and no representative is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling recording a participating dependency edge, or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call (14.11) — is identity data on the finding, not a further range. A reference spelling that records no occurrence (5.7, 11.2) is located here: for a spelling of the MDX embedding form, its finding's range is the full braced container, opening brace through closing brace — the span its occurrence would occupy (5.7) — keeping the byte classification of 11.4 exact on imperfect files. Conditions without an in-source location — configuration, path-level, journal, session, and record conditions — carry the file or path they concern. A configuration error's concerned path is reported in the anchoring form of 11.6, identified relative to the invocation working directory: where a configuration file is concerned — the file the upward search found, or the path `--config` names — it is that file; for missing configuration with no `--config` given, it is the directory the failed upward search started from, the invocation working directory. The JSON report form presents code, locations, and concerned path for every finding — the finding form of 12.7 — all conditions reported together, with the same information as the human report (12.0). +Every condition that locates in source carries, for each offending construct, the containing file and a source range (1.7) at the precision the condition allows. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders in place and no representative is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling recording a participating dependency edge, or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call (14.11) — is identity data on the finding, not a further range. A reference spelling that records no occurrence (5.7, 11.2) is located here: for a spelling of the MDX embedding form, its finding's range is the full braced container, opening brace through closing brace — the span its occurrence would occupy (5.7) — keeping the byte classification of 11.4 exact on imperfect files. Conditions without an in-source location — configuration, path-level, journal, session, and record conditions — carry the file or path they concern. A configuration error's concerned path is reported in the anchoring form of 11.6, identified relative to the invocation working directory: where a configuration file is concerned — the file the upward search found, or the path `--config` names — it is that file; for missing configuration with no `--config` given, it is the directory the failed upward search started from, the invocation working directory, spelled `.` (11.6). The JSON report form presents code, locations, and concerned path for every finding — the finding form of 12.7 — all conditions reported together, with the same information as the human report (12.0). The defined error conditions — also the findings that accompany answers over a consulted domain (11.2, 11.6, 6.6) — each reported by `build` and `check` unless its entry states otherwise: @@ -753,7 +753,7 @@ The defined error conditions — also the findings that accompany answers over a 7. Unknown TypeScript reference: a marker or `text` call that does not resolve; this is also a type error against the generated module. 8. Invalid argument: a `d` or `text(...)` reference that is not static per 2.4, a non-static bare reference in TypeScript expression-statement position (4.5), a `text(...)` call without exactly one argument, or a string-form `text(...)` argument in a TypeScript file (4.3). 9. Cycle: a dependency cycle (with the full path) or a spec import cycle. -10. Stale generated output: a derived file whose content does not match what the current sources and configuration generate, or a recorded derived file (13.3) remaining at a path the current sources and configuration no longer generate; the error names the file and instructs rebuilding. Reported by `check` only: `build` cannot observe staleness because it regenerates every derived file (12.1). +10. Stale generated output: a derived file whose content does not match what the current sources and configuration generate, or a recorded derived file (13.3) remaining at a path the current sources and configuration no longer generate — the error names the file and instructs rebuilding — or recorded generation state that exists but cannot be read as a record (14.23): one finding, instructing rebuilding, whose concerned path is the graph-data area (11.6) — the record's layout is unenumerated (13.3), so no path inside the area is named; while that state holds, this form alone reports the graph data, and the recorded-file form above, consulting no readable record, is undetectable (14). Reported by `check` only: `build` cannot observe staleness because it regenerates every derived file (12.1). 11. Cross-module text call: a node passed to the `text` export of a spec module other than its own; additionally a TypeScript type error and a runtime throw per 4.4. 12. Policy violation: rule name plus offending edge. Reported by `check` only: policy constrains the workspace graph, not source validity, and `build` regenerates output regardless of policy findings (7.5, 12.1). 13. Journal error: malformed, conflicting, or unreplayable entries, naming the lines; a journal path occupied by anything other than a plain file (13.4). From 32b5bd4ea7947309b7102515821ad48bb7346719 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 22:40:17 +0000 Subject: [PATCH 023/226] sdg(phase-4): pin preview reporting for created files, finding-order comparators, JSON contract details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration 6 of applying IP 0001. Applied: C1, I1, O1, O2, O3, O4, O5, O6. - C1: 6.6/6.5 — a created target file's entry reports its creation as its only edit, subsuming the insertion and the import additions composed into the file's initial content (per the IP's "one reported location without pre-operation coordinates"); the moved text's own rewrites stay reported in the origin file; 6.5's preview-offset promise scoped to files existing before the operation. - I1: 12.7 — between-findings `locations` ordering given its element comparator (file path bytes, range start, range end); identities' element rule spelled out alongside. - O1: 14/12.7 — a stable code's value pinned as the listed token string; numerals are ordinals for ordering, no part of the value. - O2: 12.7 — preview edit tiebreak pinned to class-name bytes. - O3: 11.2 — membership rule for a domain file's findings (a location in the file, or the file as concerned path); joint multi-file findings accompany whole when any participant is in the domain. - O4: 6.6 — a refused preview consults no record; no condition-23 finding accompanies a refusal. - O5: 11.5 — a non-UTF-8-path source is nameable by no argument value; its positions are reachable only through the view by glob. - O6: 12.7 — exit-2 error document moved under Document forms, lead exception amended. Rejected: O7 — modularization is a PROCESS SHOULD, not violated at the current size; the reviewer's own framing conditions extraction on future growth, and restructuring at convergence risks breakage with no requirement gain. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/SPEC.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/specs/SPEC.md b/specs/SPEC.md index 623bd79..32b5ac1 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -314,7 +314,7 @@ xspec move [--preview] xspec move # # [--preview] ``` -The first form relocates an entire source file; IDs are unchanged and every node's identity changes only in its file part. Relocation also rewrites the moved file's own import specifiers, and the paths by which other files import the moved file's generated module, so all references continue to resolve. The second form extracts a section subtree: the section and its descendants are removed from the origin, inserted as the last child of the target parent (or at the end of the file for a top-level `new-id`), and re-identified by prefix replacement of `` with ``. The target file is created if absent, empty before insertion. The second form's text edits are exact: the moved text is the section construct's own characters — from the first character of its opening tag through the last character of its closing tag, or the self-closing tag's own characters for a self-closing section (1.1). At the origin it is deleted in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3). It is inserted immediately before the target parent's closing tag — at the end of the file for a top-level `new-id` — followed by a U+000A line terminator, and preceded by one when the insertion point is not at the start of a line. A self-closing target parent (1.1) is first rewritten to the paired form: its `/` and any whitespace immediately before or after the `/` are deleted, and the closing tag matching the opening tag's name (`` or ``) is appended immediately after the tag's terminating `>`; the insertion rule then applies before that closing tag. Beyond these edits, the identity and reference rewrites of this section, and the finishing regeneration, a move changes no bytes. In both forms, all references across the workspace are rewritten to resolve to the new identities, converting between local and imported forms and adding or removing spec module imports as the rewrite requires — an import is added when a rewritten reference needs a module binding its file lacks, and an existing spec module import is removed exactly when its binding had references and the rewrite leaves it with none (an import whose binding was already unreferenced stays, 2.1) — and the full mapping is appended to the journal. Import edits are exact. An import removal deletes the declaration's own characters in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3): the removal's extent is the declaration plus any such adjunct drop. An added import binds fresh identifiers colliding with no binding already in the file (2.1, 4) and is inserted as a line of its own — the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — at an offset where the file's grammar permits an import declaration; the identifier choice and the insertion offset are implementation latitude, exercised deterministically: rewritten file content is byte-deterministic for a given operation and workspace state (6.1), and the offset is exactly the one the operation's preview reports (6.6). A successful move regenerates derived files as rename does (6.4). +The first form relocates an entire source file; IDs are unchanged and every node's identity changes only in its file part. Relocation also rewrites the moved file's own import specifiers, and the paths by which other files import the moved file's generated module, so all references continue to resolve. The second form extracts a section subtree: the section and its descendants are removed from the origin, inserted as the last child of the target parent (or at the end of the file for a top-level `new-id`), and re-identified by prefix replacement of `` with ``. The target file is created if absent, empty before insertion. The second form's text edits are exact: the moved text is the section construct's own characters — from the first character of its opening tag through the last character of its closing tag, or the self-closing tag's own characters for a self-closing section (1.1). At the origin it is deleted in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3). It is inserted immediately before the target parent's closing tag — at the end of the file for a top-level `new-id` — followed by a U+000A line terminator, and preceded by one when the insertion point is not at the start of a line. A self-closing target parent (1.1) is first rewritten to the paired form: its `/` and any whitespace immediately before or after the `/` are deleted, and the closing tag matching the opening tag's name (`` or ``) is appended immediately after the tag's terminating `>`; the insertion rule then applies before that closing tag. Beyond these edits, the identity and reference rewrites of this section, and the finishing regeneration, a move changes no bytes. In both forms, all references across the workspace are rewritten to resolve to the new identities, converting between local and imported forms and adding or removing spec module imports as the rewrite requires — an import is added when a rewritten reference needs a module binding its file lacks, and an existing spec module import is removed exactly when its binding had references and the rewrite leaves it with none (an import whose binding was already unreferenced stays, 2.1) — and the full mapping is appended to the journal. Import edits are exact. An import removal deletes the declaration's own characters in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3): the removal's extent is the declaration plus any such adjunct drop. An added import binds fresh identifiers colliding with no binding already in the file (2.1, 4) and is inserted as a line of its own — the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — at an offset where the file's grammar permits an import declaration; the identifier choice and the insertion offset are implementation latitude, exercised deterministically: rewritten file content is byte-deterministic for a given operation and workspace state (6.1), and in a file existing before the operation the offset is exactly the one the operation's preview reports (6.6) — an addition into a created target file is subsumed, with the rest of that file's initial content, by the preview's file-creation class (6.6), its placement pinned by byte-determinism alone. A successful move regenerates derived files as rename does (6.4). Move validation mirrors rename validation, including the valid-workspace precondition (6.4) and the usage-error classification of a nonexistent origin file or ID (12.0); the mirrored checks read in identity terms: the new ID is valid, structural parent rules remain satisfied, all rewritten references resolve, the new identity differs from the old — a cross-file section move keeping its ID is therefore valid, while the exact self-move, `#` equal to `#`, is refused and appends no journal entry — and, in the section form, `` collides with no ID remaining in the target file after the removal. Move additionally MUST refuse: a move that would create an import cycle among spec source files or a dependency cycle; a file-form move whose destination file already exists; a section-form move whose target parent — the target file's section bearing `` minus its final segment, needed whenever `` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal; and a move whose destination file path (including a target file to be created) would not be a valid discovered spec source after the move — a path belonging to no configured spec group (a move never takes a node out of the workspace), belonging to a code group as well (14.14), containing `#`, not valid UTF-8, or lacking the `.mdx` extension (14.19). These refusals keep every successful move's finishing regeneration (6.4) on a valid workspace, so it cannot fail. Each distinct refusal reason carries its stable code and location (14); with `--preview`, the operation is planned and reported but performed on nothing (6.6). @@ -323,8 +323,8 @@ Move validation mirrors rename validation, including the valid-workspace precond `xspec rename … --preview` and `xspec move … --preview` perform the full validation and planning of the operation and report its consequences while modifying nothing: no sources, no journal, no derived files, no graph data. A preview is refused exactly when — reporting what, and exiting as — the real operation would be refused, and succeeds exactly when the real operation would proceed. The equivalence is over workspace state (validation and planning), never over scheduling: the mutual-exclusion refusal of 13.5 applies to the real operation only. A preview invocation is a non-mutating command under 13.5 — it acquires no workspace exclusivity and is safe to run while readers run — and does not take the acquisition-tied test seam: supplying `--test-hold` together with `--preview` is a usage error (12.0). Preview output is byte-deterministic (12.0), supports `--json` per 12.0 — the preview document form of 12.7 — and reports: * the complete identity mapping the operation would journal; -* every file the operation would rewrite or relocate, with every edit the operation would make in it, each located by a source range (1.7) in current, pre-operation coordinates and classed as exactly one of: a reference-occurrence rewrite (5.7 — `d` references, `text(...)` references, TypeScript markers); an `id`-attribute rewrite (rename's and the section move's re-identification); an import-specifier rewrite; an import addition; an import removal — import-edit extents and insertion offsets per 6.5; the section move's origin deletion — one range spanning every byte the origin edit removes: the construct's own characters, extended over the leftover whitespace and line terminator of each line the line-drop rule additionally drops (6.5, 3), bytes contiguous with the construct, so the adjunct drop lies inside this class's range rather than forming a class of its own; the section move's target insertion point; the self-closing-target-parent rewrite when one applies (6.5); the file move's relocation of the file itself; or target-file creation — reported, when the section form's target file does not yet exist, as its own class with the insertion point at the start of the new file, the one reported location without pre-operation coordinates. A rewrite's range is the construct it rewrites — a reference occurrence's span (5.7), the `id` attribute's own characters, the import specifier literal's characters, the target parent's self-closing tag; a removal's range spans every byte its edit removes, as the origin deletion's does; an import addition's insertion point, the section move's target insertion point, and the created target file's start are zero-length ranges at the insertion offset; the relocation's range is the entire moved file. An edit is reported without replacement text: its class and the identity mapping state what changes, and the resulting bytes are observable only by running the operation — the preview is a safety report, not an edit script whose external application would bypass the journaled mapping. Reported ranges MAY nest — the section move's re-identification rewrites locate, in the same pre-operation coordinates, inside its origin deletion's range — each edit reported under its own class, containment being geometry, not double-reporting; -* the derived-file delta, both directions one datum: the derived paths the operation would newly generate — paths where nothing is currently recorded as generated — and the recorded derived paths (13.3) the operation would leave no longer generated, the pre-move module path after a file move included. The delta is the identity-relevant consequence; the full regeneration set a successful operation rewrites is workspace-constant and reported by the inventory (11.6), so the preview does not repeat it. Both directions consult the recorded derived-file paths — presence at a path cannot tell a generated occupant from a foreign one — and a preview, writing nothing, never refreshes the record. Recorded state that exists but cannot be read as a record is condition 23 (14): the delta is reported explicitly unavailable — never fabricated, never read as an empty record — the finding accompanies the report with its stable code, the invocation exits 1 (12.0), and every other part of the report is emitted in full. The real operation is not refused in that state — a corrupt record fails no build validation, and the finishing regeneration (6.4) replaces corrupt graph data — so the preview is not refused either: the unreadable record lies on the success side of the refusal equivalence. +* every file the operation would rewrite, relocate, or create, with every edit the operation would make in it, classed as exactly one of the following and — target-file creation excepted — located by a source range (1.7) in current, pre-operation coordinates: a reference-occurrence rewrite (5.7 — `d` references, `text(...)` references, TypeScript markers); an `id`-attribute rewrite (rename's and the section move's re-identification); an import-specifier rewrite; an import addition; an import removal — import-edit extents and insertion offsets per 6.5; the section move's origin deletion — one range spanning every byte the origin edit removes: the construct's own characters, extended over the leftover whitespace and line terminator of each line the line-drop rule additionally drops (6.5, 3), bytes contiguous with the construct, so the adjunct drop lies inside this class's range rather than forming a class of its own; the section move's target insertion point; the self-closing-target-parent rewrite when one applies (6.5); the file move's relocation of the file itself; or target-file creation — reported, when the section form's target file does not yet exist, as its own class with the insertion point at the start of the new file: the one reported location without pre-operation coordinates, and the created file's only reported edit — creation composes the file's entire initial content, subsuming the insertion and the import additions the rewrite requires there (6.5), edits no pre-operation coordinates exist to locate, while the moved text's own rewrites are reported in the origin file, inside the origin deletion's range (below). A rewrite's range is the construct it rewrites — a reference occurrence's span (5.7), the `id` attribute's own characters, the import specifier literal's characters, the target parent's self-closing tag; a removal's range spans every byte its edit removes, as the origin deletion's does; an import addition's insertion point, the section move's target insertion point, and the created target file's start are zero-length ranges at the insertion offset; the relocation's range is the entire moved file. An edit is reported without replacement text: its class and the identity mapping state what changes, and the resulting bytes are observable only by running the operation — the preview is a safety report, not an edit script whose external application would bypass the journaled mapping. Reported ranges MAY nest — the section move's re-identification rewrites locate, in the same pre-operation coordinates, inside its origin deletion's range — each edit reported under its own class, containment being geometry, not double-reporting; +* the derived-file delta, both directions one datum: the derived paths the operation would newly generate — paths where nothing is currently recorded as generated — and the recorded derived paths (13.3) the operation would leave no longer generated, the pre-move module path after a file move included. The delta is the identity-relevant consequence; the full regeneration set a successful operation rewrites is workspace-constant and reported by the inventory (11.6), so the preview does not repeat it. Both directions consult the recorded derived-file paths — presence at a path cannot tell a generated occupant from a foreign one — and a preview, writing nothing, never refreshes the record. Recorded state that exists but cannot be read as a record is condition 23 (14): the delta is reported explicitly unavailable — never fabricated, never read as an empty record — the finding accompanies the report with its stable code, the invocation exits 1 (12.0), and every other part of the report is emitted in full. The real operation is not refused in that state — a corrupt record fails no build validation, and the finishing regeneration (6.4) replaces corrupt graph data — so the preview is not refused either: the unreadable record lies on the success side of the refusal equivalence. A refused preview consults no record — it reports the refusal findings alone, its `mapping`, `files`, and `delta` `null` (12.7) — so no condition-23 finding ever accompanies a refusal. ### 6.7 Manual restructuring @@ -575,7 +575,7 @@ xspec query reachable --from --to [--kinds ] **Unavailability is explicit.** A datum these rules leave undefined — a node's identity (a section's under the spelling rules above; every node's in a file whose path is invalid); a section's tags or coverage value; an occurrence's source graph node, identity and range withheld together as one datum (5.7), the enclosing construct's position staying on view through 11.4; an import's resolved target when specifier form or discovery defines none; an own- or subtree-text value — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. -**Consulted domain, findings, exits.** Every answer of 11.3–11.5 has a consulted domain of files, defined per surface, and the findings (14) of every domain file accompany the answer — a masked file's parse-failure finding included. An invocation whose answer carries any finding or any explicitly-unavailable datum exits 1 with the full answer document still emitted — exit 1 signals imperfection and never withholds the answer; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their precedence (12.0, 14.14). The argument checks of 11.3–11.5 precede answering, as `rename`'s and `move`'s argument checks precede source validation (12.0): a malformed `--to` spelling or invalid glob pattern (11.3, 11.1), a `` operand outside the domain or of the wrong kind (11.4, 11.5), an ill-formed or out-of-range offset (11.5), and every other usage error of these surfaces exits 2, whatever findings the workspace or the named files carry. A possibly-incomplete answer is therefore never silent. +**Consulted domain, findings, exits.** Every answer of 11.3–11.5 has a consulted domain of files, defined per surface, and the findings (14) of every domain file accompany the answer — a masked file's parse-failure finding included. A finding is a domain file's exactly when one of its locations (14) lies in that file or that file is its concerned path (14.19); a condition several files jointly violate — a cross-file cycle (14.9) — accompanies the answer, whole, whenever any participating file lies in the domain. An invocation whose answer carries any finding or any explicitly-unavailable datum exits 1 with the full answer document still emitted — exit 1 signals imperfection and never withholds the answer; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their precedence (12.0, 14.14). The argument checks of 11.3–11.5 precede answering, as `rename`'s and `move`'s argument checks precede source validation (12.0): a malformed `--to` spelling or invalid glob pattern (11.3, 11.1), a `` operand outside the domain or of the wrong kind (11.4, 11.5), an ill-formed or out-of-range offset (11.5), and every other usage error of these surfaces exits 2, whatever findings the workspace or the named files carry. A possibly-incomplete answer is therefore never silent. **Never stale, never writing.** These surfaces never answer from stale graph data: on a workspace that passes the validations of `xspec build` (12.1) they participate in read-time refresh exactly as the reads of 13.3 do; on one that fails them — source validation errors, journal errors (14.13), and refused writes (14.22) alike (13.3) — they answer from the current sources and modify nothing: no graph data, no derived files. A gate condition that is a finding of no domain file — the journal's (14.13), a write path's (14.22) — accompanies no answer of these surfaces: on the failing side these answers consult no journal and no record and write nothing, and on the passing side no such finding exists. An answer's findings are its domain files' findings alone, and a complete, finding-free answer exits 0 (above) whatever journal or write-path state the workspace holds. @@ -615,7 +615,7 @@ With tags, imports, comments, and embedding occurrences located — an embedding xspec at ``` -Resolves a byte position in a discovered spec source: the innermost section construct whose range (1.7) contains the offset — the root when no narrower section does — reported with its construct range and, per 11.2, its node identity; and, when the offset lies within a reference occurrence's range, that occurrence and its resolved target (5.7). `` asserts domain membership exactly as a `view` operand does (11.4). Resolution is by range containment and total over the file: every within-file offset resolves — bytes inside imports, comments, and content between sections resolve to the innermost enclosing section construct — and the offset equal to the file's byte length (the caret position at end of file) resolves to the root. A greater offset is a usage error (12.0); so is an `` spelled as anything but one or more ASCII decimal digits, read in decimal — leading zeros permitted; a sign, whitespace, or any other character is not a non-negative integer's spelling. The same resolution is derivable from the view's data alone (11.4): `at` adds convenience, not information, serving consumers that keep no client-side index. The consulted domain (11.2) is the named file; on an unparseable file the resolution is reported explicitly unavailable, the parse-failure finding accompanying it (11.2). +Resolves a byte position in a discovered spec source: the innermost section construct whose range (1.7) contains the offset — the root when no narrower section does — reported with its construct range and, per 11.2, its node identity; and, when the offset lies within a reference occurrence's range, that occurrence and its resolved target (5.7). `` asserts domain membership exactly as a `view` operand does (11.4). Resolution is by range containment and total over the file: every within-file offset resolves — bytes inside imports, comments, and content between sections resolve to the innermost enclosing section construct — and the offset equal to the file's byte length (the caret position at end of file) resolves to the root. A greater offset is a usage error (12.0); so is an `` spelled as anything but one or more ASCII decimal digits, read in decimal — leading zeros permitted; a sign, whitespace, or any other character is not a non-negative integer's spelling. The same resolution is derivable from the view's data alone (11.4): `at` adds convenience, not information, serving consumers that keep no client-side index. A discovered spec source whose path is not valid UTF-8 is nameable by no argument value (12.0), so `at` cannot address it: for such a file (14.19) the view, reached by glob (11.4), is the one route to position data. The consulted domain (11.2) is the named file; on an unparseable file the resolution is reported explicitly unavailable, the parse-failure finding accompanying it (11.2). ### 11.6 `xspec inventory` @@ -692,18 +692,18 @@ Value forms: * A source range (1.7) is `{"start": …, "end": …}`, both non-negative integers. * A path — workspace-relative, or in the anchoring form of 11.6 — is a string where its bytes are valid UTF-8, and otherwise the marked byte form of 12.0: `{"bytes": "…"}`, the path's exact bytes as lowercase hexadecimal, two digits per byte — an object, equal to no path string. Identities are strings (1.5); no identity carries a non-UTF-8 path (12.0). * A datum reported explicitly unavailable (11.2, 11.6, 6.6) is `{"unavailable": true}`. A plain value, `null`, and `{"unavailable": true}` are the three observable states of a datum (11.4). -* A finding (14) is `{"code", "message", "locations", "path", "identities"}`: the stable code (`null` where 14 assigns none); the human-readable description; one `{"file", "range"}` per offending construct — ordered by file path bytes, then range start, then range end — empty for conditions without in-source locations; the concerned file or path (`null` for located conditions); and the identities or paths the condition names as context (14), empty where none. Wherever a document carries findings they form the array member `"findings"`, ordered by code — the numbered conditions in numeric order, then the refusal reasons in the order 14 lists them, then code-less findings — then by locations, compared element-wise — a sequence that is a proper prefix of another sorts first — then by concerned path (`null` before any path), then by identities, compared the same way, then by message; findings identical in every member collapse to one, so the order is total. A report whose defined content is findings alone — `build` and `check` reports, the findings of refusing reads (13.3), refused operations (6.4, 6.5, 10.7) — is `{"findings": […]}`; a refused preview instead keeps the preview document form, its `mapping`, `files`, and `delta` `null` (6.6). -* The exit-2 error document (12.0) is `{"error": …}` holding one finding form: for a configuration error, its stable code and concerned path (14); for a plain usage error, `code` and `path` `null`. One invocation reports one error: the document holds a single finding however many defects are present — a configuration file with several distinct defects is one condition-14 finding, its message deterministic (12.0) but otherwise unpinned. +* A finding (14) is `{"code", "message", "locations", "path", "identities"}`: the stable code, the token string 14 assigns (`null` where 14 assigns none); the human-readable description; one `{"file", "range"}` per offending construct — ordered by file path bytes, then range start, then range end — empty for conditions without in-source locations; the concerned file or path (`null` for located conditions); and the identities or paths the condition names as context (14), empty where none. Wherever a document carries findings they form the array member `"findings"`, ordered by code — the numbered conditions in numeric order, then the refusal reasons in the order 14 lists them, then code-less findings — then by locations, compared element-wise — each element by file path bytes, then range start, then range end; a sequence that is a proper prefix of another sorts first — then by concerned path (`null` before any path), then by identities, compared element-wise under the same prefix rule (string elements by bytes, 12.0), then by message; findings identical in every member collapse to one, so the order is total. A report whose defined content is findings alone — `build` and `check` reports, the findings of refusing reads (13.3), refused operations (6.4, 6.5, 10.7) — is `{"findings": […]}`; a refused preview instead keeps the preview document form, its `mapping`, `files`, and `delta` `null` (6.6). * A reference occurrence record (5.7) is `{"file", "range", "kind", "source", "target"}`: the referencing file; the occurrence's own range; its edge kind, `"depends"`, `"embeds"`, or `"references"` (5.2); its source graph node, `{"identity", "range"}` or unavailable (11.2); and its resolved target's identity. -Document forms — each a single JSON document whose top level is an object; every one below except `version`'s carries the consulted domain's findings (11.2, 11.6, 6.6) under `"findings"`: +Document forms — each a single JSON document whose top level is an object; every one below except `version`'s and the exit-2 error document carries the consulted domain's findings (11.2, 11.6, 6.6) under `"findings"`: * `occurrences` (11.3): `{"findings", "occurrences"}` — occurrence records in occurrence order (5.7). * `view` (11.4): `{"findings", "views"}` — one `{"file", "root", "imports", "occurrences", "comments"}` per parseable requested file, ordered by file path bytes; an unparseable requested file contributes no entry (11.4). Each node of the section tree is `{"identity", "range", "opening", "closing", "attributes", "tags", "coverage", "children"}` plus, exactly when `--text` is given, `"ownText"` and `"subtreeText"`: `identity`, `tags`, `coverage`, and the text members are each a plain value, `null` where 11.4 defines structural absence, or unavailable (11.2); `opening` and `closing` are the tag ranges of 11.4, `null` where none exists; `attributes` is one `{"name", "range", "text"}` per spelled attribute in tag order, `name` `null` for a spread attribute; `children` holds the child nodes in document order, and `root` the root node. `imports` is one `{"range", "name", "target"}` per import declaration in document order, `name` `null` where the declaration binds no default binding (11.4), `target` a path or unavailable; `occurrences` holds the file's occurrence records and `comments` the comment ranges, each in document order. * `at` (11.5): `{"findings", "resolution"}` — `resolution` is `{"section", "occurrence"}` or unavailable (11.5): `section` is `{"identity", "range"}` of the innermost enclosing section construct, its identity per 11.2; `occurrence` is the containing occurrence's record, `null` when the offset lies within none. * `inventory` (11.6): `{"findings", "root", "config", "configuration", "sources", "derived", "recorded", "graphData", "journal", "sessions"}`. `root` and `config` are the anchoring paths (11.6). `configuration` is `{"specs", "code", "markdown", "coverage", "policy"}`, the resolved view (11.6) with every default and inferred kind explicit: `specs` and `code` one `{"name", "globs"}` per group; `markdown` `{"emit", "outDir"}`, `outDir` `null` where unset and an absent `markdown` key resolving to `{"emit": false, "outDir": null}` (7.3); `coverage` one `{"name", "target", "targetTags", "targets", "boundary", "boundaryKind", "mode", "edgeKinds"}` per profile, `targetTags` `null` where absent; `policy` one `{"name", "type", "from", "to", "kinds"}` per rule, each selector `{"group", "kind"}`, `{"files"}`, or `{"tags"}` (7.5). `sources` is one `{"path", "groups"}` per discovered file, `groups` one `{"name", "kind"}` each; `derived` one `{"source", "module", "markdown"}` per discovered spec source — `module` and `markdown` `null` for a spec-group file without the `.mdx` extension (11.6, 13.1), `markdown` `null` also while emission is disabled (7.3); `recorded` the record-supplied datum (11.6) — the recorded derived-file paths in byte order — or unavailable (14.23); `graphData` the graph-data area's path; `journal` `{"path", "occupied"}`, `occupied` a boolean; `sessions` the session file paths. List order follows 11.6. -* `rename`/`move` previews (6.6): `{"findings", "mapping", "files", "delta"}`; on refusal `mapping`, `files`, and `delta` are `null`. `mapping` is one `{"from", "to"}` per mapped identity, ordered by `from` bytes. `files` is one `{"file", "edits"}` per file the operation would rewrite, relocate, or create, ordered by file path bytes; each edit is `{"class", "range"}`, edits ordered by range start, then range end, then class; `class` names, in order, the classes of 6.6: `"reference-rewrite"`, `"id-rewrite"`, `"import-specifier-rewrite"`, `"import-addition"`, `"import-removal"`, `"origin-deletion"`, `"target-insertion"`, `"target-parent-rewrite"`, `"file-relocation"`, or `"file-creation"`. `delta` is `{"generated", "removed"}`, each direction's paths in byte order, or unavailable as one datum (6.6). +* `rename`/`move` previews (6.6): `{"findings", "mapping", "files", "delta"}`; on refusal `mapping`, `files`, and `delta` are `null`. `mapping` is one `{"from", "to"}` per mapped identity, ordered by `from` bytes. `files` is one `{"file", "edits"}` per file the operation would rewrite, relocate, or create, ordered by file path bytes; each edit is `{"class", "range"}`, edits ordered by range start, then range end, then class-name bytes; `class` names, in order, the classes of 6.6: `"reference-rewrite"`, `"id-rewrite"`, `"import-specifier-rewrite"`, `"import-addition"`, `"import-removal"`, `"origin-deletion"`, `"target-insertion"`, `"target-parent-rewrite"`, `"file-relocation"`, or `"file-creation"`. `delta` is `{"generated", "removed"}`, each direction's paths in byte order, or unavailable as one datum (6.6). * `version` (12.6): `{"product", "interface"}` — the product version and the machine-interface version, both strings; the reported machine-interface value is the string form of 12.6's stated value, `"1"`. +* The exit-2 error document (12.0): `{"error": …}` holding one finding form: for a configuration error, its stable code and concerned path (14); for a plain usage error, `code` and `path` `null`. One invocation reports one error: the document holds a single finding however many defects are present — a configuration file with several distinct defects is one condition-14 finding, its message deterministic (12.0) but otherwise unpinned. ## 13. Workspace Files @@ -738,7 +738,7 @@ All state is workspace-local; instances operating on different workspaces MUST N `xspec build` and `xspec check` MUST report actionable errors that identify the file, location, and correction. When several error conditions are present, they MUST report each of them, not only the first; a condition goes unreported only where another error makes it undetectable — an unparseable file (14.20) masks the conditions inside itself, and a reference into it reports as unresolved (14.5–14.7) — and a configuration error (14.14) precedes all source analysis. -Every reported condition carries a stable machine-readable code identifying which numbered condition it is: 1 `missing-id`, 2 `invalid-structural-id`, 3 `duplicate-id`, 4 `invalid-segment-or-tag`, 5 `unknown-dependency`, 6 `unknown-text-target`, 7 `unknown-ts-reference`, 8 `invalid-argument`, 9 `cycle`, 10 `stale-output`, 11 `cross-module-text`, 12 `policy-violation`, 13 `journal-error`, 14 `configuration-error`, 15 `invalid-import`, 16 `invalid-construct`, 17 `invalid-prop`, 18 `unsupported-node-usage`, 19 `invalid-source-path`, 20 `unparseable-source`, 21 `corrupt-session`, 22 `symlink-write-path`, 23 `unreadable-record`. Stable codes cover exactly these conditions and the refusal reasons below, and no more: a plain usage error (12.0) describes the invocation the consuming tool itself composed, never workspace content to render inline, and carries no stable code — while still arriving as the JSON error document of 12.0 whenever JSON output is in effect — and review-operation refusals (10.7) likewise carry none. +Every reported condition carries a stable machine-readable code identifying which numbered condition it is — a code's value is its token as listed, a string (12.7), and a numeral below is the condition's ordinal, ordering findings (12.7), no part of the value: 1 `missing-id`, 2 `invalid-structural-id`, 3 `duplicate-id`, 4 `invalid-segment-or-tag`, 5 `unknown-dependency`, 6 `unknown-text-target`, 7 `unknown-ts-reference`, 8 `invalid-argument`, 9 `cycle`, 10 `stale-output`, 11 `cross-module-text`, 12 `policy-violation`, 13 `journal-error`, 14 `configuration-error`, 15 `invalid-import`, 16 `invalid-construct`, 17 `invalid-prop`, 18 `unsupported-node-usage`, 19 `invalid-source-path`, 20 `unparseable-source`, 21 `corrupt-session`, 22 `symlink-write-path`, 23 `unreadable-record`. Stable codes cover exactly these conditions and the refusal reasons below, and no more: a plain usage error (12.0) describes the invocation the consuming tool itself composed, never workspace content to render inline, and carries no stable code — while still arriving as the JSON error document of 12.0 whenever JSON output is in effect — and review-operation refusals (10.7) likewise carry none. Every condition that locates in source carries, for each offending construct, the containing file and a source range (1.7) at the precision the condition allows. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders in place and no representative is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling recording a participating dependency edge, or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call (14.11) — is identity data on the finding, not a further range. A reference spelling that records no occurrence (5.7, 11.2) is located here: for a spelling of the MDX embedding form, its finding's range is the full braced container, opening brace through closing brace — the span its occurrence would occupy (5.7) — keeping the byte classification of 11.4 exact on imperfect files. Conditions without an in-source location — configuration, path-level, journal, session, and record conditions — carry the file or path they concern. A configuration error's concerned path is reported in the anchoring form of 11.6, identified relative to the invocation working directory: where a configuration file is concerned — the file the upward search found, or the path `--config` names — it is that file; for missing configuration with no `--config` given, it is the directory the failed upward search started from, the invocation working directory, spelled `.` (11.6). The JSON report form presents code, locations, and concerned path for every finding — the finding form of 12.7 — all conditions reported together, with the same information as the human report (12.0). From 1df1cf6a539a692d953f61b1bf86931b779d6ddb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 23:04:20 +0000 Subject: [PATCH 024/226] sdg(phase-4): pin policy-violation finding form and invalid-workspace refusal scope (iter 7) Applied: - I1: condition 12 carries no in-source locations and no concerned path (no file's finding per 11.2, so it accompanies no 11.3-11.5 answer, closing 7.5's check-only routing structurally); identities pinned as rule name, edge source, kind token, target; 14's cardinality paragraph and 12.7's identities description widened to match. - I2: the invalid-workspace refusal precedes the operation-specific validation, which is defined and evaluated only over a workspace passing build's validations - the refusal reports the workspace's findings alone, never mixed with refusal reasons (6.4, 14). - O2: 12.2 restated build validations under the build-validations clause so the "additionally" list holds only check-only verifications. - O3: preview `files` entries keyed by current pre-operation path, the relocated file's entry included; created target file by its new path. - O4: 13.3's recorded derived-file paths scoped to generated modules, companions, and emitted Markdown - graph data records no paths, its layout staying unenumerated. Rejected: - O1 (modularization): deferred deliberately - candidate components are bound to the core by dense numbered cross-references (5.4-5.6, 12.0, 12.7, 14) whose contracts could not move to modules without the restatement module rules forbid; the SHOULD is weighed, not dropped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/SPEC.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/specs/SPEC.md b/specs/SPEC.md index 32b5ac1..0135908 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -305,7 +305,7 @@ When a command takes a baseline git ref, the baseline graph is reconstructed fro xspec rename [--preview] ``` -Renames a requirement ID, rewrites descendant IDs by prefix replacement, rewrites every reference to the affected identities across all configured spec and code sources (`id` attributes, `d` references, `text(...)` references, TypeScript markers), and appends the mapping to the journal. Rewrites are minimal in-place edits, preserving each reference's quote style and access form (2.4); where a form cannot be kept — a chain segment whose new name is not a valid TypeScript identifier, or a reference converted between local and imported form (6.5) — the rewritten part uses dot access for segments that are valid TypeScript identifiers, double-quoted computed access for segments that are not, and double-quoted string literals. Type-level TypeScript references record no edges (4.5) and are not rewritten: a rename or move can leave them naming vacated identities — a consumer type error outside xspec's validations — while the workspace stays valid. Validation MUST confirm: the new ID is valid; it differs from the old ID and collides with no existing ID; structural parent rules remain satisfied; all rewritten references resolve. A `` or old ID that does not exist is a usage error (12.0); every other validation failure refuses the rename (exit 1), each distinct refusal reason carrying its stable code and location (14). With `--preview`, the operation is planned and reported but performed on nothing (6.6). Rename MUST also refuse (exit 1), before modifying anything, when the current workspace fails the validations of `xspec build` (12.1), so the operation only ever rewrites a valid workspace; the usage-error argument checks precede this refusal (12.0). A successful rename finishes by regenerating derived files exactly as `xspec build` does (12.1) — which cannot fail, per the precondition — so generated modules, Markdown output, and graph data match the rewritten sources and no stale output (14.10) remains. +Renames a requirement ID, rewrites descendant IDs by prefix replacement, rewrites every reference to the affected identities across all configured spec and code sources (`id` attributes, `d` references, `text(...)` references, TypeScript markers), and appends the mapping to the journal. Rewrites are minimal in-place edits, preserving each reference's quote style and access form (2.4); where a form cannot be kept — a chain segment whose new name is not a valid TypeScript identifier, or a reference converted between local and imported form (6.5) — the rewritten part uses dot access for segments that are valid TypeScript identifiers, double-quoted computed access for segments that are not, and double-quoted string literals. Type-level TypeScript references record no edges (4.5) and are not rewritten: a rename or move can leave them naming vacated identities — a consumer type error outside xspec's validations — while the workspace stays valid. Validation MUST confirm: the new ID is valid; it differs from the old ID and collides with no existing ID; structural parent rules remain satisfied; all rewritten references resolve. A `` or old ID that does not exist is a usage error (12.0); every other validation failure refuses the rename (exit 1), each distinct refusal reason carrying its stable code and location (14). With `--preview`, the operation is planned and reported but performed on nothing (6.6). Rename MUST also refuse (exit 1), before modifying anything, when the current workspace fails the validations of `xspec build` (12.1), so the operation only ever rewrites a valid workspace; the usage-error argument checks precede this refusal (12.0), and it precedes the operation-specific validation above, which is defined — and evaluated — only over a workspace passing `build`'s validations: an invalid-workspace refusal reports the workspace's findings alone (14), no refusal reason evaluated or reported beside them. A successful rename finishes by regenerating derived files exactly as `xspec build` does (12.1) — which cannot fail, per the precondition — so generated modules, Markdown output, and graph data match the rewritten sources and no stale output (14.10) remains. ### 6.5 Move @@ -659,7 +659,7 @@ Parses configured sources; validates section structure, IDs, tags, and reference ### 12.2 `xspec check` -Performs all build validations without accepting stale outputs, and additionally verifies: generated files are content-identical to what the current sources and configuration generate, no recorded derived file remains at a path no longer generated, and the recorded generation state is readable as a record (14.10, 14.23); all dependency and text references resolve and are static; all TypeScript spec references resolve; no dependency cycles and no spec import cycles exist; the journal is well-formed and replayable with no conflicting mappings; no policy violations exist; review sessions are not internally corrupt. Exits 1 on any finding. Configuration validity is enforced at load by every command (14.14) and is a usage error, not a `check` finding. +Performs all build validations (12.1, 14) — unresolved and non-static references, dependency and spec import cycles, and journal errors included — without accepting stale outputs, and additionally verifies what `build` does not: generated files are content-identical to what the current sources and configuration generate, no recorded derived file remains at a path no longer generated, and the recorded generation state is readable as a record (14.10, 14.23); no policy violations exist (14.12); review sessions are not internally corrupt (14.21). Exits 1 on any finding. Configuration validity is enforced at load by every command (14.14) and is a usage error, not a `check` finding. ### 12.3 `xspec ids` @@ -692,7 +692,7 @@ Value forms: * A source range (1.7) is `{"start": …, "end": …}`, both non-negative integers. * A path — workspace-relative, or in the anchoring form of 11.6 — is a string where its bytes are valid UTF-8, and otherwise the marked byte form of 12.0: `{"bytes": "…"}`, the path's exact bytes as lowercase hexadecimal, two digits per byte — an object, equal to no path string. Identities are strings (1.5); no identity carries a non-UTF-8 path (12.0). * A datum reported explicitly unavailable (11.2, 11.6, 6.6) is `{"unavailable": true}`. A plain value, `null`, and `{"unavailable": true}` are the three observable states of a datum (11.4). -* A finding (14) is `{"code", "message", "locations", "path", "identities"}`: the stable code, the token string 14 assigns (`null` where 14 assigns none); the human-readable description; one `{"file", "range"}` per offending construct — ordered by file path bytes, then range start, then range end — empty for conditions without in-source locations; the concerned file or path (`null` for located conditions); and the identities or paths the condition names as context (14), empty where none. Wherever a document carries findings they form the array member `"findings"`, ordered by code — the numbered conditions in numeric order, then the refusal reasons in the order 14 lists them, then code-less findings — then by locations, compared element-wise — each element by file path bytes, then range start, then range end; a sequence that is a proper prefix of another sorts first — then by concerned path (`null` before any path), then by identities, compared element-wise under the same prefix rule (string elements by bytes, 12.0), then by message; findings identical in every member collapse to one, so the order is total. A report whose defined content is findings alone — `build` and `check` reports, the findings of refusing reads (13.3), refused operations (6.4, 6.5, 10.7) — is `{"findings": […]}`; a refused preview instead keeps the preview document form, its `mapping`, `files`, and `delta` `null` (6.6). +* A finding (14) is `{"code", "message", "locations", "path", "identities"}`: the stable code, the token string 14 assigns (`null` where 14 assigns none); the human-readable description; one `{"file", "range"}` per offending construct — ordered by file path bytes, then range start, then range end — empty for conditions without in-source locations; the concerned file or path (`null` for located conditions); and the identities or other context strings the condition names (14), empty where none. Wherever a document carries findings they form the array member `"findings"`, ordered by code — the numbered conditions in numeric order, then the refusal reasons in the order 14 lists them, then code-less findings — then by locations, compared element-wise — each element by file path bytes, then range start, then range end; a sequence that is a proper prefix of another sorts first — then by concerned path (`null` before any path), then by identities, compared element-wise under the same prefix rule (string elements by bytes, 12.0), then by message; findings identical in every member collapse to one, so the order is total. A report whose defined content is findings alone — `build` and `check` reports, the findings of refusing reads (13.3), refused operations (6.4, 6.5, 10.7) — is `{"findings": […]}`; a refused preview instead keeps the preview document form, its `mapping`, `files`, and `delta` `null` (6.6). * A reference occurrence record (5.7) is `{"file", "range", "kind", "source", "target"}`: the referencing file; the occurrence's own range; its edge kind, `"depends"`, `"embeds"`, or `"references"` (5.2); its source graph node, `{"identity", "range"}` or unavailable (11.2); and its resolved target's identity. Document forms — each a single JSON document whose top level is an object; every one below except `version`'s and the exit-2 error document carries the consulted domain's findings (11.2, 11.6, 6.6) under `"findings"`: @@ -701,7 +701,7 @@ Document forms — each a single JSON document whose top level is an object; eve * `view` (11.4): `{"findings", "views"}` — one `{"file", "root", "imports", "occurrences", "comments"}` per parseable requested file, ordered by file path bytes; an unparseable requested file contributes no entry (11.4). Each node of the section tree is `{"identity", "range", "opening", "closing", "attributes", "tags", "coverage", "children"}` plus, exactly when `--text` is given, `"ownText"` and `"subtreeText"`: `identity`, `tags`, `coverage`, and the text members are each a plain value, `null` where 11.4 defines structural absence, or unavailable (11.2); `opening` and `closing` are the tag ranges of 11.4, `null` where none exists; `attributes` is one `{"name", "range", "text"}` per spelled attribute in tag order, `name` `null` for a spread attribute; `children` holds the child nodes in document order, and `root` the root node. `imports` is one `{"range", "name", "target"}` per import declaration in document order, `name` `null` where the declaration binds no default binding (11.4), `target` a path or unavailable; `occurrences` holds the file's occurrence records and `comments` the comment ranges, each in document order. * `at` (11.5): `{"findings", "resolution"}` — `resolution` is `{"section", "occurrence"}` or unavailable (11.5): `section` is `{"identity", "range"}` of the innermost enclosing section construct, its identity per 11.2; `occurrence` is the containing occurrence's record, `null` when the offset lies within none. * `inventory` (11.6): `{"findings", "root", "config", "configuration", "sources", "derived", "recorded", "graphData", "journal", "sessions"}`. `root` and `config` are the anchoring paths (11.6). `configuration` is `{"specs", "code", "markdown", "coverage", "policy"}`, the resolved view (11.6) with every default and inferred kind explicit: `specs` and `code` one `{"name", "globs"}` per group; `markdown` `{"emit", "outDir"}`, `outDir` `null` where unset and an absent `markdown` key resolving to `{"emit": false, "outDir": null}` (7.3); `coverage` one `{"name", "target", "targetTags", "targets", "boundary", "boundaryKind", "mode", "edgeKinds"}` per profile, `targetTags` `null` where absent; `policy` one `{"name", "type", "from", "to", "kinds"}` per rule, each selector `{"group", "kind"}`, `{"files"}`, or `{"tags"}` (7.5). `sources` is one `{"path", "groups"}` per discovered file, `groups` one `{"name", "kind"}` each; `derived` one `{"source", "module", "markdown"}` per discovered spec source — `module` and `markdown` `null` for a spec-group file without the `.mdx` extension (11.6, 13.1), `markdown` `null` also while emission is disabled (7.3); `recorded` the record-supplied datum (11.6) — the recorded derived-file paths in byte order — or unavailable (14.23); `graphData` the graph-data area's path; `journal` `{"path", "occupied"}`, `occupied` a boolean; `sessions` the session file paths. List order follows 11.6. -* `rename`/`move` previews (6.6): `{"findings", "mapping", "files", "delta"}`; on refusal `mapping`, `files`, and `delta` are `null`. `mapping` is one `{"from", "to"}` per mapped identity, ordered by `from` bytes. `files` is one `{"file", "edits"}` per file the operation would rewrite, relocate, or create, ordered by file path bytes; each edit is `{"class", "range"}`, edits ordered by range start, then range end, then class-name bytes; `class` names, in order, the classes of 6.6: `"reference-rewrite"`, `"id-rewrite"`, `"import-specifier-rewrite"`, `"import-addition"`, `"import-removal"`, `"origin-deletion"`, `"target-insertion"`, `"target-parent-rewrite"`, `"file-relocation"`, or `"file-creation"`. `delta` is `{"generated", "removed"}`, each direction's paths in byte order, or unavailable as one datum (6.6). +* `rename`/`move` previews (6.6): `{"findings", "mapping", "files", "delta"}`; on refusal `mapping`, `files`, and `delta` are `null`. `mapping` is one `{"from", "to"}` per mapped identity, ordered by `from` bytes. `files` is one `{"file", "edits"}` per file the operation would rewrite, relocate, or create, ordered by file path bytes — `file` the file's current, pre-operation path, the relocated file's entry included, and for target-file creation the path the creation would occupy (6.6); each edit is `{"class", "range"}`, edits ordered by range start, then range end, then class-name bytes; `class` names, in order, the classes of 6.6: `"reference-rewrite"`, `"id-rewrite"`, `"import-specifier-rewrite"`, `"import-addition"`, `"import-removal"`, `"origin-deletion"`, `"target-insertion"`, `"target-parent-rewrite"`, `"file-relocation"`, or `"file-creation"`. `delta` is `{"generated", "removed"}`, each direction's paths in byte order, or unavailable as one datum (6.6). * `version` (12.6): `{"product", "interface"}` — the product version and the machine-interface version, both strings; the reported machine-interface value is the string form of 12.6's stated value, `"1"`. * The exit-2 error document (12.0): `{"error": …}` holding one finding form: for a configuration error, its stable code and concerned path (14); for a plain usage error, `code` and `path` `null`. One invocation reports one error: the document holds a single finding however many defects are present — a configuration file with several distinct defects is one condition-14 finding, its message deterministic (12.0) but otherwise unpinned. @@ -717,7 +717,7 @@ Document forms — each a single JSON document whose top level is an object; eve ### 13.3 Graph data -xspec maintains graph data under `.xspec/`, containing requirement nodes, code locations, edges by kind, reference occurrences (5.7), source ranges (1.7), all four hashes, coverage attributes, tags, and the paths of the derived files most recently generated (13.4). Graph data serves `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at`. Read results never come from stale data. On a workspace that passes the validations of `xspec build` (12.1), when graph data is missing or does not match the current sources and configuration (a comparison from which the recorded derived-file paths are excluded — refresh leaves them unchanged, so a lagging record alone is never staleness), `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at` refresh it — writing exactly what `xspec build` would write, except that no TypeScript or Markdown is generated or removed and the recorded derived-file paths are left unchanged — before answering; running only where `build` would succeed, refresh, like the finishing regeneration of 6.4, cannot fail. The record is left unchanged in every state: recorded state that exists but cannot be read as a record (14.23) is neither read, repaired, nor replaced by a refresh — these reads never consult the record and report no finding for it — so the state persists, met by the surfaces that consult the record (11.6, 6.6) and reported as staleness by `check` (14.10), until a successful `build` (12.1) or the finishing regeneration of `rename`/`move` (6.4, 6.5) replaces the record. When the current workspace fails the validations of `xspec build` — source validation errors, journal errors (14.13), and refused writes (14.22) alike: the findings a `build` would now report — `ids`, `show`, `coverage`, `impact`, `review`, and `query` report exactly those findings and exit 1 without answering, while `occurrences`, `view`, and `at` answer from the current sources per 11.2, which states the findings accompanying their answers; in either case nothing is modified: every derived file and all graph data remain byte-for-byte as they were, as after a failed `build` (12.1). `check` never refreshes; it reports staleness instead (14.10). `inventory` neither refreshes nor writes (11.6), and a preview writes nothing (6.6). Graph data is byte-deterministic for a given workspace (12.0); its content is otherwise opaque — graph data's observable contract is its location under `.xspec/`, its classification as a derived file (13.4), and the refresh, failure, and staleness behaviors above and in 14.10. +xspec maintains graph data under `.xspec/`, containing requirement nodes, code locations, edges by kind, reference occurrences (5.7), source ranges (1.7), all four hashes, coverage attributes, tags, and the paths of the derived files most recently generated (13.4) — the generated modules with their companions (13.1) and the emitted Markdown (13.2); graph data records no paths of its own, its layout staying deliberately unenumerated (11.6). Graph data serves `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at`. Read results never come from stale data. On a workspace that passes the validations of `xspec build` (12.1), when graph data is missing or does not match the current sources and configuration (a comparison from which the recorded derived-file paths are excluded — refresh leaves them unchanged, so a lagging record alone is never staleness), `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at` refresh it — writing exactly what `xspec build` would write, except that no TypeScript or Markdown is generated or removed and the recorded derived-file paths are left unchanged — before answering; running only where `build` would succeed, refresh, like the finishing regeneration of 6.4, cannot fail. The record is left unchanged in every state: recorded state that exists but cannot be read as a record (14.23) is neither read, repaired, nor replaced by a refresh — these reads never consult the record and report no finding for it — so the state persists, met by the surfaces that consult the record (11.6, 6.6) and reported as staleness by `check` (14.10), until a successful `build` (12.1) or the finishing regeneration of `rename`/`move` (6.4, 6.5) replaces the record. When the current workspace fails the validations of `xspec build` — source validation errors, journal errors (14.13), and refused writes (14.22) alike: the findings a `build` would now report — `ids`, `show`, `coverage`, `impact`, `review`, and `query` report exactly those findings and exit 1 without answering, while `occurrences`, `view`, and `at` answer from the current sources per 11.2, which states the findings accompanying their answers; in either case nothing is modified: every derived file and all graph data remain byte-for-byte as they were, as after a failed `build` (12.1). `check` never refreshes; it reports staleness instead (14.10). `inventory` neither refreshes nor writes (11.6), and a preview writes nothing (6.6). Graph data is byte-deterministic for a given workspace (12.0); its content is otherwise opaque — graph data's observable contract is its location under `.xspec/`, its classification as a derived file (13.4), and the refresh, failure, and staleness behaviors above and in 14.10. ### 13.4 Derived and durable files @@ -740,7 +740,7 @@ All state is workspace-local; instances operating on different workspaces MUST N Every reported condition carries a stable machine-readable code identifying which numbered condition it is — a code's value is its token as listed, a string (12.7), and a numeral below is the condition's ordinal, ordering findings (12.7), no part of the value: 1 `missing-id`, 2 `invalid-structural-id`, 3 `duplicate-id`, 4 `invalid-segment-or-tag`, 5 `unknown-dependency`, 6 `unknown-text-target`, 7 `unknown-ts-reference`, 8 `invalid-argument`, 9 `cycle`, 10 `stale-output`, 11 `cross-module-text`, 12 `policy-violation`, 13 `journal-error`, 14 `configuration-error`, 15 `invalid-import`, 16 `invalid-construct`, 17 `invalid-prop`, 18 `unsupported-node-usage`, 19 `invalid-source-path`, 20 `unparseable-source`, 21 `corrupt-session`, 22 `symlink-write-path`, 23 `unreadable-record`. Stable codes cover exactly these conditions and the refusal reasons below, and no more: a plain usage error (12.0) describes the invocation the consuming tool itself composed, never workspace content to render inline, and carries no stable code — while still arriving as the JSON error document of 12.0 whenever JSON output is in effect — and review-operation refusals (10.7) likewise carry none. -Every condition that locates in source carries, for each offending construct, the containing file and a source range (1.7) at the precision the condition allows. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders in place and no representative is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling recording a participating dependency edge, or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call (14.11) — is identity data on the finding, not a further range. A reference spelling that records no occurrence (5.7, 11.2) is located here: for a spelling of the MDX embedding form, its finding's range is the full braced container, opening brace through closing brace — the span its occurrence would occupy (5.7) — keeping the byte classification of 11.4 exact on imperfect files. Conditions without an in-source location — configuration, path-level, journal, session, and record conditions — carry the file or path they concern. A configuration error's concerned path is reported in the anchoring form of 11.6, identified relative to the invocation working directory: where a configuration file is concerned — the file the upward search found, or the path `--config` names — it is that file; for missing configuration with no `--config` given, it is the directory the failed upward search started from, the invocation working directory, spelled `.` (11.6). The JSON report form presents code, locations, and concerned path for every finding — the finding form of 12.7 — all conditions reported together, with the same information as the human report (12.0). +Every condition that locates in source carries, for each offending construct, the containing file and a source range (1.7) at the precision the condition allows. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders in place and no representative is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling recording a participating dependency edge, or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call (14.11) — is identity data on the finding, not a further range. A reference spelling that records no occurrence (5.7, 11.2) is located here: for a spelling of the MDX embedding form, its finding's range is the full braced container, opening brace through closing brace — the span its occurrence would occupy (5.7) — keeping the byte classification of 11.4 exact on imperfect files. Conditions without an in-source location — configuration, path-level, journal, session, and record conditions — carry the file or path they concern; a policy violation, constraining an edge rather than any file's content, carries neither location nor concerned path — its context identities alone (12). A configuration error's concerned path is reported in the anchoring form of 11.6, identified relative to the invocation working directory: where a configuration file is concerned — the file the upward search found, or the path `--config` names — it is that file; for missing configuration with no `--config` given, it is the directory the failed upward search started from, the invocation working directory, spelled `.` (11.6). The JSON report form presents code, locations, and concerned path for every finding — the finding form of 12.7 — all conditions reported together, with the same information as the human report (12.0). The defined error conditions — also the findings that accompany answers over a consulted domain (11.2, 11.6, 6.6) — each reported by `build` and `check` unless its entry states otherwise: @@ -755,7 +755,7 @@ The defined error conditions — also the findings that accompany answers over a 9. Cycle: a dependency cycle (with the full path) or a spec import cycle. 10. Stale generated output: a derived file whose content does not match what the current sources and configuration generate, or a recorded derived file (13.3) remaining at a path the current sources and configuration no longer generate — the error names the file and instructs rebuilding — or recorded generation state that exists but cannot be read as a record (14.23): one finding, instructing rebuilding, whose concerned path is the graph-data area (11.6) — the record's layout is unenumerated (13.3), so no path inside the area is named; while that state holds, this form alone reports the graph data, and the recorded-file form above, consulting no readable record, is undetectable (14). Reported by `check` only: `build` cannot observe staleness because it regenerates every derived file (12.1). 11. Cross-module text call: a node passed to the `text` export of a spec module other than its own; additionally a TypeScript type error and a runtime throw per 4.4. -12. Policy violation: rule name plus offending edge. Reported by `check` only: policy constrains the workspace graph, not source validity, and `build` regenerates output regardless of policy findings (7.5, 12.1). +12. Policy violation: one finding per violation (7.5) — per rule and offending edge. The offending entity is a graph edge, not a spelling: the finding carries no in-source locations and concerns no path — `locations` empty, `path` `null` (12.7) — so it is no file's finding (11.2) and accompanies no answer of 11.3–11.5. Its `identities` are, in order, the violated rule's name and the edge's source identity, kind token (12.7), and target identity. Reported by `check` only: policy constrains the workspace graph, not source validity, and `build` regenerates output regardless of policy findings (7.5, 12.1). 13. Journal error: malformed, conflicting, or unreplayable entries, naming the lines; a journal path occupied by anything other than a plain file (13.4). 14. Configuration error: missing or invalid configuration — a configuration file that is not well-formed TypeScript or not in the declarative form of 7; missing required fields, unknown keys (7), or invalid profile, rule, or group shapes; group names referenced by profiles, rules, or selectors that are unknown or not of the kind the reference requires (7.4, 7.5); ambiguous kinds (7.4, 7.5); an empty `edgeKinds`, `targetTags`, rule `kinds`, or selector `tags` list; a capture violation (7.5); a glob or `markdown.outDir` resolving outside the workspace root (7, 7.3); a file matched by both a spec and a code group. Reported by every command that loads the configuration — every command but `version` (12.6), which loads none — when it loads the configuration and discovers sources, as a usage error (12.0), not a finding. 15. Invalid import: in an xspec source file, an import that is not a single default binding, does not designate an xspec source file belonging to a configured spec group, or binds the identifier `S`, `Spec`, or `text` (2.1); in a TypeScript file, a `.xspec` import that does not designate such a source, a spec-module binding other than the default and `text` exports, a dynamic `import()` whose static specifier ends in `.xspec`, an export declaration or an `import X = require(…)` declaration whose specifier ends in `.xspec` (4), or an import or export declaration, `import X = require(…)`, or dynamic `import()` whose relative specifier designates a derived-file path other than through a spec module import's `.xspec` specifier (4, 13.4); in either kind of file, an import binding an identifier already bound by another import in the same file, when either import is a spec module import. @@ -768,7 +768,7 @@ The defined error conditions — also the findings that accompany answers over a 22. Symbolic link in a write path: a workspace-relative directory component of a path xspec writes is a symbolic link (13.4). A command refuses the write and reports it before modifying anything; `check` reports it without writing. The concerned path is the offending symbolic-link component's workspace-relative path — one finding per distinct offending component, whatever write paths it refuses. A symbolic link at a derived file's own path is not an error — writing replaces the link (13.4). 23. Unreadable recorded state: recorded generation state (13.3) that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise. Reported by `inventory` (11.6) and by `rename`/`move` previews (6.6), the surfaces that read the record without refreshing it: the record-supplied datum is reported explicitly unavailable, the rest of the answer is emitted in full, and the invocation exits 1. The concerned path is the graph-data area (11.6): the record's layout is deliberately unenumerated (13.3), so no path inside the area is named. Not reported by `build`, whose rebuild replaces the record (12.1, 13.4), nor by the refreshing reads of 13.3, which leave the record — unreadable state included — unchanged without consulting it (13.3); `check` reports the state as staleness (14.10). -Each distinct reason `rename` and `move` refuse (6.4, 6.5) — exactly what a refused preview (6.6) reports — carries a stable code and, under the location-cardinality rule above, the file, source range, or identity it concerns, so a refusal renders as precisely as a finding; refusals are findings in the exit-code partition (12.0), and the JSON report form above carries them. The reasons and their codes: `refused-invalid-id` — the new ID, or an ID the prefix replacement produces, violates 1.3/1.4, concerning that identity; `refused-identity-unchanged` — the new identity equals the old (6.4; the exact self-move of 6.5), concerning it; `refused-id-collision` — the new ID collides with an existing ID (6.4) or with an ID remaining in the target file after the removal (6.5), locating every colliding bearer; `refused-structural-parent` — structural parent rules would not remain satisfied, concerning the violated identity; `refused-unresolvable-reference` — a rewritten reference would not resolve, locating each such reference spelling; `refused-cycle` — the move would create a spec import cycle or a dependency cycle (6.5), locating the would-be cycle's full path per the cardinality rule; `refused-destination-exists` — the file form's destination file already exists (6.5), concerning that path; `refused-missing-target-parent` — the section form's target parent is missing or lies within the moved subtree (6.5), concerning the target-parent identity; `refused-invalid-destination` — the destination file path would not be a valid discovered spec source (6.5), concerning that path. The invalid-workspace refusal (6.4) reports the workspace's findings themselves, each under its own numbered condition and stable code. +Each distinct reason `rename` and `move` refuse (6.4, 6.5) — exactly what a refused preview (6.6) reports — carries a stable code and, under the location-cardinality rule above, the file, source range, or identity it concerns, so a refusal renders as precisely as a finding; refusals are findings in the exit-code partition (12.0), and the JSON report form above carries them. The reasons and their codes: `refused-invalid-id` — the new ID, or an ID the prefix replacement produces, violates 1.3/1.4, concerning that identity; `refused-identity-unchanged` — the new identity equals the old (6.4; the exact self-move of 6.5), concerning it; `refused-id-collision` — the new ID collides with an existing ID (6.4) or with an ID remaining in the target file after the removal (6.5), locating every colliding bearer; `refused-structural-parent` — structural parent rules would not remain satisfied, concerning the violated identity; `refused-unresolvable-reference` — a rewritten reference would not resolve, locating each such reference spelling; `refused-cycle` — the move would create a spec import cycle or a dependency cycle (6.5), locating the would-be cycle's full path per the cardinality rule; `refused-destination-exists` — the file form's destination file already exists (6.5), concerning that path; `refused-missing-target-parent` — the section form's target parent is missing or lies within the moved subtree (6.5), concerning the target-parent identity; `refused-invalid-destination` — the destination file path would not be a valid discovered spec source (6.5), concerning that path. The invalid-workspace refusal (6.4) reports the workspace's findings themselves, each under its own numbered condition and stable code — and those alone: the reasons above are defined and evaluated only over a workspace passing `build`'s validations (6.4, 6.5), so no report mixes refusal reasons with numbered conditions. ## 15. Example From b2094c4a9ddabee9ee66da994c8d044cd3644562 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:24:43 +0000 Subject: [PATCH 025/226] sdg(phase-4): pin old-ID existence datum, disjoint refusal scopes, destination occupancy (iter 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: - I1: rename/move old-ID existence judged over spelled identities (11.2), parse-local — undefined-identity bearers still establish existence, no-identity spellings establish none, unparseable origin stays masked (6.4, 6.5). - I2: refused-invalid-id scoped to intrinsic ID form (dot-path of 1.4-valid segments); positional conformance (1.3) is refused-structural-parent's alone, evaluated only over intrinsically valid IDs — no identity reports under both (14). - I3: refused-destination-exists triggers on any occupant of the file-form destination path, whatever kind of filesystem object, symbolic links included (6.5, 14). - O2: a successful non-preview rename/move reports the applied mapping — the preview's mapping information (6.4, 6.5). - O3: finding-form identities content contractual exactly where 14 states it for the condition or reason, otherwise informational — deterministic, composition unpinned (12.7). Not applied: - O1 (modularization): standing deferral upheld. Extracting Review (10) or the query/JSON complex (11, 12.7) requires full interface restatement in SPEC.md per PROCESS module rules; the restructuring risk mid-refinement outweighs the SHOULD at current size. The deferral stays a recorded decision, not an omission. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/SPEC.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/specs/SPEC.md b/specs/SPEC.md index 0135908..339ca38 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -305,7 +305,7 @@ When a command takes a baseline git ref, the baseline graph is reconstructed fro xspec rename [--preview] ``` -Renames a requirement ID, rewrites descendant IDs by prefix replacement, rewrites every reference to the affected identities across all configured spec and code sources (`id` attributes, `d` references, `text(...)` references, TypeScript markers), and appends the mapping to the journal. Rewrites are minimal in-place edits, preserving each reference's quote style and access form (2.4); where a form cannot be kept — a chain segment whose new name is not a valid TypeScript identifier, or a reference converted between local and imported form (6.5) — the rewritten part uses dot access for segments that are valid TypeScript identifiers, double-quoted computed access for segments that are not, and double-quoted string literals. Type-level TypeScript references record no edges (4.5) and are not rewritten: a rename or move can leave them naming vacated identities — a consumer type error outside xspec's validations — while the workspace stays valid. Validation MUST confirm: the new ID is valid; it differs from the old ID and collides with no existing ID; structural parent rules remain satisfied; all rewritten references resolve. A `` or old ID that does not exist is a usage error (12.0); every other validation failure refuses the rename (exit 1), each distinct refusal reason carrying its stable code and location (14). With `--preview`, the operation is planned and reported but performed on nothing (6.6). Rename MUST also refuse (exit 1), before modifying anything, when the current workspace fails the validations of `xspec build` (12.1), so the operation only ever rewrites a valid workspace; the usage-error argument checks precede this refusal (12.0), and it precedes the operation-specific validation above, which is defined — and evaluated — only over a workspace passing `build`'s validations: an invalid-workspace refusal reports the workspace's findings alone (14), no refusal reason evaluated or reported beside them. A successful rename finishes by regenerating derived files exactly as `xspec build` does (12.1) — which cannot fail, per the precondition — so generated modules, Markdown output, and graph data match the rewritten sources and no stale output (14.10) remains. +Renames a requirement ID, rewrites descendant IDs by prefix replacement, rewrites every reference to the affected identities across all configured spec and code sources (`id` attributes, `d` references, `text(...)` references, TypeScript markers), and appends the mapping to the journal. Rewrites are minimal in-place edits, preserving each reference's quote style and access form (2.4); where a form cannot be kept — a chain segment whose new name is not a valid TypeScript identifier, or a reference converted between local and imported form (6.5) — the rewritten part uses dot access for segments that are valid TypeScript identifiers, double-quoted computed access for segments that are not, and double-quoted string literals. Type-level TypeScript references record no edges (4.5) and are not rewritten: a rename or move can leave them naming vacated identities — a consumer type error outside xspec's validations — while the workspace stays valid. Validation MUST confirm: the new ID is valid; it differs from the old ID and collides with no existing ID; structural parent rules remain satisfied; all rewritten references resolve. A `` or old ID that does not exist is a usage error (12.0); the old ID's existence is parse-local, judged over spelled identities (11.2): it exists exactly when a section of the origin file spells it — a bearer whose node identity is undefined (duplicate spellings; an undefined ancestor chain, 11.2) still establishes existence, a section spelling no identity (its `id` attribute repeated or in invalid value form, 11.2) establishes none, and an unparseable origin file is masked (12.0). Every other validation failure refuses the rename (exit 1), each distinct refusal reason carrying its stable code and location (14). With `--preview`, the operation is planned and reported but performed on nothing (6.6). Rename MUST also refuse (exit 1), before modifying anything, when the current workspace fails the validations of `xspec build` (12.1), so the operation only ever rewrites a valid workspace; the usage-error argument checks precede this refusal (12.0), and it precedes the operation-specific validation above, which is defined — and evaluated — only over a workspace passing `build`'s validations: an invalid-workspace refusal reports the workspace's findings alone (14), no refusal reason evaluated or reported beside them. A successful rename finishes by regenerating derived files exactly as `xspec build` does (12.1) — which cannot fail, per the precondition — so generated modules, Markdown output, and graph data match the rewritten sources and no stale output (14.10) remains. A successful rename's report is the applied mapping: the complete identity mapping the operation journaled — the information of the preview's `mapping` (6.6), carried in JSON per 12.0 (12.7). ### 6.5 Move @@ -314,9 +314,9 @@ xspec move [--preview] xspec move # # [--preview] ``` -The first form relocates an entire source file; IDs are unchanged and every node's identity changes only in its file part. Relocation also rewrites the moved file's own import specifiers, and the paths by which other files import the moved file's generated module, so all references continue to resolve. The second form extracts a section subtree: the section and its descendants are removed from the origin, inserted as the last child of the target parent (or at the end of the file for a top-level `new-id`), and re-identified by prefix replacement of `` with ``. The target file is created if absent, empty before insertion. The second form's text edits are exact: the moved text is the section construct's own characters — from the first character of its opening tag through the last character of its closing tag, or the self-closing tag's own characters for a self-closing section (1.1). At the origin it is deleted in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3). It is inserted immediately before the target parent's closing tag — at the end of the file for a top-level `new-id` — followed by a U+000A line terminator, and preceded by one when the insertion point is not at the start of a line. A self-closing target parent (1.1) is first rewritten to the paired form: its `/` and any whitespace immediately before or after the `/` are deleted, and the closing tag matching the opening tag's name (`` or ``) is appended immediately after the tag's terminating `>`; the insertion rule then applies before that closing tag. Beyond these edits, the identity and reference rewrites of this section, and the finishing regeneration, a move changes no bytes. In both forms, all references across the workspace are rewritten to resolve to the new identities, converting between local and imported forms and adding or removing spec module imports as the rewrite requires — an import is added when a rewritten reference needs a module binding its file lacks, and an existing spec module import is removed exactly when its binding had references and the rewrite leaves it with none (an import whose binding was already unreferenced stays, 2.1) — and the full mapping is appended to the journal. Import edits are exact. An import removal deletes the declaration's own characters in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3): the removal's extent is the declaration plus any such adjunct drop. An added import binds fresh identifiers colliding with no binding already in the file (2.1, 4) and is inserted as a line of its own — the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — at an offset where the file's grammar permits an import declaration; the identifier choice and the insertion offset are implementation latitude, exercised deterministically: rewritten file content is byte-deterministic for a given operation and workspace state (6.1), and in a file existing before the operation the offset is exactly the one the operation's preview reports (6.6) — an addition into a created target file is subsumed, with the rest of that file's initial content, by the preview's file-creation class (6.6), its placement pinned by byte-determinism alone. A successful move regenerates derived files as rename does (6.4). +The first form relocates an entire source file; IDs are unchanged and every node's identity changes only in its file part. Relocation also rewrites the moved file's own import specifiers, and the paths by which other files import the moved file's generated module, so all references continue to resolve. The second form extracts a section subtree: the section and its descendants are removed from the origin, inserted as the last child of the target parent (or at the end of the file for a top-level `new-id`), and re-identified by prefix replacement of `` with ``. The target file is created if absent, empty before insertion. The second form's text edits are exact: the moved text is the section construct's own characters — from the first character of its opening tag through the last character of its closing tag, or the self-closing tag's own characters for a self-closing section (1.1). At the origin it is deleted in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3). It is inserted immediately before the target parent's closing tag — at the end of the file for a top-level `new-id` — followed by a U+000A line terminator, and preceded by one when the insertion point is not at the start of a line. A self-closing target parent (1.1) is first rewritten to the paired form: its `/` and any whitespace immediately before or after the `/` are deleted, and the closing tag matching the opening tag's name (`` or ``) is appended immediately after the tag's terminating `>`; the insertion rule then applies before that closing tag. Beyond these edits, the identity and reference rewrites of this section, and the finishing regeneration, a move changes no bytes. In both forms, all references across the workspace are rewritten to resolve to the new identities, converting between local and imported forms and adding or removing spec module imports as the rewrite requires — an import is added when a rewritten reference needs a module binding its file lacks, and an existing spec module import is removed exactly when its binding had references and the rewrite leaves it with none (an import whose binding was already unreferenced stays, 2.1) — and the full mapping is appended to the journal. Import edits are exact. An import removal deletes the declaration's own characters in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3): the removal's extent is the declaration plus any such adjunct drop. An added import binds fresh identifiers colliding with no binding already in the file (2.1, 4) and is inserted as a line of its own — the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — at an offset where the file's grammar permits an import declaration; the identifier choice and the insertion offset are implementation latitude, exercised deterministically: rewritten file content is byte-deterministic for a given operation and workspace state (6.1), and in a file existing before the operation the offset is exactly the one the operation's preview reports (6.6) — an addition into a created target file is subsumed, with the rest of that file's initial content, by the preview's file-creation class (6.6), its placement pinned by byte-determinism alone. A successful move regenerates derived files, and reports its applied mapping, as rename does (6.4). -Move validation mirrors rename validation, including the valid-workspace precondition (6.4) and the usage-error classification of a nonexistent origin file or ID (12.0); the mirrored checks read in identity terms: the new ID is valid, structural parent rules remain satisfied, all rewritten references resolve, the new identity differs from the old — a cross-file section move keeping its ID is therefore valid, while the exact self-move, `#` equal to `#`, is refused and appends no journal entry — and, in the section form, `` collides with no ID remaining in the target file after the removal. Move additionally MUST refuse: a move that would create an import cycle among spec source files or a dependency cycle; a file-form move whose destination file already exists; a section-form move whose target parent — the target file's section bearing `` minus its final segment, needed whenever `` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal; and a move whose destination file path (including a target file to be created) would not be a valid discovered spec source after the move — a path belonging to no configured spec group (a move never takes a node out of the workspace), belonging to a code group as well (14.14), containing `#`, not valid UTF-8, or lacking the `.mdx` extension (14.19). These refusals keep every successful move's finishing regeneration (6.4) on a valid workspace, so it cannot fail. Each distinct refusal reason carries its stable code and location (14); with `--preview`, the operation is planned and reported but performed on nothing (6.6). +Move validation mirrors rename validation, including the valid-workspace precondition (6.4) and the usage-error classification, existence judged as in 6.4, of a nonexistent origin file or ID (12.0); the mirrored checks read in identity terms: the new ID is valid, structural parent rules remain satisfied, all rewritten references resolve, the new identity differs from the old — a cross-file section move keeping its ID is therefore valid, while the exact self-move, `#` equal to `#`, is refused and appends no journal entry — and, in the section form, `` collides with no ID remaining in the target file after the removal. Move additionally MUST refuse: a move that would create an import cycle among spec source files or a dependency cycle; a file-form move whose destination path is already occupied — by whatever kind of filesystem object, a symbolic link included; a section-form move whose target parent — the target file's section bearing `` minus its final segment, needed whenever `` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal; and a move whose destination file path (including a target file to be created) would not be a valid discovered spec source after the move — a path belonging to no configured spec group (a move never takes a node out of the workspace), belonging to a code group as well (14.14), containing `#`, not valid UTF-8, or lacking the `.mdx` extension (14.19). These refusals keep every successful move's finishing regeneration (6.4) on a valid workspace, so it cannot fail. Each distinct refusal reason carries its stable code and location (14); with `--preview`, the operation is planned and reported but performed on nothing (6.6). ### 6.6 Previews @@ -692,7 +692,7 @@ Value forms: * A source range (1.7) is `{"start": …, "end": …}`, both non-negative integers. * A path — workspace-relative, or in the anchoring form of 11.6 — is a string where its bytes are valid UTF-8, and otherwise the marked byte form of 12.0: `{"bytes": "…"}`, the path's exact bytes as lowercase hexadecimal, two digits per byte — an object, equal to no path string. Identities are strings (1.5); no identity carries a non-UTF-8 path (12.0). * A datum reported explicitly unavailable (11.2, 11.6, 6.6) is `{"unavailable": true}`. A plain value, `null`, and `{"unavailable": true}` are the three observable states of a datum (11.4). -* A finding (14) is `{"code", "message", "locations", "path", "identities"}`: the stable code, the token string 14 assigns (`null` where 14 assigns none); the human-readable description; one `{"file", "range"}` per offending construct — ordered by file path bytes, then range start, then range end — empty for conditions without in-source locations; the concerned file or path (`null` for located conditions); and the identities or other context strings the condition names (14), empty where none. Wherever a document carries findings they form the array member `"findings"`, ordered by code — the numbered conditions in numeric order, then the refusal reasons in the order 14 lists them, then code-less findings — then by locations, compared element-wise — each element by file path bytes, then range start, then range end; a sequence that is a proper prefix of another sorts first — then by concerned path (`null` before any path), then by identities, compared element-wise under the same prefix rule (string elements by bytes, 12.0), then by message; findings identical in every member collapse to one, so the order is total. A report whose defined content is findings alone — `build` and `check` reports, the findings of refusing reads (13.3), refused operations (6.4, 6.5, 10.7) — is `{"findings": […]}`; a refused preview instead keeps the preview document form, its `mapping`, `files`, and `delta` `null` (6.6). +* A finding (14) is `{"code", "message", "locations", "path", "identities"}`: the stable code, the token string 14 assigns (`null` where 14 assigns none); the human-readable description; one `{"file", "range"}` per offending construct — ordered by file path bytes, then range start, then range end — empty for conditions without in-source locations; the concerned file or path (`null` for located conditions); and the identities or other context strings the condition names (14), empty where none — content contractual exactly where 14 states it for the condition or reason (14.12's enumeration, a condition's named context entity such as 14.11's foreign module, a refusal reason's concerned identity), otherwise informational: deterministic (12.0), its composition unpinned. Wherever a document carries findings they form the array member `"findings"`, ordered by code — the numbered conditions in numeric order, then the refusal reasons in the order 14 lists them, then code-less findings — then by locations, compared element-wise — each element by file path bytes, then range start, then range end; a sequence that is a proper prefix of another sorts first — then by concerned path (`null` before any path), then by identities, compared element-wise under the same prefix rule (string elements by bytes, 12.0), then by message; findings identical in every member collapse to one, so the order is total. A report whose defined content is findings alone — `build` and `check` reports, the findings of refusing reads (13.3), refused operations (6.4, 6.5, 10.7) — is `{"findings": […]}`; a refused preview instead keeps the preview document form, its `mapping`, `files`, and `delta` `null` (6.6). * A reference occurrence record (5.7) is `{"file", "range", "kind", "source", "target"}`: the referencing file; the occurrence's own range; its edge kind, `"depends"`, `"embeds"`, or `"references"` (5.2); its source graph node, `{"identity", "range"}` or unavailable (11.2); and its resolved target's identity. Document forms — each a single JSON document whose top level is an object; every one below except `version`'s and the exit-2 error document carries the consulted domain's findings (11.2, 11.6, 6.6) under `"findings"`: @@ -768,7 +768,7 @@ The defined error conditions — also the findings that accompany answers over a 22. Symbolic link in a write path: a workspace-relative directory component of a path xspec writes is a symbolic link (13.4). A command refuses the write and reports it before modifying anything; `check` reports it without writing. The concerned path is the offending symbolic-link component's workspace-relative path — one finding per distinct offending component, whatever write paths it refuses. A symbolic link at a derived file's own path is not an error — writing replaces the link (13.4). 23. Unreadable recorded state: recorded generation state (13.3) that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise. Reported by `inventory` (11.6) and by `rename`/`move` previews (6.6), the surfaces that read the record without refreshing it: the record-supplied datum is reported explicitly unavailable, the rest of the answer is emitted in full, and the invocation exits 1. The concerned path is the graph-data area (11.6): the record's layout is deliberately unenumerated (13.3), so no path inside the area is named. Not reported by `build`, whose rebuild replaces the record (12.1, 13.4), nor by the refreshing reads of 13.3, which leave the record — unreadable state included — unchanged without consulting it (13.3); `check` reports the state as staleness (14.10). -Each distinct reason `rename` and `move` refuse (6.4, 6.5) — exactly what a refused preview (6.6) reports — carries a stable code and, under the location-cardinality rule above, the file, source range, or identity it concerns, so a refusal renders as precisely as a finding; refusals are findings in the exit-code partition (12.0), and the JSON report form above carries them. The reasons and their codes: `refused-invalid-id` — the new ID, or an ID the prefix replacement produces, violates 1.3/1.4, concerning that identity; `refused-identity-unchanged` — the new identity equals the old (6.4; the exact self-move of 6.5), concerning it; `refused-id-collision` — the new ID collides with an existing ID (6.4) or with an ID remaining in the target file after the removal (6.5), locating every colliding bearer; `refused-structural-parent` — structural parent rules would not remain satisfied, concerning the violated identity; `refused-unresolvable-reference` — a rewritten reference would not resolve, locating each such reference spelling; `refused-cycle` — the move would create a spec import cycle or a dependency cycle (6.5), locating the would-be cycle's full path per the cardinality rule; `refused-destination-exists` — the file form's destination file already exists (6.5), concerning that path; `refused-missing-target-parent` — the section form's target parent is missing or lies within the moved subtree (6.5), concerning the target-parent identity; `refused-invalid-destination` — the destination file path would not be a valid discovered spec source (6.5), concerning that path. The invalid-workspace refusal (6.4) reports the workspace's findings themselves, each under its own numbered condition and stable code — and those alone: the reasons above are defined and evaluated only over a workspace passing `build`'s validations (6.4, 6.5), so no report mixes refusal reasons with numbered conditions. +Each distinct reason `rename` and `move` refuse (6.4, 6.5) — exactly what a refused preview (6.6) reports — carries a stable code and, under the location-cardinality rule above, the file, source range, or identity it concerns, so a refusal renders as precisely as a finding; refusals are findings in the exit-code partition (12.0), and the JSON report form above carries them. The reasons and their codes: `refused-invalid-id` — the new ID, or an ID the prefix replacement produces, is not in intrinsic ID form (one or more segments joined by `.`, each satisfying 1.4), concerning that identity — intrinsic form only: positional conformance (1.3) is `refused-structural-parent`'s, evaluated only over intrinsically valid IDs, so no identity reports under both; `refused-identity-unchanged` — the new identity equals the old (6.4; the exact self-move of 6.5), concerning it; `refused-id-collision` — the new ID collides with an existing ID (6.4) or with an ID remaining in the target file after the removal (6.5), locating every colliding bearer; `refused-structural-parent` — structural parent rules (1.3) would not remain satisfied, concerning the violated identity; `refused-unresolvable-reference` — a rewritten reference would not resolve, locating each such reference spelling; `refused-cycle` — the move would create a spec import cycle or a dependency cycle (6.5), locating the would-be cycle's full path per the cardinality rule; `refused-destination-exists` — the file form's destination path is already occupied, whatever kind of filesystem object occupies it (6.5), concerning that path; `refused-missing-target-parent` — the section form's target parent is missing or lies within the moved subtree (6.5), concerning the target-parent identity; `refused-invalid-destination` — the destination file path would not be a valid discovered spec source (6.5), concerning that path. The invalid-workspace refusal (6.4) reports the workspace's findings themselves, each under its own numbered condition and stable code — and those alone: the reasons above are defined and evaluated only over a workspace passing `build`'s validations (6.4, 6.5), so no report mixes refusal reasons with numbered conditions. ## 15. Example From ccc02b87272370227d9d38bebb88eee2282c2486 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:52:20 +0000 Subject: [PATCH 026/226] sdg(phase-4): pin move target occupancy, origin kind, rename collision timing (iter 9) Applied: - C1: section-form move target path occupied by anything other than a discovered spec source is refused under refused-destination-exists (6.5 body and refusal list, 14). - C2: rename/move origin operands pinned to discovered spec sources; a code-source origin is a wrong-kind usage error per 11.4's pattern (6.4, 6.5, 12.0 precedence bullet and exit partition). - I1: rename's collision check pinned post-mapping - vacated IDs are no collision, produced IDs checked too, identity-unchanged reports refused-identity-unchanged alone (6.4, 6.5, 14 refused-id-collision). - O2: 11.2 unavailability parenthetical no longer implies a structural view exists for code sources; occurrence's own range (11.3) named. - O3: unreadable-record outcome consolidated into 14.23 as its one normative home; 6.6 and 11.6 defer to it. - O4: explicit sentence in 14 that a refusal reports every applicable reason together, never only the first found. Rejected: - O1: modularization deferred again - the named candidates (Review, Coverage, validation catalogue) are consumer-facing contract that PROCESS.md requires to stay in SPEC.md itself and forbids modules to restate; extracting them mid-IP refinement would churn every section reference while yielding little movable non-contract content. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/SPEC.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/specs/SPEC.md b/specs/SPEC.md index 339ca38..2e6560e 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -305,7 +305,7 @@ When a command takes a baseline git ref, the baseline graph is reconstructed fro xspec rename [--preview] ``` -Renames a requirement ID, rewrites descendant IDs by prefix replacement, rewrites every reference to the affected identities across all configured spec and code sources (`id` attributes, `d` references, `text(...)` references, TypeScript markers), and appends the mapping to the journal. Rewrites are minimal in-place edits, preserving each reference's quote style and access form (2.4); where a form cannot be kept — a chain segment whose new name is not a valid TypeScript identifier, or a reference converted between local and imported form (6.5) — the rewritten part uses dot access for segments that are valid TypeScript identifiers, double-quoted computed access for segments that are not, and double-quoted string literals. Type-level TypeScript references record no edges (4.5) and are not rewritten: a rename or move can leave them naming vacated identities — a consumer type error outside xspec's validations — while the workspace stays valid. Validation MUST confirm: the new ID is valid; it differs from the old ID and collides with no existing ID; structural parent rules remain satisfied; all rewritten references resolve. A `` or old ID that does not exist is a usage error (12.0); the old ID's existence is parse-local, judged over spelled identities (11.2): it exists exactly when a section of the origin file spells it — a bearer whose node identity is undefined (duplicate spellings; an undefined ancestor chain, 11.2) still establishes existence, a section spelling no identity (its `id` attribute repeated or in invalid value form, 11.2) establishes none, and an unparseable origin file is masked (12.0). Every other validation failure refuses the rename (exit 1), each distinct refusal reason carrying its stable code and location (14). With `--preview`, the operation is planned and reported but performed on nothing (6.6). Rename MUST also refuse (exit 1), before modifying anything, when the current workspace fails the validations of `xspec build` (12.1), so the operation only ever rewrites a valid workspace; the usage-error argument checks precede this refusal (12.0), and it precedes the operation-specific validation above, which is defined — and evaluated — only over a workspace passing `build`'s validations: an invalid-workspace refusal reports the workspace's findings alone (14), no refusal reason evaluated or reported beside them. A successful rename finishes by regenerating derived files exactly as `xspec build` does (12.1) — which cannot fail, per the precondition — so generated modules, Markdown output, and graph data match the rewritten sources and no stale output (14.10) remains. A successful rename's report is the applied mapping: the complete identity mapping the operation journaled — the information of the preview's `mapping` (6.6), carried in JSON per 12.0 (12.7). +Renames a requirement ID, rewrites descendant IDs by prefix replacement, rewrites every reference to the affected identities across all configured spec and code sources (`id` attributes, `d` references, `text(...)` references, TypeScript markers), and appends the mapping to the journal. Rewrites are minimal in-place edits, preserving each reference's quote style and access form (2.4); where a form cannot be kept — a chain segment whose new name is not a valid TypeScript identifier, or a reference converted between local and imported form (6.5) — the rewritten part uses dot access for segments that are valid TypeScript identifiers, double-quoted computed access for segments that are not, and double-quoted string literals. Type-level TypeScript references record no edges (4.5) and are not rewritten: a rename or move can leave them naming vacated identities — a consumer type error outside xspec's validations — while the workspace stays valid. Validation MUST confirm: the new ID is valid; it differs from the old ID; the new ID, and each ID the prefix replacement produces, collides with no ID remaining in the file once the vacated IDs — the old ID and its descendants' — are removed, exactly as the section move's after-removal check reads (6.5): an identity-unchanged rename therefore collides with nothing and reports `refused-identity-unchanged` alone (14); structural parent rules remain satisfied; all rewritten references resolve. A `` or old ID that does not exist is a usage error (12.0); so is a `` naming a discovered source that is not a spec source — a code source bears no requirement IDs, so a code-source origin is a wrong-kind operand, the usage error of 11.4's pattern (12.0), judged like existence before any content question; the old ID's existence is parse-local, judged over spelled identities (11.2): it exists exactly when a section of the origin file spells it — a bearer whose node identity is undefined (duplicate spellings; an undefined ancestor chain, 11.2) still establishes existence, a section spelling no identity (its `id` attribute repeated or in invalid value form, 11.2) establishes none, and an unparseable origin file is masked (12.0). Every other validation failure refuses the rename (exit 1), each distinct refusal reason carrying its stable code and location (14). With `--preview`, the operation is planned and reported but performed on nothing (6.6). Rename MUST also refuse (exit 1), before modifying anything, when the current workspace fails the validations of `xspec build` (12.1), so the operation only ever rewrites a valid workspace; the usage-error argument checks precede this refusal (12.0), and it precedes the operation-specific validation above, which is defined — and evaluated — only over a workspace passing `build`'s validations: an invalid-workspace refusal reports the workspace's findings alone (14), no refusal reason evaluated or reported beside them. A successful rename finishes by regenerating derived files exactly as `xspec build` does (12.1) — which cannot fail, per the precondition — so generated modules, Markdown output, and graph data match the rewritten sources and no stale output (14.10) remains. A successful rename's report is the applied mapping: the complete identity mapping the operation journaled — the information of the preview's `mapping` (6.6), carried in JSON per 12.0 (12.7). ### 6.5 Move @@ -314,9 +314,9 @@ xspec move [--preview] xspec move # # [--preview] ``` -The first form relocates an entire source file; IDs are unchanged and every node's identity changes only in its file part. Relocation also rewrites the moved file's own import specifiers, and the paths by which other files import the moved file's generated module, so all references continue to resolve. The second form extracts a section subtree: the section and its descendants are removed from the origin, inserted as the last child of the target parent (or at the end of the file for a top-level `new-id`), and re-identified by prefix replacement of `` with ``. The target file is created if absent, empty before insertion. The second form's text edits are exact: the moved text is the section construct's own characters — from the first character of its opening tag through the last character of its closing tag, or the self-closing tag's own characters for a self-closing section (1.1). At the origin it is deleted in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3). It is inserted immediately before the target parent's closing tag — at the end of the file for a top-level `new-id` — followed by a U+000A line terminator, and preceded by one when the insertion point is not at the start of a line. A self-closing target parent (1.1) is first rewritten to the paired form: its `/` and any whitespace immediately before or after the `/` are deleted, and the closing tag matching the opening tag's name (`` or ``) is appended immediately after the tag's terminating `>`; the insertion rule then applies before that closing tag. Beyond these edits, the identity and reference rewrites of this section, and the finishing regeneration, a move changes no bytes. In both forms, all references across the workspace are rewritten to resolve to the new identities, converting between local and imported forms and adding or removing spec module imports as the rewrite requires — an import is added when a rewritten reference needs a module binding its file lacks, and an existing spec module import is removed exactly when its binding had references and the rewrite leaves it with none (an import whose binding was already unreferenced stays, 2.1) — and the full mapping is appended to the journal. Import edits are exact. An import removal deletes the declaration's own characters in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3): the removal's extent is the declaration plus any such adjunct drop. An added import binds fresh identifiers colliding with no binding already in the file (2.1, 4) and is inserted as a line of its own — the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — at an offset where the file's grammar permits an import declaration; the identifier choice and the insertion offset are implementation latitude, exercised deterministically: rewritten file content is byte-deterministic for a given operation and workspace state (6.1), and in a file existing before the operation the offset is exactly the one the operation's preview reports (6.6) — an addition into a created target file is subsumed, with the rest of that file's initial content, by the preview's file-creation class (6.6), its placement pinned by byte-determinism alone. A successful move regenerates derived files, and reports its applied mapping, as rename does (6.4). +The first form relocates an entire source file; IDs are unchanged and every node's identity changes only in its file part. Relocation also rewrites the moved file's own import specifiers, and the paths by which other files import the moved file's generated module, so all references continue to resolve. The second form extracts a section subtree: the section and its descendants are removed from the origin, inserted as the last child of the target parent (or at the end of the file for a top-level `new-id`), and re-identified by prefix replacement of `` with ``. The target file is the discovered spec source occupying the target path when one occupies it; at a target path nothing occupies, the target file is created, empty before insertion; a target path occupied by anything else is refused (below). The second form's text edits are exact: the moved text is the section construct's own characters — from the first character of its opening tag through the last character of its closing tag, or the self-closing tag's own characters for a self-closing section (1.1). At the origin it is deleted in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3). It is inserted immediately before the target parent's closing tag — at the end of the file for a top-level `new-id` — followed by a U+000A line terminator, and preceded by one when the insertion point is not at the start of a line. A self-closing target parent (1.1) is first rewritten to the paired form: its `/` and any whitespace immediately before or after the `/` are deleted, and the closing tag matching the opening tag's name (`` or ``) is appended immediately after the tag's terminating `>`; the insertion rule then applies before that closing tag. Beyond these edits, the identity and reference rewrites of this section, and the finishing regeneration, a move changes no bytes. In both forms, all references across the workspace are rewritten to resolve to the new identities, converting between local and imported forms and adding or removing spec module imports as the rewrite requires — an import is added when a rewritten reference needs a module binding its file lacks, and an existing spec module import is removed exactly when its binding had references and the rewrite leaves it with none (an import whose binding was already unreferenced stays, 2.1) — and the full mapping is appended to the journal. Import edits are exact. An import removal deletes the declaration's own characters in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3): the removal's extent is the declaration plus any such adjunct drop. An added import binds fresh identifiers colliding with no binding already in the file (2.1, 4) and is inserted as a line of its own — the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — at an offset where the file's grammar permits an import declaration; the identifier choice and the insertion offset are implementation latitude, exercised deterministically: rewritten file content is byte-deterministic for a given operation and workspace state (6.1), and in a file existing before the operation the offset is exactly the one the operation's preview reports (6.6) — an addition into a created target file is subsumed, with the rest of that file's initial content, by the preview's file-creation class (6.6), its placement pinned by byte-determinism alone. A successful move regenerates derived files, and reports its applied mapping, as rename does (6.4). -Move validation mirrors rename validation, including the valid-workspace precondition (6.4) and the usage-error classification, existence judged as in 6.4, of a nonexistent origin file or ID (12.0); the mirrored checks read in identity terms: the new ID is valid, structural parent rules remain satisfied, all rewritten references resolve, the new identity differs from the old — a cross-file section move keeping its ID is therefore valid, while the exact self-move, `#` equal to `#`, is refused and appends no journal entry — and, in the section form, `` collides with no ID remaining in the target file after the removal. Move additionally MUST refuse: a move that would create an import cycle among spec source files or a dependency cycle; a file-form move whose destination path is already occupied — by whatever kind of filesystem object, a symbolic link included; a section-form move whose target parent — the target file's section bearing `` minus its final segment, needed whenever `` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal; and a move whose destination file path (including a target file to be created) would not be a valid discovered spec source after the move — a path belonging to no configured spec group (a move never takes a node out of the workspace), belonging to a code group as well (14.14), containing `#`, not valid UTF-8, or lacking the `.mdx` extension (14.19). These refusals keep every successful move's finishing regeneration (6.4) on a valid workspace, so it cannot fail. Each distinct refusal reason carries its stable code and location (14); with `--preview`, the operation is planned and reported but performed on nothing (6.6). +Move validation mirrors rename validation, including the valid-workspace precondition (6.4) and the usage-error classification — existence and kind judged as in 6.4, both forms' origin operands naming discovered spec sources — of a nonexistent or wrong-kind origin file or a nonexistent origin ID (12.0); the mirrored checks read in identity terms: the new ID is valid, structural parent rules remain satisfied, all rewritten references resolve, the new identity differs from the old — a cross-file section move keeping its ID is therefore valid, while the exact self-move, `#` equal to `#`, is refused and appends no journal entry — and, in the section form, ``, and each ID its prefix replacement produces, collides with no ID remaining in the target file after the removal. Move additionally MUST refuse: a move that would create an import cycle among spec source files or a dependency cycle; a file-form move whose destination path is already occupied — by whatever kind of filesystem object, a symbolic link included; a section-form move whose target path is occupied by anything other than a discovered spec source — a directory, a symbolic link (discovery never follows one, 7), or any other occupant discovery does not yield as a spec source: neither an insertion target nor an absent path to create; a section-form move whose target parent — the target file's section bearing `` minus its final segment, needed whenever `` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal; and a move whose destination file path (including a target file to be created) would not be a valid discovered spec source after the move — a path belonging to no configured spec group (a move never takes a node out of the workspace), belonging to a code group as well (14.14), containing `#`, not valid UTF-8, or lacking the `.mdx` extension (14.19). These refusals keep every successful move's finishing regeneration (6.4) on a valid workspace, so it cannot fail. Each distinct refusal reason carries its stable code and location (14); with `--preview`, the operation is planned and reported but performed on nothing (6.6). ### 6.6 Previews @@ -324,7 +324,7 @@ Move validation mirrors rename validation, including the valid-workspace precond * the complete identity mapping the operation would journal; * every file the operation would rewrite, relocate, or create, with every edit the operation would make in it, classed as exactly one of the following and — target-file creation excepted — located by a source range (1.7) in current, pre-operation coordinates: a reference-occurrence rewrite (5.7 — `d` references, `text(...)` references, TypeScript markers); an `id`-attribute rewrite (rename's and the section move's re-identification); an import-specifier rewrite; an import addition; an import removal — import-edit extents and insertion offsets per 6.5; the section move's origin deletion — one range spanning every byte the origin edit removes: the construct's own characters, extended over the leftover whitespace and line terminator of each line the line-drop rule additionally drops (6.5, 3), bytes contiguous with the construct, so the adjunct drop lies inside this class's range rather than forming a class of its own; the section move's target insertion point; the self-closing-target-parent rewrite when one applies (6.5); the file move's relocation of the file itself; or target-file creation — reported, when the section form's target file does not yet exist, as its own class with the insertion point at the start of the new file: the one reported location without pre-operation coordinates, and the created file's only reported edit — creation composes the file's entire initial content, subsuming the insertion and the import additions the rewrite requires there (6.5), edits no pre-operation coordinates exist to locate, while the moved text's own rewrites are reported in the origin file, inside the origin deletion's range (below). A rewrite's range is the construct it rewrites — a reference occurrence's span (5.7), the `id` attribute's own characters, the import specifier literal's characters, the target parent's self-closing tag; a removal's range spans every byte its edit removes, as the origin deletion's does; an import addition's insertion point, the section move's target insertion point, and the created target file's start are zero-length ranges at the insertion offset; the relocation's range is the entire moved file. An edit is reported without replacement text: its class and the identity mapping state what changes, and the resulting bytes are observable only by running the operation — the preview is a safety report, not an edit script whose external application would bypass the journaled mapping. Reported ranges MAY nest — the section move's re-identification rewrites locate, in the same pre-operation coordinates, inside its origin deletion's range — each edit reported under its own class, containment being geometry, not double-reporting; -* the derived-file delta, both directions one datum: the derived paths the operation would newly generate — paths where nothing is currently recorded as generated — and the recorded derived paths (13.3) the operation would leave no longer generated, the pre-move module path after a file move included. The delta is the identity-relevant consequence; the full regeneration set a successful operation rewrites is workspace-constant and reported by the inventory (11.6), so the preview does not repeat it. Both directions consult the recorded derived-file paths — presence at a path cannot tell a generated occupant from a foreign one — and a preview, writing nothing, never refreshes the record. Recorded state that exists but cannot be read as a record is condition 23 (14): the delta is reported explicitly unavailable — never fabricated, never read as an empty record — the finding accompanies the report with its stable code, the invocation exits 1 (12.0), and every other part of the report is emitted in full. The real operation is not refused in that state — a corrupt record fails no build validation, and the finishing regeneration (6.4) replaces corrupt graph data — so the preview is not refused either: the unreadable record lies on the success side of the refusal equivalence. A refused preview consults no record — it reports the refusal findings alone, its `mapping`, `files`, and `delta` `null` (12.7) — so no condition-23 finding ever accompanies a refusal. +* the derived-file delta, both directions one datum: the derived paths the operation would newly generate — paths where nothing is currently recorded as generated — and the recorded derived paths (13.3) the operation would leave no longer generated, the pre-move module path after a file move included. The delta is the identity-relevant consequence; the full regeneration set a successful operation rewrites is workspace-constant and reported by the inventory (11.6), so the preview does not repeat it. Both directions consult the recorded derived-file paths — presence at a path cannot tell a generated occupant from a foreign one — and a preview, writing nothing, never refreshes the record. Recorded state that exists but cannot be read as a record is condition 23 (14), met here in the record-supplied datum — the delta — with the one outcome 14.23 defines. The real operation is not refused in that state — a corrupt record fails no build validation, and the finishing regeneration (6.4) replaces corrupt graph data — so the preview is not refused either: the unreadable record lies on the success side of the refusal equivalence. A refused preview consults no record — it reports the refusal findings alone, its `mapping`, `files`, and `delta` `null` (12.7) — so no condition-23 finding ever accompanies a refusal. ### 6.7 Manual restructuring @@ -573,7 +573,7 @@ xspec query reachable --from --to [--kinds ] **Expanded text.** A node's own (respectively subtree) text (1.6) is defined exactly when every embedding the expansion transitively reaches — each `text(...)` spelling in the node's own contribution (respectively anywhere in its subtree), and recursively each one anywhere in every embedded target's subtree — records an occurrence, and the recursion re-enters no node already being expanded (an embedding cycle). One unresolved spelling or one cycle on the expansion path makes the whole value unavailable: partial expansion is fabrication and never occurs. Where defined, the value is exact on imperfect files too, and the removal classification of 3 is by syntactic form, never by validity or resolution: every import declaration is removed by form — binding shape, specifier validity, and target discovery notwithstanding, so an import whose target file was deleted or renamed perturbs no text value; a section tag is removed with every attribute it spells, unknown, repeated, and spread included; and a construct matching no removal rule's form (the stray elements, expression containers, and exports of 14.16) is content, preserved byte-for-byte and located by its finding. A defined value is thus a pure function of the consulted files' parses and the resolved expansions. -**Unavailability is explicit.** A datum these rules leave undefined — a node's identity (a section's under the spelling rules above; every node's in a file whose path is invalid); a section's tags or coverage value; an occurrence's source graph node, identity and range withheld together as one datum (5.7), the enclosing construct's position staying on view through 11.4; an import's resolved target when specifier form or discovery defines none; an own- or subtree-text value — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. +**Unavailability is explicit.** A datum these rules leave undefined — a node's identity (a section's under the spelling rules above; every node's in a file whose path is invalid); a section's tags or coverage value; an occurrence's source graph node, identity and range withheld together as one datum (5.7) — the occurrence's own range (11.3) and, in a spec source, the enclosing construct's position (11.4) staying on view; an import's resolved target when specifier form or discovery defines none; an own- or subtree-text value — is reported as explicitly unavailable wherever an answer would otherwise carry it: deterministically, never silently omitted, never fabricated from partial resolution. **Consulted domain, findings, exits.** Every answer of 11.3–11.5 has a consulted domain of files, defined per surface, and the findings (14) of every domain file accompany the answer — a masked file's parse-failure finding included. A finding is a domain file's exactly when one of its locations (14) lies in that file or that file is its concerned path (14.19); a condition several files jointly violate — a cross-file cycle (14.9) — accompanies the answer, whole, whenever any participating file lies in the domain. An invocation whose answer carries any finding or any explicitly-unavailable datum exits 1 with the full answer document still emitted — exit 1 signals imperfection and never withholds the answer; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their precedence (12.0, 14.14). The argument checks of 11.3–11.5 precede answering, as `rename`'s and `move`'s argument checks precede source validation (12.0): a malformed `--to` spelling or invalid glob pattern (11.3, 11.1), a `` operand outside the domain or of the wrong kind (11.4, 11.5), an ill-formed or out-of-range offset (11.5), and every other usage error of these surfaces exits 2, whatever findings the workspace or the named files carry. A possibly-incomplete answer is therefore never silent. @@ -634,7 +634,7 @@ Reports the machine-readable shape of the workspace, so an external tool never e Inventory lists are ordered deterministically: files and paths in byte order of workspace-relative path, groups, profiles, and rules in configuration order, and session files in byte order of file name. -Recorded state that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise — is condition 23 (14): the record-supplied datum (the recorded derived-file paths) is reported explicitly unavailable, never fabricated and never read as an empty map; the finding accompanies the answer with its stable code; the invocation exits 1 (12.0); and every other provenance's content is emitted in full. +Recorded state that exists but cannot be read as a record is condition 23 (14), met here in the record-supplied datum — the recorded derived-file paths — with the one outcome 14.23 defines. ## 12. Commands @@ -650,8 +650,8 @@ Recorded state that exists but cannot be read as a record — corrupt graph data * All output, generated files, and stored data are byte-deterministic for identical input: no wall-clock values, no randomness, no absolute paths, no environment-dependent content. Invocation-anchored content is the stated exception where a section calls for it — the inventory's anchoring (11.6) and configuration-error concerned paths (14), with 11.6's no-relative-path platform case the sole absolute form — itself a pure function of invocation input, deterministic per invocation. * A workspace-relative path that is not valid UTF-8 (14.19) has no plain string form. Wherever an output carries one — a discovered source or derived path in the inventory (11.6), an occurrence's referencing file (11.3), a per-file view's file or an import's resolved target (11.4), a finding's location file or concerned path (14) — it is presented in an explicitly marked byte form (12.7) that carries the path's exact bytes and is distinguishable from every plain path string, deterministically; a valid-UTF-8 path is never presented in the marked form. No identity carries such a path — no node of such a file has a defined identity (11.2) — and no argument value names one (argument values are UTF-8, above). * Where this specification calls for one shortest path and several shortest paths qualify, the reported one is the least by element-wise byte comparison of the paths' node-identity sequences. -* Exit codes partition all outcomes; every defined failure belongs to exactly one class. `0` — success, including informational reports (`ids`, `show`, `impact`, `query`, the `review` read subcommands including `next` with nothing to review, `coverage` without `--check`, `version`) and complete, finding-free answers (11.2, 11.6). `1` — findings: source, workspace, and operation validation failures (`build` on invalid sources, `check` findings, `coverage --check` with uncovered requirements, refused `rename`/`move` and their refused previews (6.4–6.6), refused review operations (10.7), `review` subcommands naming a corrupt session and `review list` reporting one (14.21)), and answers carrying findings or explicitly-unavailable data — emitted in full, with exit 1 (11.2, 11.6, 6.6). `2` — usage and configuration errors: unknown commands or flags; missing required flags or arguments; invalid flag values; unknown profiles, sessions, groups, review items, node identities, or files named in arguments — except on `occurrences --to`, where only a malformed identity spelling is a usage error and an unknown or unresolving one selects nothing (11.3); invalid session names; missing or invalid configuration (14.14), which never reaches `version` (12.6); a baseline that cannot be read or reconstructed (6.3); a mutating command refused because another is running (13.5). -* The argument existence checks of `rename` and `move` (a nonexistent origin file or old ID, 6.4, 6.5) and baseline resolution (6.3) precede source validation: these usage errors are reported, and the command exits 2, even when the current workspace also fails the validations of `xspec build` (6.4, 13.3) — as configuration errors precede all source analysis (14.14). An old ID inside an unparseable origin file (14.20) is masked (14): there the validation findings are reported and the command exits 1. Within exit class 2, an error the invocation's syntax alone determines — an unknown command or flag, a repeated flag, a missing required flag or argument, a malformed value — is reported without loading configuration; a configuration error (14.14) precedes every argument check that consults configuration, discovery, or the workspace, the unknown names and files of the usage class included. +* Exit codes partition all outcomes; every defined failure belongs to exactly one class. `0` — success, including informational reports (`ids`, `show`, `impact`, `query`, the `review` read subcommands including `next` with nothing to review, `coverage` without `--check`, `version`) and complete, finding-free answers (11.2, 11.6). `1` — findings: source, workspace, and operation validation failures (`build` on invalid sources, `check` findings, `coverage --check` with uncovered requirements, refused `rename`/`move` and their refused previews (6.4–6.6), refused review operations (10.7), `review` subcommands naming a corrupt session and `review list` reporting one (14.21)), and answers carrying findings or explicitly-unavailable data — emitted in full, with exit 1 (11.2, 11.6, 6.6). `2` — usage and configuration errors: unknown commands or flags; missing required flags or arguments; invalid flag values; unknown profiles, sessions, groups, review items, node identities, or files named in arguments — except on `occurrences --to`, where only a malformed identity spelling is a usage error and an unknown or unresolving one selects nothing (11.3); wrong-kind operands — a code source named where a spec source is required (6.4, 6.5, 11.4, 11.5); invalid session names; missing or invalid configuration (14.14), which never reaches `version` (12.6); a baseline that cannot be read or reconstructed (6.3); a mutating command refused because another is running (13.5). +* The argument checks of `rename` and `move` (a nonexistent origin file or old ID; a wrong-kind, non-spec-source origin file, 6.4, 6.5) and baseline resolution (6.3) precede source validation: these usage errors are reported, and the command exits 2, even when the current workspace also fails the validations of `xspec build` (6.4, 13.3) — as configuration errors precede all source analysis (14.14). An old ID inside an unparseable origin file (14.20) is masked (14): there the validation findings are reported and the command exits 1. Within exit class 2, an error the invocation's syntax alone determines — an unknown command or flag, a repeated flag, a missing required flag or argument, a malformed value — is reported without loading configuration; a configuration error (14.14) precedes every argument check that consults configuration, discovery, or the workspace, the unknown names and files of the usage class included. ### 12.1 `xspec build` @@ -766,9 +766,9 @@ The defined error conditions — also the findings that accompany answers over a 20. Unparseable source: a spec-group file that is not well-formed MDX, a code-group file that is not well-formed TypeScript under the grammar its file name selects (`.tsx` parses as TSX, any other name as plain TypeScript), or a discovered source file of either kind that is not valid UTF-8 or begins with a byte-order mark (1.6); the error reports the location of the parse failure. 21. Corrupt review session: a session file that is not a plain file (13.4), cannot be parsed, or violates a session invariant (10.1). Reported by `check`, by any `review` subcommand naming the session, and by `review list` (exit 1); not reported by `build`, which does not read sessions. 22. Symbolic link in a write path: a workspace-relative directory component of a path xspec writes is a symbolic link (13.4). A command refuses the write and reports it before modifying anything; `check` reports it without writing. The concerned path is the offending symbolic-link component's workspace-relative path — one finding per distinct offending component, whatever write paths it refuses. A symbolic link at a derived file's own path is not an error — writing replaces the link (13.4). -23. Unreadable recorded state: recorded generation state (13.3) that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise. Reported by `inventory` (11.6) and by `rename`/`move` previews (6.6), the surfaces that read the record without refreshing it: the record-supplied datum is reported explicitly unavailable, the rest of the answer is emitted in full, and the invocation exits 1. The concerned path is the graph-data area (11.6): the record's layout is deliberately unenumerated (13.3), so no path inside the area is named. Not reported by `build`, whose rebuild replaces the record (12.1, 13.4), nor by the refreshing reads of 13.3, which leave the record — unreadable state included — unchanged without consulting it (13.3); `check` reports the state as staleness (14.10). +23. Unreadable recorded state: recorded generation state (13.3) that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise. Reported by the surfaces that read the record without refreshing it — `inventory` (11.6) and `rename`/`move` previews (6.6) — with one outcome, defined here for both: the surface's record-supplied datum — the inventory's recorded derived-file paths, the preview's delta — is reported explicitly unavailable, never fabricated and never read as an empty record; the finding accompanies the answer with its stable code; the invocation exits 1 (12.0); and every other part of the answer — every other provenance's content, every other part of the preview report — is emitted in full. The concerned path is the graph-data area (11.6): the record's layout is deliberately unenumerated (13.3), so no path inside the area is named. Not reported by `build`, whose rebuild replaces the record (12.1, 13.4), nor by the refreshing reads of 13.3, which leave the record — unreadable state included — unchanged without consulting it (13.3); `check` reports the state as staleness (14.10). -Each distinct reason `rename` and `move` refuse (6.4, 6.5) — exactly what a refused preview (6.6) reports — carries a stable code and, under the location-cardinality rule above, the file, source range, or identity it concerns, so a refusal renders as precisely as a finding; refusals are findings in the exit-code partition (12.0), and the JSON report form above carries them. The reasons and their codes: `refused-invalid-id` — the new ID, or an ID the prefix replacement produces, is not in intrinsic ID form (one or more segments joined by `.`, each satisfying 1.4), concerning that identity — intrinsic form only: positional conformance (1.3) is `refused-structural-parent`'s, evaluated only over intrinsically valid IDs, so no identity reports under both; `refused-identity-unchanged` — the new identity equals the old (6.4; the exact self-move of 6.5), concerning it; `refused-id-collision` — the new ID collides with an existing ID (6.4) or with an ID remaining in the target file after the removal (6.5), locating every colliding bearer; `refused-structural-parent` — structural parent rules (1.3) would not remain satisfied, concerning the violated identity; `refused-unresolvable-reference` — a rewritten reference would not resolve, locating each such reference spelling; `refused-cycle` — the move would create a spec import cycle or a dependency cycle (6.5), locating the would-be cycle's full path per the cardinality rule; `refused-destination-exists` — the file form's destination path is already occupied, whatever kind of filesystem object occupies it (6.5), concerning that path; `refused-missing-target-parent` — the section form's target parent is missing or lies within the moved subtree (6.5), concerning the target-parent identity; `refused-invalid-destination` — the destination file path would not be a valid discovered spec source (6.5), concerning that path. The invalid-workspace refusal (6.4) reports the workspace's findings themselves, each under its own numbered condition and stable code — and those alone: the reasons above are defined and evaluated only over a workspace passing `build`'s validations (6.4, 6.5), so no report mixes refusal reasons with numbered conditions. +Each distinct reason `rename` and `move` refuse (6.4, 6.5) — exactly what a refused preview (6.6) reports — carries a stable code and, under the location-cardinality rule above, the file, source range, or identity it concerns, so a refusal renders as precisely as a finding; refusals are findings in the exit-code partition (12.0), and the JSON report form above carries them. A refused operation or preview reports every applicable reason together, one finding per reason — never only the first found — each reason's applicability read on its own terms below. The reasons and their codes: `refused-invalid-id` — the new ID, or an ID the prefix replacement produces, is not in intrinsic ID form (one or more segments joined by `.`, each satisfying 1.4), concerning that identity — intrinsic form only: positional conformance (1.3) is `refused-structural-parent`'s, evaluated only over intrinsically valid IDs, so no identity reports under both; `refused-identity-unchanged` — the new identity equals the old (6.4; the exact self-move of 6.5), concerning it; `refused-id-collision` — the new ID, or an ID the prefix replacement produces, collides with an ID remaining after the operation's removals — rename's prefix mapping (6.4), the section move's subtree removal (6.5) — locating every colliding bearer; `refused-structural-parent` — structural parent rules (1.3) would not remain satisfied, concerning the violated identity; `refused-unresolvable-reference` — a rewritten reference would not resolve, locating each such reference spelling; `refused-cycle` — the move would create a spec import cycle or a dependency cycle (6.5), locating the would-be cycle's full path per the cardinality rule; `refused-destination-exists` — the file form's destination path is already occupied, whatever kind of filesystem object occupies it, or the section form's target path is occupied by anything other than a discovered spec source (6.5), concerning that path; `refused-missing-target-parent` — the section form's target parent is missing or lies within the moved subtree (6.5), concerning the target-parent identity; `refused-invalid-destination` — the destination file path would not be a valid discovered spec source (6.5), concerning that path. The invalid-workspace refusal (6.4) reports the workspace's findings themselves, each under its own numbered condition and stable code — and those alone: the reasons above are defined and evaluated only over a workspace passing `build`'s validations (6.4, 6.5), so no report mixes refusal reasons with numbered conditions. ## 15. Example From e34bcddc6f68b2c34dfb31a2f05d2f0635be5417 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 16:32:58 +0000 Subject: [PATCH 027/226] sdg(phase-4): pin check's graph-data staleness form, classify mixed move operands (iter 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: - C1: 14.10 restructured into four forms — per-file staleness (generated modules/companions per 13.1, emitted Markdown per 13.2) and orphaned recorded files name the file; graph-data staleness (missing or mismatching under 13.3's comparison, recorded paths excluded) and the unreadable record are each one finding whose concerned path is the graph-data area, no path inside named, mirroring 14.23's shape. 12.2 now enumerates the verifications, graph data included, removing the 'generated files' ambiguity. Follows 13.3's existing commitment that check reports graph-data staleness while keeping 11.6's unattributed- path regime intact. - O1: 6.5 classifies move operands by spelling — a mixed-form invocation matches neither synopsis and is a usage error; the file form's inability to spell '#'-containing paths noted as harmless (14.19). - O2: 6.4 drops the '(12.7)' citation on the successful-rename report; 12.7 pins no form for it, so the report stays information-only per 12.0 — consistent with O4's recorded-gap stance. - O3: 7 qualifies 'Every command locates the configuration' to except version, matching 12.6 and 14.14. Rejected: - O5 (modularization): SHOULD, not MUST. The named components are not loosely coupled — 14 is cross-referenced from nearly every section, 10 and 11 interlock with 5.4-5.7, 12.0, 12.7, 13.3-13.5 — so extraction would duplicate contract text into SPEC.md or violate the no-reference-into-module rule; restructuring at convergence adds contradiction risk with no behavioral content. Consistent with prior rounds' deferrals. No-op: - O4: reviewer records the unpinned query/impact/coverage/review member names as a deliberate, IP-faithful gap for a future proposal; no change requested. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/SPEC.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/specs/SPEC.md b/specs/SPEC.md index 2e6560e..e42bd60 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -305,7 +305,7 @@ When a command takes a baseline git ref, the baseline graph is reconstructed fro xspec rename [--preview] ``` -Renames a requirement ID, rewrites descendant IDs by prefix replacement, rewrites every reference to the affected identities across all configured spec and code sources (`id` attributes, `d` references, `text(...)` references, TypeScript markers), and appends the mapping to the journal. Rewrites are minimal in-place edits, preserving each reference's quote style and access form (2.4); where a form cannot be kept — a chain segment whose new name is not a valid TypeScript identifier, or a reference converted between local and imported form (6.5) — the rewritten part uses dot access for segments that are valid TypeScript identifiers, double-quoted computed access for segments that are not, and double-quoted string literals. Type-level TypeScript references record no edges (4.5) and are not rewritten: a rename or move can leave them naming vacated identities — a consumer type error outside xspec's validations — while the workspace stays valid. Validation MUST confirm: the new ID is valid; it differs from the old ID; the new ID, and each ID the prefix replacement produces, collides with no ID remaining in the file once the vacated IDs — the old ID and its descendants' — are removed, exactly as the section move's after-removal check reads (6.5): an identity-unchanged rename therefore collides with nothing and reports `refused-identity-unchanged` alone (14); structural parent rules remain satisfied; all rewritten references resolve. A `` or old ID that does not exist is a usage error (12.0); so is a `` naming a discovered source that is not a spec source — a code source bears no requirement IDs, so a code-source origin is a wrong-kind operand, the usage error of 11.4's pattern (12.0), judged like existence before any content question; the old ID's existence is parse-local, judged over spelled identities (11.2): it exists exactly when a section of the origin file spells it — a bearer whose node identity is undefined (duplicate spellings; an undefined ancestor chain, 11.2) still establishes existence, a section spelling no identity (its `id` attribute repeated or in invalid value form, 11.2) establishes none, and an unparseable origin file is masked (12.0). Every other validation failure refuses the rename (exit 1), each distinct refusal reason carrying its stable code and location (14). With `--preview`, the operation is planned and reported but performed on nothing (6.6). Rename MUST also refuse (exit 1), before modifying anything, when the current workspace fails the validations of `xspec build` (12.1), so the operation only ever rewrites a valid workspace; the usage-error argument checks precede this refusal (12.0), and it precedes the operation-specific validation above, which is defined — and evaluated — only over a workspace passing `build`'s validations: an invalid-workspace refusal reports the workspace's findings alone (14), no refusal reason evaluated or reported beside them. A successful rename finishes by regenerating derived files exactly as `xspec build` does (12.1) — which cannot fail, per the precondition — so generated modules, Markdown output, and graph data match the rewritten sources and no stale output (14.10) remains. A successful rename's report is the applied mapping: the complete identity mapping the operation journaled — the information of the preview's `mapping` (6.6), carried in JSON per 12.0 (12.7). +Renames a requirement ID, rewrites descendant IDs by prefix replacement, rewrites every reference to the affected identities across all configured spec and code sources (`id` attributes, `d` references, `text(...)` references, TypeScript markers), and appends the mapping to the journal. Rewrites are minimal in-place edits, preserving each reference's quote style and access form (2.4); where a form cannot be kept — a chain segment whose new name is not a valid TypeScript identifier, or a reference converted between local and imported form (6.5) — the rewritten part uses dot access for segments that are valid TypeScript identifiers, double-quoted computed access for segments that are not, and double-quoted string literals. Type-level TypeScript references record no edges (4.5) and are not rewritten: a rename or move can leave them naming vacated identities — a consumer type error outside xspec's validations — while the workspace stays valid. Validation MUST confirm: the new ID is valid; it differs from the old ID; the new ID, and each ID the prefix replacement produces, collides with no ID remaining in the file once the vacated IDs — the old ID and its descendants' — are removed, exactly as the section move's after-removal check reads (6.5): an identity-unchanged rename therefore collides with nothing and reports `refused-identity-unchanged` alone (14); structural parent rules remain satisfied; all rewritten references resolve. A `` or old ID that does not exist is a usage error (12.0); so is a `` naming a discovered source that is not a spec source — a code source bears no requirement IDs, so a code-source origin is a wrong-kind operand, the usage error of 11.4's pattern (12.0), judged like existence before any content question; the old ID's existence is parse-local, judged over spelled identities (11.2): it exists exactly when a section of the origin file spells it — a bearer whose node identity is undefined (duplicate spellings; an undefined ancestor chain, 11.2) still establishes existence, a section spelling no identity (its `id` attribute repeated or in invalid value form, 11.2) establishes none, and an unparseable origin file is masked (12.0). Every other validation failure refuses the rename (exit 1), each distinct refusal reason carrying its stable code and location (14). With `--preview`, the operation is planned and reported but performed on nothing (6.6). Rename MUST also refuse (exit 1), before modifying anything, when the current workspace fails the validations of `xspec build` (12.1), so the operation only ever rewrites a valid workspace; the usage-error argument checks precede this refusal (12.0), and it precedes the operation-specific validation above, which is defined — and evaluated — only over a workspace passing `build`'s validations: an invalid-workspace refusal reports the workspace's findings alone (14), no refusal reason evaluated or reported beside them. A successful rename finishes by regenerating derived files exactly as `xspec build` does (12.1) — which cannot fail, per the precondition — so generated modules, Markdown output, and graph data match the rewritten sources and no stale output (14.10) remains. A successful rename's report is the applied mapping: the complete identity mapping the operation journaled — the information of the preview's `mapping` (6.6), carried in JSON per 12.0. ### 6.5 Move @@ -316,7 +316,7 @@ xspec move # # [--preview] The first form relocates an entire source file; IDs are unchanged and every node's identity changes only in its file part. Relocation also rewrites the moved file's own import specifiers, and the paths by which other files import the moved file's generated module, so all references continue to resolve. The second form extracts a section subtree: the section and its descendants are removed from the origin, inserted as the last child of the target parent (or at the end of the file for a top-level `new-id`), and re-identified by prefix replacement of `` with ``. The target file is the discovered spec source occupying the target path when one occupies it; at a target path nothing occupies, the target file is created, empty before insertion; a target path occupied by anything else is refused (below). The second form's text edits are exact: the moved text is the section construct's own characters — from the first character of its opening tag through the last character of its closing tag, or the self-closing tag's own characters for a self-closing section (1.1). At the origin it is deleted in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3). It is inserted immediately before the target parent's closing tag — at the end of the file for a top-level `new-id` — followed by a U+000A line terminator, and preceded by one when the insertion point is not at the start of a line. A self-closing target parent (1.1) is first rewritten to the paired form: its `/` and any whitespace immediately before or after the `/` are deleted, and the closing tag matching the opening tag's name (`` or ``) is appended immediately after the tag's terminating `>`; the insertion rule then applies before that closing tag. Beyond these edits, the identity and reference rewrites of this section, and the finishing regeneration, a move changes no bytes. In both forms, all references across the workspace are rewritten to resolve to the new identities, converting between local and imported forms and adding or removing spec module imports as the rewrite requires — an import is added when a rewritten reference needs a module binding its file lacks, and an existing spec module import is removed exactly when its binding had references and the rewrite leaves it with none (an import whose binding was already unreferenced stays, 2.1) — and the full mapping is appended to the journal. Import edits are exact. An import removal deletes the declaration's own characters in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3): the removal's extent is the declaration plus any such adjunct drop. An added import binds fresh identifiers colliding with no binding already in the file (2.1, 4) and is inserted as a line of its own — the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — at an offset where the file's grammar permits an import declaration; the identifier choice and the insertion offset are implementation latitude, exercised deterministically: rewritten file content is byte-deterministic for a given operation and workspace state (6.1), and in a file existing before the operation the offset is exactly the one the operation's preview reports (6.6) — an addition into a created target file is subsumed, with the rest of that file's initial content, by the preview's file-creation class (6.6), its placement pinned by byte-determinism alone. A successful move regenerates derived files, and reports its applied mapping, as rename does (6.4). -Move validation mirrors rename validation, including the valid-workspace precondition (6.4) and the usage-error classification — existence and kind judged as in 6.4, both forms' origin operands naming discovered spec sources — of a nonexistent or wrong-kind origin file or a nonexistent origin ID (12.0); the mirrored checks read in identity terms: the new ID is valid, structural parent rules remain satisfied, all rewritten references resolve, the new identity differs from the old — a cross-file section move keeping its ID is therefore valid, while the exact self-move, `#` equal to `#`, is refused and appends no journal entry — and, in the section form, ``, and each ID its prefix replacement produces, collides with no ID remaining in the target file after the removal. Move additionally MUST refuse: a move that would create an import cycle among spec source files or a dependency cycle; a file-form move whose destination path is already occupied — by whatever kind of filesystem object, a symbolic link included; a section-form move whose target path is occupied by anything other than a discovered spec source — a directory, a symbolic link (discovery never follows one, 7), or any other occupant discovery does not yield as a spec source: neither an insertion target nor an absent path to create; a section-form move whose target parent — the target file's section bearing `` minus its final segment, needed whenever `` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal; and a move whose destination file path (including a target file to be created) would not be a valid discovered spec source after the move — a path belonging to no configured spec group (a move never takes a node out of the workspace), belonging to a code group as well (14.14), containing `#`, not valid UTF-8, or lacking the `.mdx` extension (14.19). These refusals keep every successful move's finishing regeneration (6.4) on a valid workspace, so it cannot fail. Each distinct refusal reason carries its stable code and location (14); with `--preview`, the operation is planned and reported but performed on nothing (6.6). +A move operand is classified by spelling alone: an operand containing `#` is a `#` pair under the split of 12.0 and one without is a file — an invocation mixing the two synopses' forms therefore matches neither and is a usage error (12.0), and the file form cannot spell a `#`-containing path: a harmless limit, such paths being invalid source paths (14.19). Move validation mirrors rename validation, including the valid-workspace precondition (6.4) and the usage-error classification — existence and kind judged as in 6.4, both forms' origin operands naming discovered spec sources — of a nonexistent or wrong-kind origin file or a nonexistent origin ID (12.0); the mirrored checks read in identity terms: the new ID is valid, structural parent rules remain satisfied, all rewritten references resolve, the new identity differs from the old — a cross-file section move keeping its ID is therefore valid, while the exact self-move, `#` equal to `#`, is refused and appends no journal entry — and, in the section form, ``, and each ID its prefix replacement produces, collides with no ID remaining in the target file after the removal. Move additionally MUST refuse: a move that would create an import cycle among spec source files or a dependency cycle; a file-form move whose destination path is already occupied — by whatever kind of filesystem object, a symbolic link included; a section-form move whose target path is occupied by anything other than a discovered spec source — a directory, a symbolic link (discovery never follows one, 7), or any other occupant discovery does not yield as a spec source: neither an insertion target nor an absent path to create; a section-form move whose target parent — the target file's section bearing `` minus its final segment, needed whenever `` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal; and a move whose destination file path (including a target file to be created) would not be a valid discovered spec source after the move — a path belonging to no configured spec group (a move never takes a node out of the workspace), belonging to a code group as well (14.14), containing `#`, not valid UTF-8, or lacking the `.mdx` extension (14.19). These refusals keep every successful move's finishing regeneration (6.4) on a valid workspace, so it cannot fail. Each distinct refusal reason carries its stable code and location (14); with `--preview`, the operation is planned and reported but performed on nothing (6.6). ### 6.6 Previews @@ -369,7 +369,7 @@ export default defineConfig({ The configuration is declarative — data, not executed code. The file MUST consist of exactly an import of `defineConfig` from the module specifier `"xspec"` (optionally aliased) and a default export of one call to that binding, whose sole argument is statically literal: object literals with non-computed identifier or string-literal keys, array literals, static string literals (2.4), and the boolean literals `true` and `false` — no other statement or expression form, no spread, no computed value. Configuration therefore cannot carry side effects, environment-dependent values (12.0), or network access; a configuration file that is not well-formed TypeScript or does not conform is a configuration error (14.14). -Every command locates the configuration by upward search for `xspec.config.ts` from the working directory, or uses the path given by the global `--config ` option. `specs` is required; `code`, `markdown`, `coverage`, and `policy` are optional — omitting one means no code groups, no Markdown emission, no coverage profiles, or no policy rules, respectively; an empty `coverage` or `policy` list is valid and equivalent to omitting the key. Unknown keys anywhere in the `defineConfig` argument — a top-level key, or a field of `markdown`, a profile, a rule, or a selector — are a configuration error (14.14). All configured paths and globs resolve relative to the configuration file's directory, which is the workspace root. Glob matching, like every path comparison (12.0), is byte-wise: workspace-relative paths are matched as their UTF-8 bytes, and a discovered source file whose workspace-relative path is not valid UTF-8 is invalid (14.19). Globs support exactly `*` (any possibly empty run of bytes within one path segment), `?` (one byte within a segment), and `**` (any number of whole segments, including none); matching is case-sensitive; a path segment beginning with `.` is matched only by a pattern segment written with a leading `.`; a pattern that resolves outside the workspace root is a configuration error (14.14). Discovery never follows symbolic links: a symbolic link — to a file or to a directory, broken or not — is never a discovered source and is never traversed, so symlinked, cyclic, or workspace-external content never enters the discovered set. Discovery of source files is controlled exclusively by configuration; derived files are never discovered as sources (13.4); imports resolve references between files but never add files to the workspace (2.1). A group whose globs match no files is valid, as is a `specs` or `code` map with no groups: discovery simply yields fewer, possibly zero, sources. +Every command except `version` (12.6), which loads no configuration, locates the configuration by upward search for `xspec.config.ts` from the working directory, or uses the path given by the global `--config ` option. `specs` is required; `code`, `markdown`, `coverage`, and `policy` are optional — omitting one means no code groups, no Markdown emission, no coverage profiles, or no policy rules, respectively; an empty `coverage` or `policy` list is valid and equivalent to omitting the key. Unknown keys anywhere in the `defineConfig` argument — a top-level key, or a field of `markdown`, a profile, a rule, or a selector — are a configuration error (14.14). All configured paths and globs resolve relative to the configuration file's directory, which is the workspace root. Glob matching, like every path comparison (12.0), is byte-wise: workspace-relative paths are matched as their UTF-8 bytes, and a discovered source file whose workspace-relative path is not valid UTF-8 is invalid (14.19). Globs support exactly `*` (any possibly empty run of bytes within one path segment), `?` (one byte within a segment), and `**` (any number of whole segments, including none); matching is case-sensitive; a path segment beginning with `.` is matched only by a pattern segment written with a leading `.`; a pattern that resolves outside the workspace root is a configuration error (14.14). Discovery never follows symbolic links: a symbolic link — to a file or to a directory, broken or not — is never a discovered source and is never traversed, so symlinked, cyclic, or workspace-external content never enters the discovered set. Discovery of source files is controlled exclusively by configuration; derived files are never discovered as sources (13.4); imports resolve references between files but never add files to the workspace (2.1). A group whose globs match no files is valid, as is a `specs` or `code` map with no groups: discovery simply yields fewer, possibly zero, sources. ### 7.1 `specs` @@ -659,7 +659,7 @@ Parses configured sources; validates section structure, IDs, tags, and reference ### 12.2 `xspec check` -Performs all build validations (12.1, 14) — unresolved and non-static references, dependency and spec import cycles, and journal errors included — without accepting stale outputs, and additionally verifies what `build` does not: generated files are content-identical to what the current sources and configuration generate, no recorded derived file remains at a path no longer generated, and the recorded generation state is readable as a record (14.10, 14.23); no policy violations exist (14.12); review sessions are not internally corrupt (14.21). Exits 1 on any finding. Configuration validity is enforced at load by every command (14.14) and is a usage error, not a `check` finding. +Performs all build validations (12.1, 14) — unresolved and non-static references, dependency and spec import cycles, and journal errors included — without accepting stale outputs, and additionally verifies what `build` does not: generated modules, companions (13.1), and emitted Markdown (13.2) are content-identical to what the current sources and configuration generate; graph data matches the current sources and configuration (13.3); no recorded derived file remains at a path no longer generated; and the recorded generation state is readable as a record (14.10, 14.23); no policy violations exist (14.12); review sessions are not internally corrupt (14.21). Exits 1 on any finding. Configuration validity is enforced at load by every command (14.14) and is a usage error, not a `check` finding. ### 12.3 `xspec ids` @@ -753,7 +753,7 @@ The defined error conditions — also the findings that accompany answers over a 7. Unknown TypeScript reference: a marker or `text` call that does not resolve; this is also a type error against the generated module. 8. Invalid argument: a `d` or `text(...)` reference that is not static per 2.4, a non-static bare reference in TypeScript expression-statement position (4.5), a `text(...)` call without exactly one argument, or a string-form `text(...)` argument in a TypeScript file (4.3). 9. Cycle: a dependency cycle (with the full path) or a spec import cycle. -10. Stale generated output: a derived file whose content does not match what the current sources and configuration generate, or a recorded derived file (13.3) remaining at a path the current sources and configuration no longer generate — the error names the file and instructs rebuilding — or recorded generation state that exists but cannot be read as a record (14.23): one finding, instructing rebuilding, whose concerned path is the graph-data area (11.6) — the record's layout is unenumerated (13.3), so no path inside the area is named; while that state holds, this form alone reports the graph data, and the recorded-file form above, consulting no readable record, is undetectable (14). Reported by `check` only: `build` cannot observe staleness because it regenerates every derived file (12.1). +10. Stale generated output, in four forms. Per file: a generated module or companion (13.1) or emitted Markdown file (13.2) whose content does not match what the current sources and configuration generate, or a recorded derived file (13.3) remaining at a path the current sources and configuration no longer generate — the error names the file and instructs rebuilding. As one unit: graph data that is missing or does not match the current sources and configuration — the comparison of 13.3, the recorded derived-file paths excluded, so a lagging record alone is never staleness — or recorded generation state that exists but cannot be read as a record (14.23): one finding either way, instructing rebuilding, whose concerned path is the graph-data area (11.6) — the record's layout is unenumerated (13.3), so no path inside the area is named. The unit forms are exclusive — an unreadable record (14.23) reports under its own form alone, never the mismatch form beside it — and while that state holds the recorded-file form, consulting no readable record, is undetectable (14). Reported by `check` only: `build` cannot observe staleness because it regenerates every derived file (12.1). 11. Cross-module text call: a node passed to the `text` export of a spec module other than its own; additionally a TypeScript type error and a runtime throw per 4.4. 12. Policy violation: one finding per violation (7.5) — per rule and offending edge. The offending entity is a graph edge, not a spelling: the finding carries no in-source locations and concerns no path — `locations` empty, `path` `null` (12.7) — so it is no file's finding (11.2) and accompanies no answer of 11.3–11.5. Its `identities` are, in order, the violated rule's name and the edge's source identity, kind token (12.7), and target identity. Reported by `check` only: policy constrains the workspace graph, not source validity, and `build` regenerates output regardless of policy findings (7.5, 12.1). 13. Journal error: malformed, conflicting, or unreplayable entries, naming the lines; a journal path occupied by anything other than a plain file (13.4). From 6a74e5be18031bd91f203f41cca0e9338f73ca08 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 17:04:22 +0000 Subject: [PATCH 028/226] sdg(phase-4): pin per-file staleness occupant judgment and absent-file case (iter 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: - C1: 14.10/12.2 — the per-file content comparison judges the path's occupant itself, never traversing a symbolic link (13.4): it matches only a plain file holding exactly the generated content, so a symlink (whatever its target holds), a directory, or any other non-plain-file occupant is stale per se. Two conforming implementations can no longer diverge on a symlinked byte-identical occupant. - I1: 14.10 — per-file form now reads "missing or does not match", covering check on a deleted generated module/companion/Markdown file, parallel to the unit form, 13.3, and 13.4's deleted-file promise; 12.2 aligned ("present as plain files content-identical"). - O2: 11.6 — stated that the condition-23 finding is the only finding an inventory answer ever carries; findings a listed file or path may bear (14.19, 14.13, 14.21) report where their conditions assign them. - O3: 12.7 — concerned-path ordering key pinned to byte-wise comparison across presentation forms: marked byte-form and plain-string paths sort in one byte order. Rejected: - O1: modularization deferred again — standing SHOULD acknowledged, but the consumer-facing contract must stay in SPEC.md (PROCESS), limiting the extractable interior, and restructuring mid-IP-refinement is not clearly right this round. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/SPEC.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specs/SPEC.md b/specs/SPEC.md index e42bd60..a36037a 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -634,7 +634,7 @@ Reports the machine-readable shape of the workspace, so an external tool never e Inventory lists are ordered deterministically: files and paths in byte order of workspace-relative path, groups, profiles, and rules in configuration order, and session files in byte order of file name. -Recorded state that exists but cannot be read as a record is condition 23 (14), met here in the record-supplied datum — the recorded derived-file paths — with the one outcome 14.23 defines. +Recorded state that exists but cannot be read as a record is condition 23 (14), met here in the record-supplied datum — the recorded derived-file paths — with the one outcome 14.23 defines. That is the only finding an inventory answer ever carries: parsing no sources, reading no journal or session content, and writing nothing, the inventory meets no other condition, and the findings a listed file or path may bear — an invalid source path (14.19), a journal error (14.13), a corrupt session (14.21) — are reported where their conditions assign them (14), never here. ## 12. Commands @@ -659,7 +659,7 @@ Parses configured sources; validates section structure, IDs, tags, and reference ### 12.2 `xspec check` -Performs all build validations (12.1, 14) — unresolved and non-static references, dependency and spec import cycles, and journal errors included — without accepting stale outputs, and additionally verifies what `build` does not: generated modules, companions (13.1), and emitted Markdown (13.2) are content-identical to what the current sources and configuration generate; graph data matches the current sources and configuration (13.3); no recorded derived file remains at a path no longer generated; and the recorded generation state is readable as a record (14.10, 14.23); no policy violations exist (14.12); review sessions are not internally corrupt (14.21). Exits 1 on any finding. Configuration validity is enforced at load by every command (14.14) and is a usage error, not a `check` finding. +Performs all build validations (12.1, 14) — unresolved and non-static references, dependency and spec import cycles, and journal errors included — without accepting stale outputs, and additionally verifies what `build` does not: generated modules, companions (13.1), and emitted Markdown (13.2) are present as plain files content-identical to what the current sources and configuration generate — each path's occupant judged itself, never through a symbolic link (14.10); graph data matches the current sources and configuration (13.3); no recorded derived file remains at a path no longer generated; and the recorded generation state is readable as a record (14.10, 14.23); no policy violations exist (14.12); review sessions are not internally corrupt (14.21). Exits 1 on any finding. Configuration validity is enforced at load by every command (14.14) and is a usage error, not a `check` finding. ### 12.3 `xspec ids` @@ -692,7 +692,7 @@ Value forms: * A source range (1.7) is `{"start": …, "end": …}`, both non-negative integers. * A path — workspace-relative, or in the anchoring form of 11.6 — is a string where its bytes are valid UTF-8, and otherwise the marked byte form of 12.0: `{"bytes": "…"}`, the path's exact bytes as lowercase hexadecimal, two digits per byte — an object, equal to no path string. Identities are strings (1.5); no identity carries a non-UTF-8 path (12.0). * A datum reported explicitly unavailable (11.2, 11.6, 6.6) is `{"unavailable": true}`. A plain value, `null`, and `{"unavailable": true}` are the three observable states of a datum (11.4). -* A finding (14) is `{"code", "message", "locations", "path", "identities"}`: the stable code, the token string 14 assigns (`null` where 14 assigns none); the human-readable description; one `{"file", "range"}` per offending construct — ordered by file path bytes, then range start, then range end — empty for conditions without in-source locations; the concerned file or path (`null` for located conditions); and the identities or other context strings the condition names (14), empty where none — content contractual exactly where 14 states it for the condition or reason (14.12's enumeration, a condition's named context entity such as 14.11's foreign module, a refusal reason's concerned identity), otherwise informational: deterministic (12.0), its composition unpinned. Wherever a document carries findings they form the array member `"findings"`, ordered by code — the numbered conditions in numeric order, then the refusal reasons in the order 14 lists them, then code-less findings — then by locations, compared element-wise — each element by file path bytes, then range start, then range end; a sequence that is a proper prefix of another sorts first — then by concerned path (`null` before any path), then by identities, compared element-wise under the same prefix rule (string elements by bytes, 12.0), then by message; findings identical in every member collapse to one, so the order is total. A report whose defined content is findings alone — `build` and `check` reports, the findings of refusing reads (13.3), refused operations (6.4, 6.5, 10.7) — is `{"findings": […]}`; a refused preview instead keeps the preview document form, its `mapping`, `files`, and `delta` `null` (6.6). +* A finding (14) is `{"code", "message", "locations", "path", "identities"}`: the stable code, the token string 14 assigns (`null` where 14 assigns none); the human-readable description; one `{"file", "range"}` per offending construct — ordered by file path bytes, then range start, then range end — empty for conditions without in-source locations; the concerned file or path (`null` for located conditions); and the identities or other context strings the condition names (14), empty where none — content contractual exactly where 14 states it for the condition or reason (14.12's enumeration, a condition's named context entity such as 14.11's foreign module, a refusal reason's concerned identity), otherwise informational: deterministic (12.0), its composition unpinned. Wherever a document carries findings they form the array member `"findings"`, ordered by code — the numbered conditions in numeric order, then the refusal reasons in the order 14 lists them, then code-less findings — then by locations, compared element-wise — each element by file path bytes, then range start, then range end; a sequence that is a proper prefix of another sorts first — then by concerned path (`null` before any path; paths compare byte-wise whatever their presentation form (12.0) — a marked byte-form path and a plain string sort in one byte order), then by identities, compared element-wise under the same prefix rule (string elements by bytes, 12.0), then by message; findings identical in every member collapse to one, so the order is total. A report whose defined content is findings alone — `build` and `check` reports, the findings of refusing reads (13.3), refused operations (6.4, 6.5, 10.7) — is `{"findings": […]}`; a refused preview instead keeps the preview document form, its `mapping`, `files`, and `delta` `null` (6.6). * A reference occurrence record (5.7) is `{"file", "range", "kind", "source", "target"}`: the referencing file; the occurrence's own range; its edge kind, `"depends"`, `"embeds"`, or `"references"` (5.2); its source graph node, `{"identity", "range"}` or unavailable (11.2); and its resolved target's identity. Document forms — each a single JSON document whose top level is an object; every one below except `version`'s and the exit-2 error document carries the consulted domain's findings (11.2, 11.6, 6.6) under `"findings"`: @@ -753,7 +753,7 @@ The defined error conditions — also the findings that accompany answers over a 7. Unknown TypeScript reference: a marker or `text` call that does not resolve; this is also a type error against the generated module. 8. Invalid argument: a `d` or `text(...)` reference that is not static per 2.4, a non-static bare reference in TypeScript expression-statement position (4.5), a `text(...)` call without exactly one argument, or a string-form `text(...)` argument in a TypeScript file (4.3). 9. Cycle: a dependency cycle (with the full path) or a spec import cycle. -10. Stale generated output, in four forms. Per file: a generated module or companion (13.1) or emitted Markdown file (13.2) whose content does not match what the current sources and configuration generate, or a recorded derived file (13.3) remaining at a path the current sources and configuration no longer generate — the error names the file and instructs rebuilding. As one unit: graph data that is missing or does not match the current sources and configuration — the comparison of 13.3, the recorded derived-file paths excluded, so a lagging record alone is never staleness — or recorded generation state that exists but cannot be read as a record (14.23): one finding either way, instructing rebuilding, whose concerned path is the graph-data area (11.6) — the record's layout is unenumerated (13.3), so no path inside the area is named. The unit forms are exclusive — an unreadable record (14.23) reports under its own form alone, never the mismatch form beside it — and while that state holds the recorded-file form, consulting no readable record, is undetectable (14). Reported by `check` only: `build` cannot observe staleness because it regenerates every derived file (12.1). +10. Stale generated output, in four forms. Per file: a generated module or companion (13.1) or emitted Markdown file (13.2) that is missing or does not match what the current sources and configuration generate, or a recorded derived file (13.3) remaining at a path the current sources and configuration no longer generate — the error names the file and instructs rebuilding. The per-file content comparison judges the path's occupant itself, never traversing a symbolic link (13.4): it matches only a plain file holding exactly the generated content, so a symbolic link (whatever its target holds), a directory, or any other non-plain-file occupant is stale, exactly as a missing or content-differing file is. As one unit: graph data that is missing or does not match the current sources and configuration — the comparison of 13.3, the recorded derived-file paths excluded, so a lagging record alone is never staleness — or recorded generation state that exists but cannot be read as a record (14.23): one finding either way, instructing rebuilding, whose concerned path is the graph-data area (11.6) — the record's layout is unenumerated (13.3), so no path inside the area is named. The unit forms are exclusive — an unreadable record (14.23) reports under its own form alone, never the mismatch form beside it — and while that state holds the recorded-file form, consulting no readable record, is undetectable (14). Reported by `check` only: `build` cannot observe staleness because it regenerates every derived file (12.1). 11. Cross-module text call: a node passed to the `text` export of a spec module other than its own; additionally a TypeScript type error and a runtime throw per 4.4. 12. Policy violation: one finding per violation (7.5) — per rule and offending edge. The offending entity is a graph edge, not a spelling: the finding carries no in-source locations and concerns no path — `locations` empty, `path` `null` (12.7) — so it is no file's finding (11.2) and accompanies no answer of 11.3–11.5. Its `identities` are, in order, the violated rule's name and the edge's source identity, kind token (12.7), and target identity. Reported by `check` only: policy constrains the workspace graph, not source validity, and `build` regenerates output regardless of policy findings (7.5, 12.1). 13. Journal error: malformed, conflicting, or unreplayable entries, naming the lines; a journal path occupied by anything other than a plain file (13.4). From 926ce74846361144c982be220ce6708980781898 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 14:41:40 +0000 Subject: [PATCH 029/226] sdg(phase-4): pin gated-read precedence and move-destination component causes (iter 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: C1, I1, O2, O3 - C1: one precedence statement for the 13.3-gated reads (12.0) — their argument checks precede the invalid-workspace gate, judged per 6.4's parse-local pattern with unparseable-named-file masking, so unknown names exit 2 whatever the workspace's findings; the gate precedes session reading, so a corrupt session (14.21) reports only on a passing workspace and item-ID checks are masked by the corruption (13.3, 10.1, 10.7, 14.21 aligned). - I1: a non-directory-occupied (symbolic link included) workspace-relative directory component of a move destination or of a derived path the destination would generate is one refusal cause under refused-invalid-destination (6.5, 14), disclaimed in 14.22 so refusal reports stay refusal-reasons-only and the finishing-regeneration infallibility claim holds; writes create nonexistent intermediate directories (13.4). - O2: 11.2 closing label no longer overstates — "Never stale; writing nothing on a failing workspace." - O3: duplicate profile/rule names named explicitly in 14.14. Rejected: O1 — modularization stays deferred, consistent with prior rounds; the Reviewer records it for completeness and states nothing reopens that decision. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/SPEC.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/specs/SPEC.md b/specs/SPEC.md index a36037a..af7e0a5 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -316,7 +316,7 @@ xspec move # # [--preview] The first form relocates an entire source file; IDs are unchanged and every node's identity changes only in its file part. Relocation also rewrites the moved file's own import specifiers, and the paths by which other files import the moved file's generated module, so all references continue to resolve. The second form extracts a section subtree: the section and its descendants are removed from the origin, inserted as the last child of the target parent (or at the end of the file for a top-level `new-id`), and re-identified by prefix replacement of `` with ``. The target file is the discovered spec source occupying the target path when one occupies it; at a target path nothing occupies, the target file is created, empty before insertion; a target path occupied by anything else is refused (below). The second form's text edits are exact: the moved text is the section construct's own characters — from the first character of its opening tag through the last character of its closing tag, or the self-closing tag's own characters for a self-closing section (1.1). At the origin it is deleted in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3). It is inserted immediately before the target parent's closing tag — at the end of the file for a top-level `new-id` — followed by a U+000A line terminator, and preceded by one when the insertion point is not at the start of a line. A self-closing target parent (1.1) is first rewritten to the paired form: its `/` and any whitespace immediately before or after the `/` are deleted, and the closing tag matching the opening tag's name (`` or ``) is appended immediately after the tag's terminating `>`; the insertion rule then applies before that closing tag. Beyond these edits, the identity and reference rewrites of this section, and the finishing regeneration, a move changes no bytes. In both forms, all references across the workspace are rewritten to resolve to the new identities, converting between local and imported forms and adding or removing spec module imports as the rewrite requires — an import is added when a rewritten reference needs a module binding its file lacks, and an existing spec module import is removed exactly when its binding had references and the rewrite leaves it with none (an import whose binding was already unreferenced stays, 2.1) — and the full mapping is appended to the journal. Import edits are exact. An import removal deletes the declaration's own characters in place, and lines left empty or whitespace-only purely by that deletion are dropped with their line terminators, exactly as in Markdown compilation (3): the removal's extent is the declaration plus any such adjunct drop. An added import binds fresh identifiers colliding with no binding already in the file (2.1, 4) and is inserted as a line of its own — the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — at an offset where the file's grammar permits an import declaration; the identifier choice and the insertion offset are implementation latitude, exercised deterministically: rewritten file content is byte-deterministic for a given operation and workspace state (6.1), and in a file existing before the operation the offset is exactly the one the operation's preview reports (6.6) — an addition into a created target file is subsumed, with the rest of that file's initial content, by the preview's file-creation class (6.6), its placement pinned by byte-determinism alone. A successful move regenerates derived files, and reports its applied mapping, as rename does (6.4). -A move operand is classified by spelling alone: an operand containing `#` is a `#` pair under the split of 12.0 and one without is a file — an invocation mixing the two synopses' forms therefore matches neither and is a usage error (12.0), and the file form cannot spell a `#`-containing path: a harmless limit, such paths being invalid source paths (14.19). Move validation mirrors rename validation, including the valid-workspace precondition (6.4) and the usage-error classification — existence and kind judged as in 6.4, both forms' origin operands naming discovered spec sources — of a nonexistent or wrong-kind origin file or a nonexistent origin ID (12.0); the mirrored checks read in identity terms: the new ID is valid, structural parent rules remain satisfied, all rewritten references resolve, the new identity differs from the old — a cross-file section move keeping its ID is therefore valid, while the exact self-move, `#` equal to `#`, is refused and appends no journal entry — and, in the section form, ``, and each ID its prefix replacement produces, collides with no ID remaining in the target file after the removal. Move additionally MUST refuse: a move that would create an import cycle among spec source files or a dependency cycle; a file-form move whose destination path is already occupied — by whatever kind of filesystem object, a symbolic link included; a section-form move whose target path is occupied by anything other than a discovered spec source — a directory, a symbolic link (discovery never follows one, 7), or any other occupant discovery does not yield as a spec source: neither an insertion target nor an absent path to create; a section-form move whose target parent — the target file's section bearing `` minus its final segment, needed whenever `` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal; and a move whose destination file path (including a target file to be created) would not be a valid discovered spec source after the move — a path belonging to no configured spec group (a move never takes a node out of the workspace), belonging to a code group as well (14.14), containing `#`, not valid UTF-8, or lacking the `.mdx` extension (14.19). These refusals keep every successful move's finishing regeneration (6.4) on a valid workspace, so it cannot fail. Each distinct refusal reason carries its stable code and location (14); with `--preview`, the operation is planned and reported but performed on nothing (6.6). +A move operand is classified by spelling alone: an operand containing `#` is a `#` pair under the split of 12.0 and one without is a file — an invocation mixing the two synopses' forms therefore matches neither and is a usage error (12.0), and the file form cannot spell a `#`-containing path: a harmless limit, such paths being invalid source paths (14.19). Move validation mirrors rename validation, including the valid-workspace precondition (6.4) and the usage-error classification — existence and kind judged as in 6.4, both forms' origin operands naming discovered spec sources — of a nonexistent or wrong-kind origin file or a nonexistent origin ID (12.0); the mirrored checks read in identity terms: the new ID is valid, structural parent rules remain satisfied, all rewritten references resolve, the new identity differs from the old — a cross-file section move keeping its ID is therefore valid, while the exact self-move, `#` equal to `#`, is refused and appends no journal entry — and, in the section form, ``, and each ID its prefix replacement produces, collides with no ID remaining in the target file after the removal. Move additionally MUST refuse: a move that would create an import cycle among spec source files or a dependency cycle; a file-form move whose destination path is already occupied — by whatever kind of filesystem object, a symbolic link included; a section-form move whose target path is occupied by anything other than a discovered spec source — a directory, a symbolic link (discovery never follows one, 7), or any other occupant discovery does not yield as a spec source: neither an insertion target nor an absent path to create; a section-form move whose target parent — the target file's section bearing `` minus its final segment, needed whenever `` has more than one segment — is missing or lies within the moved subtree, leaving no insertion point after the removal; and a move whose destination file path (including a target file to be created) would not be a valid discovered spec source after the move, or whose destination could not be written and regenerated — a path belonging to no configured spec group (a move never takes a node out of the workspace), belonging to a code group as well (14.14), containing `#`, not valid UTF-8, or lacking the `.mdx` extension (14.19); or a workspace-relative directory component of the destination path, or of a derived path the destination would generate (13.1, 13.2, 7.3), occupied by anything other than a directory — a symbolic link included, whatever it targets: discovery never traverses one (7) and writes never traverse or replace one (13.4, 14.22); a nonexistent component is never a cause, since writes create those (13.4). These refusals keep every successful move's finishing regeneration (6.4) on a valid workspace — the destination's write paths vetted here, the rewritten sources lying under real directories (7: discovery never traverses a symbolic link), and every other regenerated path vetted by the valid-workspace precondition (14.22) — so it cannot fail. Each distinct refusal reason carries its stable code and location (14); with `--preview`, the operation is planned and reported but performed on nothing (6.6). ### 6.6 Previews @@ -459,7 +459,7 @@ Review turns graph results into a staged checklist. xspec separates the review m ### 10.1 Sessions -A review session is stored at `.xspec/reviews/.json` as a plain, deterministic file. A session name MUST consist of one or more characters from `A–Z`, `a–z`, `0–9`, `.`, `_`, and `-`, and MUST NOT begin with `.`; any other name is a usage error (12.0). Session names are case-sensitive, but so that session files stay unambiguous on case-insensitive filesystems, a name that matches an existing session's name ignoring ASCII case is treated at `review create` as the name of an existing session and refused (10.7); every other subcommand matches names exactly. A session is a durable task ledger for a specific graph state (13.4), not a source of requirement identity. Only a directory entry directly under `.xspec/reviews/` named `.json` with a valid session name is a session; any other entry there is not a session and is ignored by every command, `check` included. A session file that exists but is not a plain file (13.4), cannot be parsed, or violates a session invariant — the fields of 10.2 present and well-formed, statuses drawn from 10.3, item `id`s unique within the session, `blockedBy` naming only item `id`s present in the session and containing no cycle (no item transitively blocks itself; every built-in strategy and `split` produce only acyclic blocking, so a cycle can only enter by external modification), at most one item per kind and scope node (10.5), and the recorded creation parameters and decompositions (10.7) well-formed — is corrupt (14.21): every `review` subcommand naming that session reports the corruption and exits 1, modifying nothing, and `list` reports it as corrupt (10.7). +A review session is stored at `.xspec/reviews/.json` as a plain, deterministic file. A session name MUST consist of one or more characters from `A–Z`, `a–z`, `0–9`, `.`, `_`, and `-`, and MUST NOT begin with `.`; any other name is a usage error (12.0). Session names are case-sensitive, but so that session files stay unambiguous on case-insensitive filesystems, a name that matches an existing session's name ignoring ASCII case is treated at `review create` as the name of an existing session and refused (10.7); every other subcommand matches names exactly. A session is a durable task ledger for a specific graph state (13.4), not a source of requirement identity. Only a directory entry directly under `.xspec/reviews/` named `.json` with a valid session name is a session; any other entry there is not a session and is ignored by every command, `check` included. A session file that exists but is not a plain file (13.4), cannot be parsed, or violates a session invariant — the fields of 10.2 present and well-formed, statuses drawn from 10.3, item `id`s unique within the session, `blockedBy` naming only item `id`s present in the session and containing no cycle (no item transitively blocks itself; every built-in strategy and `split` produce only acyclic blocking, so a cycle can only enter by external modification), at most one item per kind and scope node (10.5), and the recorded creation parameters and decompositions (10.7) well-formed — is corrupt (14.21): every `review` subcommand naming that session — on a workspace passing `build`'s validations, the only state in which a `review` subcommand reads a session (13.3, 12.0) — reports the corruption and exits 1, modifying nothing, an item ID named beside the session masked by the corruption (12.0), and `list` reports it as corrupt (10.7). ### 10.2 Items @@ -534,7 +534,7 @@ xspec review export [--json] `review create` requires exactly one of `--base`, `--strategy audit`, or `--coverage`; supplying none, more than one, or any other `--strategy` value is a usage error (12.0). `create` records the session's creation parameters in the session file, fully resolved: a baseline session records the commit identity `--base` resolved to at creation, a `coverage` session records the named profile's definition — its 7.4 fields, with each group name replaced by that group's configured glob list and kind — and an audit session records none. Every later generator run (10.4, 10.5) uses the recorded parameters — the recorded commit as the baseline, the recorded globs matched against the currently discovered sources (7) — so renaming or editing refs, profiles, or groups after `create` never changes the recorded parameters the session runs with. Discovery itself still follows the current configuration: a file that no longer belongs to any configured group is out of the session's view, exactly as if deleted. A `review` command that cannot resolve or reconstruct the recorded (or, at `create`, the given) baseline fails per 6.3 as a usage error (12.0), modifying nothing. A `coverage` session contains one `uncovered-requirement` item per uncovered required node of the profile — scope: that node; context: its ancestor chain; origin and `blockedBy` empty. -`list` reports every session, in byte order of session name, with its name, strategy, and item counts by status — counted from stored statuses, without the read-time invalidation of 10.4 — and reports each corrupt session (14.21) by name as corrupt in place of those fields; `list` exits 1 when any session is corrupt and 0 otherwise. `status ` reports the session's items in item order — each with id, kind, scope, status, and blocked state — plus totals by status. `show ` reports the full item: every field of 10.2 plus the same self-contained text payload as `next --json`. `export ` emits the entire session as a single JSON document — its only output form, with or without `--json`: the session's name, strategy, recorded creation parameters, and recorded decompositions, plus every item in item order, each with every field of 10.2, its blocked state, and the same self-contained text payload as `next --json`, with read-time invalidation (10.4) applied. +`list` reports every session, in byte order of session name, with its name, strategy, and item counts by status — counted from stored statuses, without the read-time invalidation of 10.4 — and reports each corrupt session (14.21) by name as corrupt in place of those fields; `list` exits 1 when any session is corrupt and 0 otherwise — on a workspace failing `build`'s validations the gate's report replaces all of this (13.3, 12.0). `status ` reports the session's items in item order — each with id, kind, scope, status, and blocked state — plus totals by status. `show ` reports the full item: every field of 10.2 plus the same self-contained text payload as `next --json`. `export ` emits the entire session as a single JSON document — its only output form, with or without `--json`: the session's name, strategy, recorded creation parameters, and recorded decompositions, plus every item in item order, each with every field of 10.2, its blocked state, and the same self-contained text payload as `next --json`, with read-time invalidation (10.4) applied. `next` returns the first item in the session's item order (10.5, 10.6, or the coverage order below) that needs review (`unresolved` or `invalidated`, 10.3) and is unblocked. When no item qualifies, every item is resolved — with acyclic `blockedBy` (10.1) a minimal needing-review item is always unblocked, so no other case exists — and `next` exits 0 and reports the session fully resolved (a session with no items reports the same) in both human and `--json` output; the JSON payload then contains no item. With `--json`, the payload MUST be self-contained, so the item can be acted on without further reads: every scope, context, and origin node, under its current identity and presence (10.4) and — for a present graph node, requirement node and code location alike — with its source range (1.7; an absent node carries none); the item's `baseline` and `current` hashes (10.2); and text per item kind. Scope text is the scope root's subtree text for `subtree-coherence`, the scope node's subtree text for `uncovered-requirement`, and the scope node's own text for `parent-consistency`, `dependency-consistency`, and `metadata-consistency`; a code location has no text value, so a `code-impact` scope enters as identity, presence, and — when present — source range, with no text. Context text is own text where the context is an ancestor chain (`subtree-coherence`, `uncovered-requirement`) and subtree text otherwise (`parent-consistency` branch children; `dependency-consistency`, `metadata-consistency`, and `code-impact` targets). Origin text is a before/after pair of the node's own text: before from the item's `baseline` state (10.2), after from the current graph. Every text value is the expanded value of 1.6. A present node's text is read from the current graph; an absent node's is its value in the most recent graph state that contained it, among the item's `baseline` state and the states under which mutating subcommands (13.5) derived the item with that node among its nodes (10.2, 10.5) — a node contained in none, and the absent side of a before/after pair, is presented absent, with no text. A `coverage` session's `uncovered-requirement` items are ordered by file path, then document order. @@ -577,7 +577,7 @@ xspec query reachable --from --to [--kinds ] **Consulted domain, findings, exits.** Every answer of 11.3–11.5 has a consulted domain of files, defined per surface, and the findings (14) of every domain file accompany the answer — a masked file's parse-failure finding included. A finding is a domain file's exactly when one of its locations (14) lies in that file or that file is its concerned path (14.19); a condition several files jointly violate — a cross-file cycle (14.9) — accompanies the answer, whole, whenever any participating file lies in the domain. An invocation whose answer carries any finding or any explicitly-unavailable datum exits 1 with the full answer document still emitted — exit 1 signals imperfection and never withholds the answer; a complete, finding-free answer exits 0; usage and configuration errors keep exit 2 and their precedence (12.0, 14.14). The argument checks of 11.3–11.5 precede answering, as `rename`'s and `move`'s argument checks precede source validation (12.0): a malformed `--to` spelling or invalid glob pattern (11.3, 11.1), a `` operand outside the domain or of the wrong kind (11.4, 11.5), an ill-formed or out-of-range offset (11.5), and every other usage error of these surfaces exits 2, whatever findings the workspace or the named files carry. A possibly-incomplete answer is therefore never silent. -**Never stale, never writing.** These surfaces never answer from stale graph data: on a workspace that passes the validations of `xspec build` (12.1) they participate in read-time refresh exactly as the reads of 13.3 do; on one that fails them — source validation errors, journal errors (14.13), and refused writes (14.22) alike (13.3) — they answer from the current sources and modify nothing: no graph data, no derived files. A gate condition that is a finding of no domain file — the journal's (14.13), a write path's (14.22) — accompanies no answer of these surfaces: on the failing side these answers consult no journal and no record and write nothing, and on the passing side no such finding exists. An answer's findings are its domain files' findings alone, and a complete, finding-free answer exits 0 (above) whatever journal or write-path state the workspace holds. +**Never stale; writing nothing on a failing workspace.** These surfaces never answer from stale graph data: on a workspace that passes the validations of `xspec build` (12.1) they participate in read-time refresh exactly as the reads of 13.3 do; on one that fails them — source validation errors, journal errors (14.13), and refused writes (14.22) alike (13.3) — they answer from the current sources and modify nothing: no graph data, no derived files. A gate condition that is a finding of no domain file — the journal's (14.13), a write path's (14.22) — accompanies no answer of these surfaces: on the failing side these answers consult no journal and no record and write nothing, and on the passing side no such finding exists. An answer's findings are its domain files' findings alone, and a complete, finding-free answer exits 0 (above) whatever journal or write-path state the workspace holds. ### 11.3 `xspec occurrences` @@ -651,7 +651,7 @@ Recorded state that exists but cannot be read as a record is condition 23 (14), * A workspace-relative path that is not valid UTF-8 (14.19) has no plain string form. Wherever an output carries one — a discovered source or derived path in the inventory (11.6), an occurrence's referencing file (11.3), a per-file view's file or an import's resolved target (11.4), a finding's location file or concerned path (14) — it is presented in an explicitly marked byte form (12.7) that carries the path's exact bytes and is distinguishable from every plain path string, deterministically; a valid-UTF-8 path is never presented in the marked form. No identity carries such a path — no node of such a file has a defined identity (11.2) — and no argument value names one (argument values are UTF-8, above). * Where this specification calls for one shortest path and several shortest paths qualify, the reported one is the least by element-wise byte comparison of the paths' node-identity sequences. * Exit codes partition all outcomes; every defined failure belongs to exactly one class. `0` — success, including informational reports (`ids`, `show`, `impact`, `query`, the `review` read subcommands including `next` with nothing to review, `coverage` without `--check`, `version`) and complete, finding-free answers (11.2, 11.6). `1` — findings: source, workspace, and operation validation failures (`build` on invalid sources, `check` findings, `coverage --check` with uncovered requirements, refused `rename`/`move` and their refused previews (6.4–6.6), refused review operations (10.7), `review` subcommands naming a corrupt session and `review list` reporting one (14.21)), and answers carrying findings or explicitly-unavailable data — emitted in full, with exit 1 (11.2, 11.6, 6.6). `2` — usage and configuration errors: unknown commands or flags; missing required flags or arguments; invalid flag values; unknown profiles, sessions, groups, review items, node identities, or files named in arguments — except on `occurrences --to`, where only a malformed identity spelling is a usage error and an unknown or unresolving one selects nothing (11.3); wrong-kind operands — a code source named where a spec source is required (6.4, 6.5, 11.4, 11.5); invalid session names; missing or invalid configuration (14.14), which never reaches `version` (12.6); a baseline that cannot be read or reconstructed (6.3); a mutating command refused because another is running (13.5). -* The argument checks of `rename` and `move` (a nonexistent origin file or old ID; a wrong-kind, non-spec-source origin file, 6.4, 6.5) and baseline resolution (6.3) precede source validation: these usage errors are reported, and the command exits 2, even when the current workspace also fails the validations of `xspec build` (6.4, 13.3) — as configuration errors precede all source analysis (14.14). An old ID inside an unparseable origin file (14.20) is masked (14): there the validation findings are reported and the command exits 1. Within exit class 2, an error the invocation's syntax alone determines — an unknown command or flag, a repeated flag, a missing required flag or argument, a malformed value — is reported without loading configuration; a configuration error (14.14) precedes every argument check that consults configuration, discovery, or the workspace, the unknown names and files of the usage class included. +* The argument checks of `rename` and `move` (a nonexistent origin file or old ID; a wrong-kind, non-spec-source origin file, 6.4, 6.5) and baseline resolution (6.3) precede source validation: these usage errors are reported, and the command exits 2, even when the current workspace also fails the validations of `xspec build` (6.4, 13.3) — as configuration errors precede all source analysis (14.14). An old ID inside an unparseable origin file (14.20) is masked (14): there the validation findings are reported and the command exits 1. The reads 13.3 gates (`ids`, `show`, `coverage`, `impact`, `review`, `query`) observe the same precedence: their argument checks precede the invalid-workspace report of 13.3, so a usage-error argument — an unknown or wrong-kind name included — exits 2 whatever findings the workspace carries. Each check is judged from what it consults, identically on valid and failing workspaces: a profile or group name against the configuration (7.4, 7.5, 11.1), a session name against the session directory (10.1), and a requirement-node or graph-node identity parse-local against the named file, as 6.4 judges the old ID — a discovered path of the identity's kind (11.1), an `id` over the file's spelled identities (11.2), a code unit over the file's named units (4.6) — an unparseable named file masking the check as in 6.4, the gated report of 13.3 then exiting 1. One check runs past the gate: an item ID is judged against its session's content, which no gated command reads on a failing workspace (13.3) and a corrupt session withholds — the corruption reported in the check's place (10.1, 14.21). Within exit class 2, an error the invocation's syntax alone determines — an unknown command or flag, a repeated flag, a missing required flag or argument, a malformed value — is reported without loading configuration; a configuration error (14.14) precedes every argument check that consults configuration, discovery, or the workspace, the unknown names and files of the usage class included. ### 12.1 `xspec build` @@ -717,7 +717,7 @@ Document forms — each a single JSON document whose top level is an object; eve ### 13.3 Graph data -xspec maintains graph data under `.xspec/`, containing requirement nodes, code locations, edges by kind, reference occurrences (5.7), source ranges (1.7), all four hashes, coverage attributes, tags, and the paths of the derived files most recently generated (13.4) — the generated modules with their companions (13.1) and the emitted Markdown (13.2); graph data records no paths of its own, its layout staying deliberately unenumerated (11.6). Graph data serves `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at`. Read results never come from stale data. On a workspace that passes the validations of `xspec build` (12.1), when graph data is missing or does not match the current sources and configuration (a comparison from which the recorded derived-file paths are excluded — refresh leaves them unchanged, so a lagging record alone is never staleness), `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at` refresh it — writing exactly what `xspec build` would write, except that no TypeScript or Markdown is generated or removed and the recorded derived-file paths are left unchanged — before answering; running only where `build` would succeed, refresh, like the finishing regeneration of 6.4, cannot fail. The record is left unchanged in every state: recorded state that exists but cannot be read as a record (14.23) is neither read, repaired, nor replaced by a refresh — these reads never consult the record and report no finding for it — so the state persists, met by the surfaces that consult the record (11.6, 6.6) and reported as staleness by `check` (14.10), until a successful `build` (12.1) or the finishing regeneration of `rename`/`move` (6.4, 6.5) replaces the record. When the current workspace fails the validations of `xspec build` — source validation errors, journal errors (14.13), and refused writes (14.22) alike: the findings a `build` would now report — `ids`, `show`, `coverage`, `impact`, `review`, and `query` report exactly those findings and exit 1 without answering, while `occurrences`, `view`, and `at` answer from the current sources per 11.2, which states the findings accompanying their answers; in either case nothing is modified: every derived file and all graph data remain byte-for-byte as they were, as after a failed `build` (12.1). `check` never refreshes; it reports staleness instead (14.10). `inventory` neither refreshes nor writes (11.6), and a preview writes nothing (6.6). Graph data is byte-deterministic for a given workspace (12.0); its content is otherwise opaque — graph data's observable contract is its location under `.xspec/`, its classification as a derived file (13.4), and the refresh, failure, and staleness behaviors above and in 14.10. +xspec maintains graph data under `.xspec/`, containing requirement nodes, code locations, edges by kind, reference occurrences (5.7), source ranges (1.7), all four hashes, coverage attributes, tags, and the paths of the derived files most recently generated (13.4) — the generated modules with their companions (13.1) and the emitted Markdown (13.2); graph data records no paths of its own, its layout staying deliberately unenumerated (11.6). Graph data serves `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at`. Read results never come from stale data. On a workspace that passes the validations of `xspec build` (12.1), when graph data is missing or does not match the current sources and configuration (a comparison from which the recorded derived-file paths are excluded — refresh leaves them unchanged, so a lagging record alone is never staleness), `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, and `at` refresh it — writing exactly what `xspec build` would write, except that no TypeScript or Markdown is generated or removed and the recorded derived-file paths are left unchanged — before answering; running only where `build` would succeed, refresh, like the finishing regeneration of 6.4, cannot fail. The record is left unchanged in every state: recorded state that exists but cannot be read as a record (14.23) is neither read, repaired, nor replaced by a refresh — these reads never consult the record and report no finding for it — so the state persists, met by the surfaces that consult the record (11.6, 6.6) and reported as staleness by `check` (14.10), until a successful `build` (12.1) or the finishing regeneration of `rename`/`move` (6.4, 6.5) replaces the record. When the current workspace fails the validations of `xspec build` — source validation errors, journal errors (14.13), and refused writes (14.22) alike: the findings a `build` would now report — `ids`, `show`, `coverage`, `impact`, `review`, and `query` report exactly those findings and exit 1 without answering — only their argument checks precede this report (12.0), and nothing is evaluated past it: a `review` subcommand then reads no session file, so a session's corruption (14.21) is reported exactly where sessions are read, on a workspace passing `build`'s validations (10.1, 12.0) — while `occurrences`, `view`, and `at` answer from the current sources per 11.2, which states the findings accompanying their answers; in either case nothing is modified: every derived file and all graph data remain byte-for-byte as they were, as after a failed `build` (12.1). `check` never refreshes; it reports staleness instead (14.10). `inventory` neither refreshes nor writes (11.6), and a preview writes nothing (6.6). Graph data is byte-deterministic for a given workspace (12.0); its content is otherwise opaque — graph data's observable contract is its location under `.xspec/`, its classification as a derived file (13.4), and the refresh, failure, and staleness behaviors above and in 14.10. ### 13.4 Derived and durable files @@ -728,7 +728,7 @@ Every file xspec writes is a plain file suitable for committing, written with st Derived-file paths belong to xspec: writing a derived file replaces whatever exists at its path, whether or not xspec wrote it. Derived files are never sources: paths whose file name contains `.xspec.`, files under `.xspec/`, and files at the configured Markdown emit destinations (7.3) are excluded from every spec and code group (7). -Writes never traverse symbolic links. A symbolic link at a derived file's path is an occupant like any other: the write replaces the link itself, and nothing is ever written through it. A durable file's path occupied by a symbolic link — or by anything other than a plain file — is never read, appended to, or replaced: such a journal is a journal error (14.13), such a session corrupt (10.1, 14.21). A write path having a symbolic link at a workspace-relative directory component is refused (14.22); path components above the workspace root are unrestricted. +Writes never traverse symbolic links. A symbolic link at a derived file's path is an occupant like any other: the write replaces the link itself, and nothing is ever written through it. A durable file's path occupied by a symbolic link — or by anything other than a plain file — is never read, appended to, or replaced: such a journal is a journal error (14.13), such a session corrupt (10.1, 14.21). A write path having a symbolic link at a workspace-relative directory component is refused (14.22; a move's destination-side case is instead the refusal of 6.5); path components above the workspace root are unrestricted. A write brings the nonexistent workspace-relative directory components of its path into existence as directories — the file-form move's fresh destination directories, a created target file's (6.5), and a first emission under `markdown.outDir` (7.3) alike — so a missing intermediate directory never refuses or fails a write. ### 13.5 Concurrency and isolation @@ -757,18 +757,18 @@ The defined error conditions — also the findings that accompany answers over a 11. Cross-module text call: a node passed to the `text` export of a spec module other than its own; additionally a TypeScript type error and a runtime throw per 4.4. 12. Policy violation: one finding per violation (7.5) — per rule and offending edge. The offending entity is a graph edge, not a spelling: the finding carries no in-source locations and concerns no path — `locations` empty, `path` `null` (12.7) — so it is no file's finding (11.2) and accompanies no answer of 11.3–11.5. Its `identities` are, in order, the violated rule's name and the edge's source identity, kind token (12.7), and target identity. Reported by `check` only: policy constrains the workspace graph, not source validity, and `build` regenerates output regardless of policy findings (7.5, 12.1). 13. Journal error: malformed, conflicting, or unreplayable entries, naming the lines; a journal path occupied by anything other than a plain file (13.4). -14. Configuration error: missing or invalid configuration — a configuration file that is not well-formed TypeScript or not in the declarative form of 7; missing required fields, unknown keys (7), or invalid profile, rule, or group shapes; group names referenced by profiles, rules, or selectors that are unknown or not of the kind the reference requires (7.4, 7.5); ambiguous kinds (7.4, 7.5); an empty `edgeKinds`, `targetTags`, rule `kinds`, or selector `tags` list; a capture violation (7.5); a glob or `markdown.outDir` resolving outside the workspace root (7, 7.3); a file matched by both a spec and a code group. Reported by every command that loads the configuration — every command but `version` (12.6), which loads none — when it loads the configuration and discovers sources, as a usage error (12.0), not a finding. +14. Configuration error: missing or invalid configuration — a configuration file that is not well-formed TypeScript or not in the declarative form of 7; missing required fields, unknown keys (7), a duplicate profile or rule name (7.4, 7.5), or otherwise invalid profile, rule, or group shapes; group names referenced by profiles, rules, or selectors that are unknown or not of the kind the reference requires (7.4, 7.5); ambiguous kinds (7.4, 7.5); an empty `edgeKinds`, `targetTags`, rule `kinds`, or selector `tags` list; a capture violation (7.5); a glob or `markdown.outDir` resolving outside the workspace root (7, 7.3); a file matched by both a spec and a code group. Reported by every command that loads the configuration — every command but `version` (12.6), which loads none — when it loads the configuration and discovers sources, as a usage error (12.0), not a finding. 15. Invalid import: in an xspec source file, an import that is not a single default binding, does not designate an xspec source file belonging to a configured spec group, or binds the identifier `S`, `Spec`, or `text` (2.1); in a TypeScript file, a `.xspec` import that does not designate such a source, a spec-module binding other than the default and `text` exports, a dynamic `import()` whose static specifier ends in `.xspec`, an export declaration or an `import X = require(…)` declaration whose specifier ends in `.xspec` (4), or an import or export declaration, `import X = require(…)`, or dynamic `import()` whose relative specifier designates a derived-file path other than through a spec module import's `.xspec` specifier (4, 13.4); in either kind of file, an import binding an identifier already bound by another import in the same file, when either import is a spec module import. 16. Invalid construct: a JSX element other than ``/``, an expression container other than a `text(...)` embedding or an MDX comment (2.7), or an export statement in a source file. 17. Invalid prop: an unknown or repeated prop, or a spread attribute, on ``/`` (2.7), an `id`, `coverage`, or `tags` value that is not a quoted-form static string literal, a `d` value that is not a braced expression (2.7), or a `coverage` value other than `required` or `none`. 18. Unsupported node usage: a spec module binding or node used in TypeScript other than as a dependency marker, a child property access, or a direct argument to a spec module's `text` export (a cross-module `text` argument is condition 11, and a non-static bare reference in expression-statement position is condition 8, not this one; a value-level use of a binding introduced type-only falls under no condition, 4.5). 19. Invalid source path: a discovered spec or code source file whose workspace-relative path contains `#` or is not valid UTF-8 (7), or a spec-group file without the `.mdx` extension (7.1). 20. Unparseable source: a spec-group file that is not well-formed MDX, a code-group file that is not well-formed TypeScript under the grammar its file name selects (`.tsx` parses as TSX, any other name as plain TypeScript), or a discovered source file of either kind that is not valid UTF-8 or begins with a byte-order mark (1.6); the error reports the location of the parse failure. -21. Corrupt review session: a session file that is not a plain file (13.4), cannot be parsed, or violates a session invariant (10.1). Reported by `check`, by any `review` subcommand naming the session, and by `review list` (exit 1); not reported by `build`, which does not read sessions. -22. Symbolic link in a write path: a workspace-relative directory component of a path xspec writes is a symbolic link (13.4). A command refuses the write and reports it before modifying anything; `check` reports it without writing. The concerned path is the offending symbolic-link component's workspace-relative path — one finding per distinct offending component, whatever write paths it refuses. A symbolic link at a derived file's own path is not an error — writing replaces the link (13.4). +21. Corrupt review session: a session file that is not a plain file (13.4), cannot be parsed, or violates a session invariant (10.1). Reported by `check`, beside a failing workspace's other findings (14); by any `review` subcommand naming the session and by `review list` (exit 1), each only on a workspace passing `build`'s validations — on a failing one the gate's findings are reported without any session being read (13.3, 12.0); not reported by `build`, which does not read sessions. +22. Symbolic link in a write path: a workspace-relative directory component of a path xspec writes is a symbolic link (13.4). A command refuses the write and reports it before modifying anything; `check` reports it without writing. The concerned path is the offending symbolic-link component's workspace-relative path — one finding per distinct offending component, whatever write paths it refuses. A symbolic link at a derived file's own path is not an error — writing replaces the link (13.4). A component under a move's destination path or under a derived path the destination would generate is the move's `refused-invalid-destination` (6.5), never this condition — a refused operation reports refusal reasons alone (below). 23. Unreadable recorded state: recorded generation state (13.3) that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise. Reported by the surfaces that read the record without refreshing it — `inventory` (11.6) and `rename`/`move` previews (6.6) — with one outcome, defined here for both: the surface's record-supplied datum — the inventory's recorded derived-file paths, the preview's delta — is reported explicitly unavailable, never fabricated and never read as an empty record; the finding accompanies the answer with its stable code; the invocation exits 1 (12.0); and every other part of the answer — every other provenance's content, every other part of the preview report — is emitted in full. The concerned path is the graph-data area (11.6): the record's layout is deliberately unenumerated (13.3), so no path inside the area is named. Not reported by `build`, whose rebuild replaces the record (12.1, 13.4), nor by the refreshing reads of 13.3, which leave the record — unreadable state included — unchanged without consulting it (13.3); `check` reports the state as staleness (14.10). -Each distinct reason `rename` and `move` refuse (6.4, 6.5) — exactly what a refused preview (6.6) reports — carries a stable code and, under the location-cardinality rule above, the file, source range, or identity it concerns, so a refusal renders as precisely as a finding; refusals are findings in the exit-code partition (12.0), and the JSON report form above carries them. A refused operation or preview reports every applicable reason together, one finding per reason — never only the first found — each reason's applicability read on its own terms below. The reasons and their codes: `refused-invalid-id` — the new ID, or an ID the prefix replacement produces, is not in intrinsic ID form (one or more segments joined by `.`, each satisfying 1.4), concerning that identity — intrinsic form only: positional conformance (1.3) is `refused-structural-parent`'s, evaluated only over intrinsically valid IDs, so no identity reports under both; `refused-identity-unchanged` — the new identity equals the old (6.4; the exact self-move of 6.5), concerning it; `refused-id-collision` — the new ID, or an ID the prefix replacement produces, collides with an ID remaining after the operation's removals — rename's prefix mapping (6.4), the section move's subtree removal (6.5) — locating every colliding bearer; `refused-structural-parent` — structural parent rules (1.3) would not remain satisfied, concerning the violated identity; `refused-unresolvable-reference` — a rewritten reference would not resolve, locating each such reference spelling; `refused-cycle` — the move would create a spec import cycle or a dependency cycle (6.5), locating the would-be cycle's full path per the cardinality rule; `refused-destination-exists` — the file form's destination path is already occupied, whatever kind of filesystem object occupies it, or the section form's target path is occupied by anything other than a discovered spec source (6.5), concerning that path; `refused-missing-target-parent` — the section form's target parent is missing or lies within the moved subtree (6.5), concerning the target-parent identity; `refused-invalid-destination` — the destination file path would not be a valid discovered spec source (6.5), concerning that path. The invalid-workspace refusal (6.4) reports the workspace's findings themselves, each under its own numbered condition and stable code — and those alone: the reasons above are defined and evaluated only over a workspace passing `build`'s validations (6.4, 6.5), so no report mixes refusal reasons with numbered conditions. +Each distinct reason `rename` and `move` refuse (6.4, 6.5) — exactly what a refused preview (6.6) reports — carries a stable code and, under the location-cardinality rule above, the file, source range, or identity it concerns, so a refusal renders as precisely as a finding; refusals are findings in the exit-code partition (12.0), and the JSON report form above carries them. A refused operation or preview reports every applicable reason together, one finding per reason — never only the first found — each reason's applicability read on its own terms below. The reasons and their codes: `refused-invalid-id` — the new ID, or an ID the prefix replacement produces, is not in intrinsic ID form (one or more segments joined by `.`, each satisfying 1.4), concerning that identity — intrinsic form only: positional conformance (1.3) is `refused-structural-parent`'s, evaluated only over intrinsically valid IDs, so no identity reports under both; `refused-identity-unchanged` — the new identity equals the old (6.4; the exact self-move of 6.5), concerning it; `refused-id-collision` — the new ID, or an ID the prefix replacement produces, collides with an ID remaining after the operation's removals — rename's prefix mapping (6.4), the section move's subtree removal (6.5) — locating every colliding bearer; `refused-structural-parent` — structural parent rules (1.3) would not remain satisfied, concerning the violated identity; `refused-unresolvable-reference` — a rewritten reference would not resolve, locating each such reference spelling; `refused-cycle` — the move would create a spec import cycle or a dependency cycle (6.5), locating the would-be cycle's full path per the cardinality rule; `refused-destination-exists` — the file form's destination path is already occupied, whatever kind of filesystem object occupies it, or the section form's target path is occupied by anything other than a discovered spec source (6.5), concerning that path; `refused-missing-target-parent` — the section form's target parent is missing or lies within the moved subtree (6.5), concerning the target-parent identity; `refused-invalid-destination` — the destination file path would not be a valid discovered spec source, or a workspace-relative directory component of it or of a derived path it would generate is occupied by anything other than a directory (6.5), concerning the destination path. The invalid-workspace refusal (6.4) reports the workspace's findings themselves, each under its own numbered condition and stable code — and those alone: the reasons above are defined and evaluated only over a workspace passing `build`'s validations (6.4, 6.5), so no report mixes refusal reasons with numbered conditions. ## 15. Example From 253e7e07e33c0e8fd4e045891a1882b06ad74d2f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:38:19 +0000 Subject: [PATCH 030/226] =?UTF-8?q?sdg(phase-4):=20close=20IP=200001=20?= =?UTF-8?q?=E2=80=94=20write-path=20occupancy,=20spells-no-identity=20find?= =?UTF-8?q?ings,=20multi-#=20split=20(iter=2013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: - C1: 13.4/14.22 generalized from symlink-only to any non-directory occupant of a workspace-relative write-path directory component; condition 22 retitled obstructed-write-path, own-path and 6.5 destination-side exclusivities kept. Closes the plain-file-at-.xspec/outDir gap; 12.1's taxonomy, 13.3's gate enumeration, and 6.5's cannot-fail regeneration argument now cover the state. - C2: condition 1 pinned to id-attribute absence; repeated/invalid-form id is condition 17 alone; condition 2's mask extended to every spells-no-identity parent (11.2), so finding sets like + child are derivable. - I1: 12.0 pins at most one '#' in //# spellings (mirroring 11.3); more is a malformed value, a usage error — split never ambiguous. - O1: 5.3 cycle detection attributed to validation (build and check alike). - O2: 7.5 pins captures as exactly $1-$9; every other $ is a literal byte. - O3: 12.0 wrong-kind illustration now cites the 11.1/12.4 node-kind checks. - O4: 6.6 delta rationale marked rationale-not-filter; degenerates toward the full set on an empty or lagging record. Rejected/deferred: - O5 (modularization): re-deferred — SHOULD-level; near-total contract locality keeps the single file; residual delegated to the downstream problems-file net per the standing closure ruling. Closing iteration per Liaison valve ruling: REVIEW.md deleted; patch 0001 Stage: Accepted -> Applied. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/SPEC.md | 20 ++++++++++---------- specs/patches/0001-external-ui-apis.md | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/specs/SPEC.md b/specs/SPEC.md index af7e0a5..7f707b4 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -245,7 +245,7 @@ The graph contains requirement nodes and code locations. ### 5.3 Cycles -Dependency-edge cycles are invalid. `xspec check` MUST detect and report cycles in the combined graph of `contains`, `depends`, and `embeds` edges over requirement nodes, including the full cycle path. A node that depends on or embeds itself is a dependency cycle of length one. In particular, a section MUST NOT depend on or embed its own ancestor, because text expansion and effectiveHash recurse through both children and dependency targets. +Dependency-edge cycles are invalid. Validation — `build` and `check` alike (14.9) — MUST detect and report cycles in the combined graph of `contains`, `depends`, and `embeds` edges over requirement nodes, including the full cycle path. A node that depends on or embeds itself is a dependency cycle of length one. In particular, a section MUST NOT depend on or embed its own ancestor, because text expansion and effectiveHash recurse through both children and dependency targets. ### 5.4 Reference canonicalization @@ -324,7 +324,7 @@ A move operand is classified by spelling alone: an operand containing `#` is a ` * the complete identity mapping the operation would journal; * every file the operation would rewrite, relocate, or create, with every edit the operation would make in it, classed as exactly one of the following and — target-file creation excepted — located by a source range (1.7) in current, pre-operation coordinates: a reference-occurrence rewrite (5.7 — `d` references, `text(...)` references, TypeScript markers); an `id`-attribute rewrite (rename's and the section move's re-identification); an import-specifier rewrite; an import addition; an import removal — import-edit extents and insertion offsets per 6.5; the section move's origin deletion — one range spanning every byte the origin edit removes: the construct's own characters, extended over the leftover whitespace and line terminator of each line the line-drop rule additionally drops (6.5, 3), bytes contiguous with the construct, so the adjunct drop lies inside this class's range rather than forming a class of its own; the section move's target insertion point; the self-closing-target-parent rewrite when one applies (6.5); the file move's relocation of the file itself; or target-file creation — reported, when the section form's target file does not yet exist, as its own class with the insertion point at the start of the new file: the one reported location without pre-operation coordinates, and the created file's only reported edit — creation composes the file's entire initial content, subsuming the insertion and the import additions the rewrite requires there (6.5), edits no pre-operation coordinates exist to locate, while the moved text's own rewrites are reported in the origin file, inside the origin deletion's range (below). A rewrite's range is the construct it rewrites — a reference occurrence's span (5.7), the `id` attribute's own characters, the import specifier literal's characters, the target parent's self-closing tag; a removal's range spans every byte its edit removes, as the origin deletion's does; an import addition's insertion point, the section move's target insertion point, and the created target file's start are zero-length ranges at the insertion offset; the relocation's range is the entire moved file. An edit is reported without replacement text: its class and the identity mapping state what changes, and the resulting bytes are observable only by running the operation — the preview is a safety report, not an edit script whose external application would bypass the journaled mapping. Reported ranges MAY nest — the section move's re-identification rewrites locate, in the same pre-operation coordinates, inside its origin deletion's range — each edit reported under its own class, containment being geometry, not double-reporting; -* the derived-file delta, both directions one datum: the derived paths the operation would newly generate — paths where nothing is currently recorded as generated — and the recorded derived paths (13.3) the operation would leave no longer generated, the pre-move module path after a file move included. The delta is the identity-relevant consequence; the full regeneration set a successful operation rewrites is workspace-constant and reported by the inventory (11.6), so the preview does not repeat it. Both directions consult the recorded derived-file paths — presence at a path cannot tell a generated occupant from a foreign one — and a preview, writing nothing, never refreshes the record. Recorded state that exists but cannot be read as a record is condition 23 (14), met here in the record-supplied datum — the delta — with the one outcome 14.23 defines. The real operation is not refused in that state — a corrupt record fails no build validation, and the finishing regeneration (6.4) replaces corrupt graph data — so the preview is not refused either: the unreadable record lies on the success side of the refusal equivalence. A refused preview consults no record — it reports the refusal findings alone, its `mapping`, `files`, and `delta` `null` (12.7) — so no condition-23 finding ever accompanies a refusal. +* the derived-file delta, both directions one datum: the derived paths the operation would newly generate — paths where nothing is currently recorded as generated — and the recorded derived paths (13.3) the operation would leave no longer generated, the pre-move module path after a file move included. The delta is the identity-relevant consequence; the full regeneration set a successful operation rewrites is workspace-constant and reported by the inventory (11.6), so the preview does not repeat it — a rationale, not a filter: both directions follow the record-based rule above, and on an empty or lagging record the newly-generated direction approaches the full regeneration set. Both directions consult the recorded derived-file paths — presence at a path cannot tell a generated occupant from a foreign one — and a preview, writing nothing, never refreshes the record. Recorded state that exists but cannot be read as a record is condition 23 (14), met here in the record-supplied datum — the delta — with the one outcome 14.23 defines. The real operation is not refused in that state — a corrupt record fails no build validation, and the finishing regeneration (6.4) replaces corrupt graph data — so the preview is not refused either: the unreadable record lies on the success side of the refusal equivalence. A refused preview consults no record — it reports the refusal findings alone, its `mapping`, `files`, and `delta` `null` (12.7) — so no condition-23 finding ever accompanies a refusal. ### 6.7 Manual restructuring @@ -407,7 +407,7 @@ Named policy rules constraining which dependency edges may exist. Each rule has: A selector matches nodes (or code locations) by exactly one of: `{ group: }`, `{ files: }`, or `{ tags: [, ...] }` (matching means carrying at least one listed tag; an empty tag list is a configuration error, 14.14). A group selector MAY include `kind: "spec" | "code"`; as with `boundaryKind` (7.4), the kind MUST be inferred when the name is unambiguous and MUST be given when the name exists as both a spec group and a code group (14.14). -In `files` selectors, the `from` pattern MAY contain capture wildcards `$1`…`$9`, each appearing at most once, and the `to` pattern MAY reference them; a `to` containing captures matches only targets whose expansion agrees with the captured values. A capture matches one or more bytes within a single path segment (never `/`). When a pattern could match a path in more than one way, the match is disambiguated across the whole pattern, left to right: each wildcard (`*`, `?`, `**`) and each capture, in pattern order, takes as few bytes as possible while a match of the remainder of the pattern still exists — so every match, and every capture value, is unique. `$1-$2.ts` against `a-b-c.ts` captures `$1 = a` and `$2 = b-c`; `*$1*` against `abc` captures `$1 = a` (the leading `*` takes the empty string). A `to` referencing a capture absent from `from` is a configuration error (14.14). +In `files` selectors, the `from` pattern MAY contain capture wildcards `$1`…`$9`, each appearing at most once, and the `to` pattern MAY reference them; a capture is exactly `$` followed by one digit `1`–`9` — every other `$`, `$0` and a trailing `$` included, is a literal byte in either pattern, never a capture or a capture violation (14.14) — and a `to` containing captures matches only targets whose expansion agrees with the captured values. A capture matches one or more bytes within a single path segment (never `/`). When a pattern could match a path in more than one way, the match is disambiguated across the whole pattern, left to right: each wildcard (`*`, `?`, `**`) and each capture, in pattern order, takes as few bytes as possible while a match of the remainder of the pattern still exists — so every match, and every capture value, is unique. `$1-$2.ts` against `a-b-c.ts` captures `$1 = a` and `$2 = b-c`; `*$1*` against `abc` captures `$1 = a` (the leading `*` takes the empty string). A `to` referencing a capture absent from `from` is a configuration error (14.14). Semantics, evaluated over dependency edges of the rule's kinds: @@ -644,13 +644,13 @@ Recorded state that exists but cannot be read as a record is condition 23 (14), * The report — findings included: a failing `build`'s validation errors and `check` findings are reports — is standard-output content; usage and configuration error messages (exit 2) and all other diagnostic text are standard-error content. JSON output is in effect exactly when `--json` appears among the invocation's arguments — governing error delivery even when the arguments are themselves the error, an unknown command or flag included — or when the invoked surface is JSON-only, a single JSON document its only output form with or without `--json` (10.7, 11, 12.6). When JSON output is in effect, the single JSON document is the entire standard output, and an invocation that fails with a usage or configuration error (exit 2) emits as its entire standard output a single JSON document reporting the error — the error document of 12.7, carrying the stable code and concerned file or path (14) where the condition defines them. When JSON output is not in effect, an exit-2 error leaves standard output empty. The output form never changes an exit code, the error-precedence rules, or standard-error content. * Every command supports `--config ` (7). * A flag MAY be given at most once per invocation; repeating a flag is a usage error. List-valued flags (`--kinds`) take one comma-separated value (11). -* Arguments that name requirement nodes, graph nodes, workspace files, or file globs (``, ``, ``, `--file`) are workspace-relative, independent of the working directory. `` and `` values are identities in the form of 1.5, their `#` splitting path from id or unit; the split applies equally to an operand spelled `#` (6.5). A bare `` operand and a `--file` glob are a whole path or pattern: `#` has no delimiter role in them, so a `#`-containing spelling names the discovered file of that invalid path (14.19, 11.4), never a `path#id` pair. `--config ` and `--test-hold ` are filesystem paths resolved against the working directory. +* Arguments that name requirement nodes, graph nodes, workspace files, or file globs (``, ``, ``, `--file`) are workspace-relative, independent of the working directory. `` and `` values are identities in the form of 1.5, their `#` splitting path from id or unit; the split applies equally to an operand spelled `#` (6.5). At most one `#` is well-formed in any such value — no identity contains one in path, id segment, or unit name (1.4, 1.5, 4.6), and 11.3 pins the same bound for `--to` — so a spelling containing more than one `#` is a malformed value, a usage error, and the split is never ambiguous. A bare `` operand and a `--file` glob are a whole path or pattern: `#` has no delimiter role in them, so a `#`-containing spelling names the discovered file of that invalid path (14.19, 11.4), never a `path#id` pair. `--config ` and `--test-hold ` are filesystem paths resolved against the working directory. * Argument values are interpreted as UTF-8; an argument value that is not valid UTF-8 is a usage error. * IDs, tags, identities, session names, and paths compare byte-wise and case-sensitively; no Unicode normalization or case folding is applied anywhere (the create-time session-name restriction of 10.1 is the sole exception). * All output, generated files, and stored data are byte-deterministic for identical input: no wall-clock values, no randomness, no absolute paths, no environment-dependent content. Invocation-anchored content is the stated exception where a section calls for it — the inventory's anchoring (11.6) and configuration-error concerned paths (14), with 11.6's no-relative-path platform case the sole absolute form — itself a pure function of invocation input, deterministic per invocation. * A workspace-relative path that is not valid UTF-8 (14.19) has no plain string form. Wherever an output carries one — a discovered source or derived path in the inventory (11.6), an occurrence's referencing file (11.3), a per-file view's file or an import's resolved target (11.4), a finding's location file or concerned path (14) — it is presented in an explicitly marked byte form (12.7) that carries the path's exact bytes and is distinguishable from every plain path string, deterministically; a valid-UTF-8 path is never presented in the marked form. No identity carries such a path — no node of such a file has a defined identity (11.2) — and no argument value names one (argument values are UTF-8, above). * Where this specification calls for one shortest path and several shortest paths qualify, the reported one is the least by element-wise byte comparison of the paths' node-identity sequences. -* Exit codes partition all outcomes; every defined failure belongs to exactly one class. `0` — success, including informational reports (`ids`, `show`, `impact`, `query`, the `review` read subcommands including `next` with nothing to review, `coverage` without `--check`, `version`) and complete, finding-free answers (11.2, 11.6). `1` — findings: source, workspace, and operation validation failures (`build` on invalid sources, `check` findings, `coverage --check` with uncovered requirements, refused `rename`/`move` and their refused previews (6.4–6.6), refused review operations (10.7), `review` subcommands naming a corrupt session and `review list` reporting one (14.21)), and answers carrying findings or explicitly-unavailable data — emitted in full, with exit 1 (11.2, 11.6, 6.6). `2` — usage and configuration errors: unknown commands or flags; missing required flags or arguments; invalid flag values; unknown profiles, sessions, groups, review items, node identities, or files named in arguments — except on `occurrences --to`, where only a malformed identity spelling is a usage error and an unknown or unresolving one selects nothing (11.3); wrong-kind operands — a code source named where a spec source is required (6.4, 6.5, 11.4, 11.5); invalid session names; missing or invalid configuration (14.14), which never reaches `version` (12.6); a baseline that cannot be read or reconstructed (6.3); a mutating command refused because another is running (13.5). +* Exit codes partition all outcomes; every defined failure belongs to exactly one class. `0` — success, including informational reports (`ids`, `show`, `impact`, `query`, the `review` read subcommands including `next` with nothing to review, `coverage` without `--check`, `version`) and complete, finding-free answers (11.2, 11.6). `1` — findings: source, workspace, and operation validation failures (`build` on invalid sources, `check` findings, `coverage --check` with uncovered requirements, refused `rename`/`move` and their refused previews (6.4–6.6), refused review operations (10.7), `review` subcommands naming a corrupt session and `review list` reporting one (14.21)), and answers carrying findings or explicitly-unavailable data — emitted in full, with exit 1 (11.2, 11.6, 6.6). `2` — usage and configuration errors: unknown commands or flags; missing required flags or arguments; invalid flag values; unknown profiles, sessions, groups, review items, node identities, or files named in arguments — except on `occurrences --to`, where only a malformed identity spelling is a usage error and an unknown or unresolving one selects nothing (11.3); wrong-kind operands — a code source named where a spec source is required (6.4, 6.5, 11.4, 11.5) or where a requirement-node identity is required (11.1, 12.4); invalid session names; missing or invalid configuration (14.14), which never reaches `version` (12.6); a baseline that cannot be read or reconstructed (6.3); a mutating command refused because another is running (13.5). * The argument checks of `rename` and `move` (a nonexistent origin file or old ID; a wrong-kind, non-spec-source origin file, 6.4, 6.5) and baseline resolution (6.3) precede source validation: these usage errors are reported, and the command exits 2, even when the current workspace also fails the validations of `xspec build` (6.4, 13.3) — as configuration errors precede all source analysis (14.14). An old ID inside an unparseable origin file (14.20) is masked (14): there the validation findings are reported and the command exits 1. The reads 13.3 gates (`ids`, `show`, `coverage`, `impact`, `review`, `query`) observe the same precedence: their argument checks precede the invalid-workspace report of 13.3, so a usage-error argument — an unknown or wrong-kind name included — exits 2 whatever findings the workspace carries. Each check is judged from what it consults, identically on valid and failing workspaces: a profile or group name against the configuration (7.4, 7.5, 11.1), a session name against the session directory (10.1), and a requirement-node or graph-node identity parse-local against the named file, as 6.4 judges the old ID — a discovered path of the identity's kind (11.1), an `id` over the file's spelled identities (11.2), a code unit over the file's named units (4.6) — an unparseable named file masking the check as in 6.4, the gated report of 13.3 then exiting 1. One check runs past the gate: an item ID is judged against its session's content, which no gated command reads on a failing workspace (13.3) and a corrupt session withholds — the corruption reported in the check's place (10.1, 14.21). Within exit class 2, an error the invocation's syntax alone determines — an unknown command or flag, a repeated flag, a missing required flag or argument, a malformed value — is reported without loading configuration; a configuration error (14.14) precedes every argument check that consults configuration, discovery, or the workspace, the unknown names and files of the usage class included. ### 12.1 `xspec build` @@ -728,7 +728,7 @@ Every file xspec writes is a plain file suitable for committing, written with st Derived-file paths belong to xspec: writing a derived file replaces whatever exists at its path, whether or not xspec wrote it. Derived files are never sources: paths whose file name contains `.xspec.`, files under `.xspec/`, and files at the configured Markdown emit destinations (7.3) are excluded from every spec and code group (7). -Writes never traverse symbolic links. A symbolic link at a derived file's path is an occupant like any other: the write replaces the link itself, and nothing is ever written through it. A durable file's path occupied by a symbolic link — or by anything other than a plain file — is never read, appended to, or replaced: such a journal is a journal error (14.13), such a session corrupt (10.1, 14.21). A write path having a symbolic link at a workspace-relative directory component is refused (14.22; a move's destination-side case is instead the refusal of 6.5); path components above the workspace root are unrestricted. A write brings the nonexistent workspace-relative directory components of its path into existence as directories — the file-form move's fresh destination directories, a created target file's (6.5), and a first emission under `markdown.outDir` (7.3) alike — so a missing intermediate directory never refuses or fails a write. +Writes never traverse symbolic links. A symbolic link at a derived file's path is an occupant like any other: the write replaces the link itself, and nothing is ever written through it. A durable file's path occupied by a symbolic link — or by anything other than a plain file — is never read, appended to, or replaced: such a journal is a journal error (14.13), such a session corrupt (10.1, 14.21). A write path having a workspace-relative directory component occupied by anything other than a directory — a plain file, a symbolic link (whatever it targets), or any other non-directory occupant — is refused (14.22; a move's destination-side case is instead the refusal of 6.5); path components above the workspace root are unrestricted. A write brings the nonexistent workspace-relative directory components of its path into existence as directories — the file-form move's fresh destination directories, a created target file's (6.5), and a first emission under `markdown.outDir` (7.3) alike — so a missing intermediate directory never refuses or fails a write. ### 13.5 Concurrency and isolation @@ -738,14 +738,14 @@ All state is workspace-local; instances operating on different workspaces MUST N `xspec build` and `xspec check` MUST report actionable errors that identify the file, location, and correction. When several error conditions are present, they MUST report each of them, not only the first; a condition goes unreported only where another error makes it undetectable — an unparseable file (14.20) masks the conditions inside itself, and a reference into it reports as unresolved (14.5–14.7) — and a configuration error (14.14) precedes all source analysis. -Every reported condition carries a stable machine-readable code identifying which numbered condition it is — a code's value is its token as listed, a string (12.7), and a numeral below is the condition's ordinal, ordering findings (12.7), no part of the value: 1 `missing-id`, 2 `invalid-structural-id`, 3 `duplicate-id`, 4 `invalid-segment-or-tag`, 5 `unknown-dependency`, 6 `unknown-text-target`, 7 `unknown-ts-reference`, 8 `invalid-argument`, 9 `cycle`, 10 `stale-output`, 11 `cross-module-text`, 12 `policy-violation`, 13 `journal-error`, 14 `configuration-error`, 15 `invalid-import`, 16 `invalid-construct`, 17 `invalid-prop`, 18 `unsupported-node-usage`, 19 `invalid-source-path`, 20 `unparseable-source`, 21 `corrupt-session`, 22 `symlink-write-path`, 23 `unreadable-record`. Stable codes cover exactly these conditions and the refusal reasons below, and no more: a plain usage error (12.0) describes the invocation the consuming tool itself composed, never workspace content to render inline, and carries no stable code — while still arriving as the JSON error document of 12.0 whenever JSON output is in effect — and review-operation refusals (10.7) likewise carry none. +Every reported condition carries a stable machine-readable code identifying which numbered condition it is — a code's value is its token as listed, a string (12.7), and a numeral below is the condition's ordinal, ordering findings (12.7), no part of the value: 1 `missing-id`, 2 `invalid-structural-id`, 3 `duplicate-id`, 4 `invalid-segment-or-tag`, 5 `unknown-dependency`, 6 `unknown-text-target`, 7 `unknown-ts-reference`, 8 `invalid-argument`, 9 `cycle`, 10 `stale-output`, 11 `cross-module-text`, 12 `policy-violation`, 13 `journal-error`, 14 `configuration-error`, 15 `invalid-import`, 16 `invalid-construct`, 17 `invalid-prop`, 18 `unsupported-node-usage`, 19 `invalid-source-path`, 20 `unparseable-source`, 21 `corrupt-session`, 22 `obstructed-write-path`, 23 `unreadable-record`. Stable codes cover exactly these conditions and the refusal reasons below, and no more: a plain usage error (12.0) describes the invocation the consuming tool itself composed, never workspace content to render inline, and carries no stable code — while still arriving as the JSON error document of 12.0 whenever JSON output is in effect — and review-operation refusals (10.7) likewise carry none. Every condition that locates in source carries, for each offending construct, the containing file and a source range (1.7) at the precision the condition allows. Location cardinality follows the condition's structure: a condition that several constructs jointly violate is one finding carrying a location for every participating construct, each located in the file that contains it, so every offending spelling renders in place and no representative is chosen — duplicate identities locate every bearer; an import-binding collision locates every colliding declaration; a cycle locates its full path in source, every reference spelling recording a participating dependency edge, or each participating import declaration of a spec import cycle. An entity a condition names as context rather than as an offending construct — the foreign module of a cross-module `text` call (14.11) — is identity data on the finding, not a further range. A reference spelling that records no occurrence (5.7, 11.2) is located here: for a spelling of the MDX embedding form, its finding's range is the full braced container, opening brace through closing brace — the span its occurrence would occupy (5.7) — keeping the byte classification of 11.4 exact on imperfect files. Conditions without an in-source location — configuration, path-level, journal, session, and record conditions — carry the file or path they concern; a policy violation, constraining an edge rather than any file's content, carries neither location nor concerned path — its context identities alone (12). A configuration error's concerned path is reported in the anchoring form of 11.6, identified relative to the invocation working directory: where a configuration file is concerned — the file the upward search found, or the path `--config` names — it is that file; for missing configuration with no `--config` given, it is the directory the failed upward search started from, the invocation working directory, spelled `.` (11.6). The JSON report form presents code, locations, and concerned path for every finding — the finding form of 12.7 — all conditions reported together, with the same information as the human report (12.0). The defined error conditions — also the findings that accompany answers over a consulted domain (11.2, 11.6, 6.6) — each reported by `build` and `check` unless its entry states otherwise: -1. Missing ID: a non-root section without `id`. -2. Invalid structural ID: a child ID that does not equal the parent ID plus one segment, including IDs that skip levels; the error states the expected form. A top-level section is checked against the empty prefix (exactly one segment). The check needs the parent's ID: for the immediate children of a section lacking `id`, condition 1 masks this condition — their other conditions, and this condition for their own children, report normally. +1. Missing ID: a non-root section with no `id` attribute. A section whose `id` attribute is repeated or whose value is not in quoted static-string form (2.7) is condition 17, never this one — each case spells no identity (11.2) and masks condition 2 for its immediate children (condition 2's rule). +2. Invalid structural ID: a child ID that does not equal the parent ID plus one segment, including IDs that skip levels; the error states the expected form. A top-level section is checked against the empty prefix (exactly one segment). The check needs the parent's spelled identity (11.2): for the immediate children of a section spelling no identity — its `id` absent (condition 1), repeated, or in invalid value form (condition 17) — the parent's condition masks this one; their other conditions, and this condition for their own children, report normally. 3. Duplicate ID within a file. 4. Invalid segment or tag: violation of 1.4. 5. Unknown dependency: a `d` reference that does not resolve. @@ -765,7 +765,7 @@ The defined error conditions — also the findings that accompany answers over a 19. Invalid source path: a discovered spec or code source file whose workspace-relative path contains `#` or is not valid UTF-8 (7), or a spec-group file without the `.mdx` extension (7.1). 20. Unparseable source: a spec-group file that is not well-formed MDX, a code-group file that is not well-formed TypeScript under the grammar its file name selects (`.tsx` parses as TSX, any other name as plain TypeScript), or a discovered source file of either kind that is not valid UTF-8 or begins with a byte-order mark (1.6); the error reports the location of the parse failure. 21. Corrupt review session: a session file that is not a plain file (13.4), cannot be parsed, or violates a session invariant (10.1). Reported by `check`, beside a failing workspace's other findings (14); by any `review` subcommand naming the session and by `review list` (exit 1), each only on a workspace passing `build`'s validations — on a failing one the gate's findings are reported without any session being read (13.3, 12.0); not reported by `build`, which does not read sessions. -22. Symbolic link in a write path: a workspace-relative directory component of a path xspec writes is a symbolic link (13.4). A command refuses the write and reports it before modifying anything; `check` reports it without writing. The concerned path is the offending symbolic-link component's workspace-relative path — one finding per distinct offending component, whatever write paths it refuses. A symbolic link at a derived file's own path is not an error — writing replaces the link (13.4). A component under a move's destination path or under a derived path the destination would generate is the move's `refused-invalid-destination` (6.5), never this condition — a refused operation reports refusal reasons alone (below). +22. Obstructed write path: a workspace-relative directory component of a path xspec writes is occupied by anything other than a directory — a plain file, a symbolic link (whatever it targets), or any other non-directory occupant (13.4); a nonexistent component is never this condition, since writes create those (13.4). A command refuses the write and reports it before modifying anything; `check` reports it without writing. The concerned path is the offending component's workspace-relative path — one finding per distinct offending component, whatever write paths it refuses. An occupant at a derived file's own path is not an error — the write replaces whatever occupies the path (13.4) — and a durable file's own path holding anything but a plain file is that file's condition (14.13, 14.21), never this one. A component under a move's destination path or under a derived path the destination would generate is the move's `refused-invalid-destination` (6.5), never this condition — a refused operation reports refusal reasons alone (below). 23. Unreadable recorded state: recorded generation state (13.3) that exists but cannot be read as a record — corrupt graph data, merge-conflicted or otherwise. Reported by the surfaces that read the record without refreshing it — `inventory` (11.6) and `rename`/`move` previews (6.6) — with one outcome, defined here for both: the surface's record-supplied datum — the inventory's recorded derived-file paths, the preview's delta — is reported explicitly unavailable, never fabricated and never read as an empty record; the finding accompanies the answer with its stable code; the invocation exits 1 (12.0); and every other part of the answer — every other provenance's content, every other part of the preview report — is emitted in full. The concerned path is the graph-data area (11.6): the record's layout is deliberately unenumerated (13.3), so no path inside the area is named. Not reported by `build`, whose rebuild replaces the record (12.1, 13.4), nor by the refreshing reads of 13.3, which leave the record — unreadable state included — unchanged without consulting it (13.3); `check` reports the state as staleness (14.10). Each distinct reason `rename` and `move` refuse (6.4, 6.5) — exactly what a refused preview (6.6) reports — carries a stable code and, under the location-cardinality rule above, the file, source range, or identity it concerns, so a refusal renders as precisely as a finding; refusals are findings in the exit-code partition (12.0), and the JSON report form above carries them. A refused operation or preview reports every applicable reason together, one finding per reason — never only the first found — each reason's applicability read on its own terms below. The reasons and their codes: `refused-invalid-id` — the new ID, or an ID the prefix replacement produces, is not in intrinsic ID form (one or more segments joined by `.`, each satisfying 1.4), concerning that identity — intrinsic form only: positional conformance (1.3) is `refused-structural-parent`'s, evaluated only over intrinsically valid IDs, so no identity reports under both; `refused-identity-unchanged` — the new identity equals the old (6.4; the exact self-move of 6.5), concerning it; `refused-id-collision` — the new ID, or an ID the prefix replacement produces, collides with an ID remaining after the operation's removals — rename's prefix mapping (6.4), the section move's subtree removal (6.5) — locating every colliding bearer; `refused-structural-parent` — structural parent rules (1.3) would not remain satisfied, concerning the violated identity; `refused-unresolvable-reference` — a rewritten reference would not resolve, locating each such reference spelling; `refused-cycle` — the move would create a spec import cycle or a dependency cycle (6.5), locating the would-be cycle's full path per the cardinality rule; `refused-destination-exists` — the file form's destination path is already occupied, whatever kind of filesystem object occupies it, or the section form's target path is occupied by anything other than a discovered spec source (6.5), concerning that path; `refused-missing-target-parent` — the section form's target parent is missing or lies within the moved subtree (6.5), concerning the target-parent identity; `refused-invalid-destination` — the destination file path would not be a valid discovered spec source, or a workspace-relative directory component of it or of a derived path it would generate is occupied by anything other than a directory (6.5), concerning the destination path. The invalid-workspace refusal (6.4) reports the workspace's findings themselves, each under its own numbered condition and stable code — and those alone: the reasons above are defined and evaluated only over a workspace passing `build`'s validations (6.4, 6.5), so no report mixes refusal reasons with numbered conditions. diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index a847940..141ee5d 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -1,7 +1,7 @@ # 0001 — Foundational machine surfaces for an external spec UI - **Type:** Improvement Proposal (IP) -- **Stage:** Accepted +- **Stage:** Applied - **Branch:** `claude/xspec-ui-apis-4df8fa` (harness-designated for this session; stands in for `patch/external-ui-apis`) ## Motivation From 0fb8dcaa1eca9a76a4eb9b1fde9c14ebc2f66d53 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 16:09:07 +0000 Subject: [PATCH 031/226] sdg(phase-6): cover IP 0001 surfaces in TEST-SPEC (iter 1) Applied all review items; none rejected. Critical: C1 new 5.7 section (T5.7-1..4) plus T1.7-2 code-location ranges; C2 6.6 Previews (T6.6-2..6), manual restructuring renumbered to 6.7 (T6.6-1 retired, T6.7-1 issued), T6.1-3 reference fixed, applied-mapping reports added to T6.4-1/T6.5-1; C3 11.2 section (T11.2-1..6) and 13.3 read-list updates; C4 11.3 (T11.3-1..4); C5 11.4 (T11.4-1..6); C6 11.5 (T11.5-1..3); C7 11.6 (T11.6-1..4); C8 12.6 (T12.6-1/2) plus T12.0-9/T12.0-12 updates; C9 12.7 (T12.7-1..3); C10 T12.0-2 and H-5 flipped to the JSON error-document rule; C11 T1.7-1/T10.7-7/T10.7-12 flipped to range-in-two-outputs and bare-identity endpoints; C12 stable-code notation rule, T14-6/7/8, map rows; C13 condition-23 arms in T6.6-6, T11.6-4, T12.2-2, T13.3-2, T14-4; C14 H-3 re-scoped to form-exact 12.7 surfaces vs adapter-decoded information surfaces. Important: I1 T12.0-10 gated-read precedence and T11-6 wrong-kind arms; I2 T6.4-4/T6.5-5 operand-classification arms; I3 T12.0-13; I4 T12.0-9 class members; I5 E-6 Windows leg extended (new surfaces, drive-mismatch anchoring); I6 P-11/P-12. Optional: O1 T12.5-1 dispatch list, section 11 heading, ID retirement; O2 unified JSON-only preamble for section 11. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 139 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 110 insertions(+), 29 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index 6d30518..bfc9acf 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -6,7 +6,7 @@ The harness treats xspec strictly as a black box. Tests drive the product exclus Sections 1–15 of this document mirror sections 1–15 of SPEC.md one-to-one. Every normative statement in SPEC.md section *N* is covered by tests in section *N* here (cross-references are explicit where one test covers statements from several sections), so coverage can be verified requirement by requirement. SPEC.md's unnumbered document preamble also carries requirements — no network access; git read only where explicitly stated, never written — covered by this introduction, T12.0-11/12, and E-1. Sections 16–18 define property-based/fuzz testing, harness self-testing and certification, and execution/CI requirements. -Test case notation: each test has a stable ID `T
-` (e.g. `T2.4-3`), a setup (workspace content), an action (commands run or consumer code compiled/executed), and expected observations. IDs are never reused; a withdrawn test's ID is retired. Where a test asserts an error, it MUST assert the exit code class (12.0) and that the report identifies the file/location/correction information SPEC.md §14 requires — not exact wording. +Test case notation: each test has a stable ID `T
-` (e.g. `T2.4-3`), a setup (workspace content), an action (commands run or consumer code compiled/executed), and expected observations. IDs are never reused; a withdrawn test's ID is retired. Where a test asserts an error, it MUST assert the exit code class (12.0), that the report identifies the file/location/correction information SPEC.md §14 requires — not exact wording — and, where §14 assigns the condition or refusal reason a stable code, that exact code string (a code is contract, not wording; 12.7, T14-6). There are currently no spec modules under `specs/modules/`; consequently there are no test modules. If a spec module `specs/modules/.md` is added, a test module `specs/modules/TEST-.md` MUST accompany it under the same rules as this document. @@ -18,9 +18,9 @@ These requirements bind the harness implementation regardless of test framework * **H-1 Workspace isolation.** Every test constructs a fresh, self-contained workspace in a unique temporary directory: `xspec.config.ts`, source files, and (when needed) a local git repository with scripted commits. Tests share no mutable state. Two harness instances MUST be able to run concurrently on the same machine (unique temporary roots), satisfying SPEC.md 13.5 isolation from the observer side. * **H-2 Blackbox drive.** Tests invoke the `xspec` executable as a subprocess with controlled working directory, arguments, and environment, and observe: exit code, standard output, standard error, and workspace file state. Consumer-side contracts (generated modules, type errors, runtime behavior, hover/go-to-definition) are exercised by compiling and running small consumer TypeScript programs under standard TypeScript tooling with no xspec runtime dependency (SPEC.md 13.1). No other channel into the product exists — in particular, invoking the product in-process (importing product code or calling a product-internal entry function) is not a permitted channel for any test, fast paths included: SPEC.md's complete interface is the executable, an in-process entry is an implementation detail outside that interface, and subprocess invocation carries the process-level contract the suite asserts (exit codes, stream separation, working directory, environment, termination; 12.0, 13.5). -* **H-3 Output adapters.** SPEC.md fixes the information content of reports and JSON documents but not their concrete shape. Each command's assertions go through a thin decoding adapter that maps the product's actual output onto the information model this document asserts against (nodes, categories, counts, paths, findings, …). Adapters are the only place aware of concrete output shape; they may be adjusted to shape, never to values, and they MUST fail loudly (test error, not pass) when required information is absent. Human-readable reports are asserted only for required information (via robust matching), never exact wording. Where a test stages a corrupt or tampered product-written file whose concrete shape SPEC.md leaves opaque (T10.1-4), the staging transformation lives in this same adapter layer — shape-aware, value-blind, applied to a file the product itself wrote, and failing loudly when the shape does not match — never fabricating such a file from an assumed layout. +* **H-3 Output adapters and form-exact surfaces.** SPEC.md 12.7 fixes the concrete JSON shape — member names, `null`-vs-omission, `[]`-vs-`null`, the range, path, byte-form, unavailability-marker, and finding value forms, and findings order — of every findings array and findings-only report, the exit-2 error document, and the document forms of 6.6, 11.3–11.6, and 12.6. Assertions on those surfaces are form-exact: tests assert the 12.7 member names and forms literally, and no adapter may re-map, rename, or coerce them — output differing from 12.7 in shape is a conformance failure, never an adapter fixture (T12.7-1..3). Everywhere else — human-readable reports, and command JSON that carries its defining section's information (12.0: `query`, `ids`, `show`, `coverage`, `impact`, the `review` payloads, the applied-mapping reports of 6.4/6.5) — SPEC.md fixes information content, not concrete shape: those assertions go through a thin decoding adapter that maps the product's actual output onto the information model this document asserts against (nodes, categories, counts, paths, …). Adapters are the only place aware of that unpinned shape; they may be adjusted to shape, never to values, and they MUST fail loudly (test error, not pass) when required information is absent. Human-readable reports are asserted only for required information (via robust matching), never exact wording. Where a test stages a corrupt or tampered product-written file whose concrete shape SPEC.md leaves opaque (T10.1-4, T6.6-6), the staging transformation lives in this same adapter layer — shape-aware, value-blind, applied to a file the product itself wrote, and failing loudly when the shape does not match — never fabricating such a file from an assumed layout. * **H-4 Byte assertions.** Where SPEC.md requires byte determinism or exact bytes (12.0 determinism, 3 Markdown output, 6.5 move edits, 13.4 stable ordering), tests assert byte equality. Where SPEC.md declares content opaque (journal entry content 6.1, graph data content 13.3), tests assert only the stated observable contract (location, line-orientation, append-only effect, refresh/staleness behavior) and MUST NOT pin opaque bytes across product versions — except for determinism checks comparing the product to itself. -* **H-5 Exit codes and streams.** Every test asserts the exact exit code and, where relevant, the stdout/stderr separation of 12.0 (reports and findings on stdout; usage/configuration errors and diagnostics on stderr; with `--json`, stdout is exactly one JSON document or empty on exit-2). +* **H-5 Exit codes and streams.** Every test asserts the exact exit code and, where relevant, the stdout/stderr separation of 12.0: reports and findings are stdout content; usage/configuration error messages and all other diagnostics are stderr content; when JSON output is in effect — `--json` among the invocation's arguments, even when the arguments are themselves the error, or a JSON-only surface (10.7, 11, 12.6) — stdout is exactly one JSON document, on exit 2 the error document of 12.7; when it is not in effect, exit-2 stdout is empty. * **H-6 Determinism protocol.** Tests marked *determinism* run the same command twice (or rebuild the same workspace in two separate directories) and assert byte-identical outputs and written files, after normalizing nothing. Workspace-relative path rules (1.5) make this well-defined across directories. * **H-7 Traceability.** The harness maintains a machine-readable mapping from test ID to the SPEC.md passage(s) it covers. The map's keys are: SPEC.md's unnumbered document preamble (T12.0-11/12, E-1); every numbered subsection; and every numbered section's own body text outside its subsections (3, 4, 5, 7, 8, 9, 10, 11, 14, and 15 — sections 1, 2, 6, 12, and 13 carry no requirements outside their subsections and are covered through them). A harness self-check (17) fails if any key lacks at least one mapped test, or if a test maps to a nonexistent key. * **H-8 Red-green compatibility.** The full suite MUST be runnable when no product is installed (or against a deliberately empty stub): every product-facing test fails with a diagnosed assertion failure — never a harness crash, hang, or false pass. Self-tests (17) and certification MUST pass before the product exists. @@ -73,7 +73,8 @@ These requirements bind the harness implementation regardless of test framework ### 1.7 Source ranges -* **T1.7-1 Range definition.** A fixture whose exact bytes are known, with an import line and multi-byte UTF-8 content preceding the first section (so byte offsets into the source diverge from code-point offsets, UTF-16 offsets, and compiled-output offsets): the source range reported by `query node` — and equal via `show` (12.4) — is a pair of zero-based byte offsets into the file's bytes, start-inclusive and end-exclusive; for a non-root node it spans the section construct's own characters, from the first character of its opening tag through the last character of its closing tag; for a self-closing section (1.1), exactly the self-closing tag's own characters; for the root node, the entire file — start 0, end the file's byte length. Asserted against precomputed offsets, so a product emitting line/column pairs, 1-based, code-point-based, or end-inclusive ranges fails. Code locations carry no source range (1.7): asserted on the 10.7 payload, where a `code-impact` scope enters as identity and presence alone (T10.7-12). Field presence across the other surfaces is covered by T11-1/T11-2, T12.4-1, and T10.7-7. +* **T1.7-1 Range definition.** A fixture whose exact bytes are known, with an import line and multi-byte UTF-8 content preceding the first section (so byte offsets into the source diverge from code-point offsets, UTF-16 offsets, and compiled-output offsets): the source range reported by `query node` — and equal via `show` (12.4) — is a pair of zero-based byte offsets into the file's bytes, start-inclusive and end-exclusive; for a non-root node it spans the section construct's own characters, from the first character of its opening tag through the last character of its closing tag; for a self-closing section (1.1), exactly the self-closing tag's own characters; for the root node, the entire file — start 0, end the file's byte length. Asserted against precomputed offsets, so a product emitting line/column pairs, 1-based, code-point-based, or end-inclusive ranges fails. A code location is presented with its source range in exactly two outputs — occurrence records (5.7, 11.3; T1.7-2) and review payloads (10.7; T10.7-12) — and everywhere a graph node appears as an edge endpoint it is a bare identity, requirement node and code location alike (1.7): asserted on `edges` rows, on a `reachable` witness path, and on `query node`'s incoming and outgoing edge lists, each traversing a code location — the reported endpoints are identities alone, no range datum accompanying them. Field presence across the other surfaces is covered by T11-1/T11-2, T12.4-1, and T10.7-7. +* **T1.7-2 Code-location ranges.** Occurrence records are the surface making every code unit's range reachable (1.7): against precomputed byte offsets, the `source` node of a marker or TS `text(...)` occurrence carries — for a whole-file location (top-level marker) — the entire file; for a function and a class declaration, the construct binding the name; for a function- or class-valued variable declaration inside a multi-declaration statement (`const a = 1, f = () => {…}`), the unit's own name through its initializer, not the enclosing statement; for the nested units of a dotted namespace (`namespace A.B`), the single namespace declaration's range shared by the `path#A` and `path#A.B` units; for a default export of a named construct, that construct's own range, and for an anonymous one, the whole export declaration's range under unit `default`; for a document-order-disambiguated `path#unit@2` (4.6), the range of its own — second — occurrence's construct. The same ranges appear on a `code-impact` scope in the 10.7 payload (T10.7-12). ## 2. Source Syntax @@ -219,13 +220,22 @@ All category tests run `impact --base ` against a committed baseline and as * **T5.6-5 Multiple flags.** A node that is simultaneously `changed` and `upstream-changed` (own edit plus dependency-target edit) carries both categories. * **T5.6-6 Added/deleted convention.** Baseline hash comparison is defined only for nodes present on both sides (5.6). Add a subtree whose root carries `d` targets (one targeting a node also edited since the baseline), `coverage="none"`, tags, children, and an embedding: every added node is `changed` only — never `metadata-changed`, `descendant-changed`, or `upstream-changed`, whatever metadata, children, or dependency edges it carries. Delete a subtree with the same features: each deleted node reports as deleted and `changed` only. (Impacted-code evaluation is the stated exception, 9.2: T9.2-1.) +### 5.7 Reference occurrences + +Occurrences are observed through `xspec occurrences` (11.3) and per-file views (11.4), in the 12.7 record form (T12.7-1, form-exact per H-3). + +* **T5.7-1 Units and duplicates.** One workspace spelling every occurrence kind: a three-entry `d` array, a single-reference `d`, an MDX `{text(...)}`, a TS `text(...)` call, and a TS marker. `occurrences` reports one occurrence per `d` array entry — never one for the array or the prop (2.2) — and one per embedding, call, and marker, each carrying its edge kind. Duplicates: `d={[BASE.a.b, BASE.a.b]}` and a twice-spelled marker collapse to one edge each (T2.2-3, T5.2-1) yet remain two distinct occurrences each, at distinct ranges. +* **T5.7-2 Spans.** Byte-precise fixtures per kind against precomputed offsets: a `d` occurrence spans exactly that one reference's own expression — an array's middle entry alone, no brackets, commas, or surrounding whitespace; an MDX embedding occurrence spans the entire braced container `{text(...)}`, opening brace through closing brace — the whole construct compilation replaces (3); a TS call occurrence spans callee through closing parenthesis, argument included (an aliased callee `t(SPEC.a)` from its `t`); a marker occurrence spans the bare reference chain alone, exclusive of the statement's terminating `;` and surrounding trivia. +* **T5.7-3 Record data and order.** Each record carries the referencing file, its own range, its edge kind, its source graph node as one identity-plus-range datum — the containing section for MDX (construct range, 1.7), the innermost enclosing named unit or file for TS (T1.7-2) — and the resolved target's identity. Order is total and deterministic (5.7): a multi-file fixture asserts file-path byte order, then range start, then range end, byte-identical across repeated runs (H-6); no two records share a range. +* **T5.7-4 No occurrence.** Constructs that record no edge record no occurrence: an import declaration (binding used and unused, 2.1); a type-only binding's marker-shaped uses (T4-4); a chain rooted at a shadowing local declaration (T4.5-4); a dynamic reference spelling and an unresolving one (each also its finding, 14.8/14.5–14.7). `occurrences` over such a workspace reports records for exactly the resolving spellings — the unresolved spelling's position reaching consumers only through its finding's range (11.2, T14-8), never as a record with an unavailable target — the answer carrying the domain's findings, exit 1 (11.2). + ## 6. Identity Continuity ### 6.1 The journal * **T6.1-1 Lifecycle.** No journal file exists after `build` in a fresh workspace; the file appears at `.xspec/journal` with the first `rename`/`move`; each subsequent operation appends exactly one line-oriented entry and rewrites nothing above it (byte-prefix asserted); `build`, `check`, `coverage`, `impact`, `review`, `query` never modify it (byte-compare around each). * **T6.1-2 Determinism.** The same operation on the same workspace state (two identical directories) appends byte-identical entries. -* **T6.1-3 Integrity.** `check` reports a malformed journal (garbage line), naming the line, with 14.13; a journal path occupied by a directory or symbolic link is a journal error (14.13). The conflicting and well-formed-yet-unreplayable arms of 12.2/14.13 admit no discriminating fixture at `check`: entry content is opaque (6.1, H-4), so such an entry cannot be authored directly, and tampering with product-written lines (duplicating one, recombining entries) has no product-independent expected outcome — manual restructuring is never journaled (6.6), so under some conforming entry contents the tampered bytes are exactly what a legitimate history of journaled operations interleaved with manual edits would have appended (entries are byte-deterministic for a given operation and workspace state, 6.1), indistinguishable and accepted, while under others they are detectably impossible and rejected. Replay failure where outcomes are pinned is tested at baseline resolution: T6.3-4's unresolvable-mapping and prefix-violation arms (6.3). +* **T6.1-3 Integrity.** `check` reports a malformed journal (garbage line), naming the line, with 14.13; a journal path occupied by a directory or symbolic link is a journal error (14.13). The conflicting and well-formed-yet-unreplayable arms of 12.2/14.13 admit no discriminating fixture at `check`: entry content is opaque (6.1, H-4), so such an entry cannot be authored directly, and tampering with product-written lines (duplicating one, recombining entries) has no product-independent expected outcome — manual restructuring is never journaled (6.7), so under some conforming entry contents the tampered bytes are exactly what a legitimate history of journaled operations interleaved with manual edits would have appended (entries are byte-deterministic for a given operation and workspace state, 6.1), indistinguishable and accepted, while under others they are detectably impossible and rejected. Replay failure where outcomes are pinned is tested at baseline resolution: T6.3-4's unresolvable-mapping and prefix-violation arms (6.3). ### 6.2 Identity guarantee @@ -243,26 +253,36 @@ All category tests run `impact --base ` against a committed baseline and as ### 6.4 Rename -* **T6.4-1 Rewrites.** Renaming a mid-tree ID rewrites: its `id`, all descendant `id`s by prefix replacement, local string references, external chain references in other files, `text(...)` targets in MDX and TS, and TS markers — workspace builds and all edges retarget (query-asserted); mapping appended to journal. +* **T6.4-1 Rewrites.** Renaming a mid-tree ID rewrites: its `id`, all descendant `id`s by prefix replacement, local string references, external chain references in other files, `text(...)` targets in MDX and TS, and TS markers — workspace builds and all edges retarget (query-asserted); mapping appended to journal. The command's own report is the applied mapping — every identity pair the operation journaled, the information of the preview's `mapping` (6.4) — carried in JSON per 12.0 (H-3 adapter; the successful operation's report shape is unpinned). * **T6.4-2 Minimal edits.** Quote style (single vs double) and access form (dot vs computed) of untouched reference parts are preserved byte-wise; only the affected parts change. Where the form cannot be kept: a new segment that is not a TS identifier is written as double-quoted computed access; a valid-identifier segment as dot access; string literals double-quoted. * **T6.4-3 Validation refusals (exit 1).** New ID invalid (1.4); equal to old; colliding with an existing ID; violating structural parent rules. Each refusal modifies nothing (workspace byte-compare). The remaining 6.4 clause — all rewritten references resolve — admits no discriminating fixture: rename rewrites only valid workspaces (T6.4-6) and retargets every affected reference to identities that exist after the operation, so a non-resolving rewritten reference is unconstructible; the clause is exercised as the always-passing side of every successful rename (T6.4-1). -* **T6.4-4 Usage errors (exit 2).** Nonexistent ``; nonexistent old ID. Checked before source validation: same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering); but an old ID inside an unparseable origin file is masked — validation findings reported, exit 1. +* **T6.4-4 Usage errors (exit 2).** Nonexistent ``; nonexistent old ID; a discovered code source as `` — a wrong-kind operand, judged like existence before any content question (6.4). Checked before source validation: same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering); but an old ID inside an unparseable origin file is masked — validation findings reported, exit 1. Old-ID existence is parse-local over spelled identities (6.4, 11.2): renaming an ID two sections both spell is no usage error — the bearers establish existence, their undefined node identities notwithstanding, and the duplicate-ID finding refuses instead (exit 1, the invalid-workspace refusal, T14-7); an old ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent — exit 2 even beside that file's findings. * **T6.4-5 Type-level references.** A `typeof`-level reference to the old identity is not rewritten; the workspace stays xspec-valid (the consumer type error is outside xspec's validations — `build` and `check` report no finding for it). * **T6.4-6 Valid-workspace precondition.** With a pre-existing validation error elsewhere, rename refuses (exit 1) before modifying anything. * **T6.4-7 Finishing regeneration.** After a successful rename, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the rewritten sources; `check` immediately after reports no staleness (14.10). ### 6.5 Move -* **T6.5-1 File form.** IDs unchanged; identities change file part only; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; journal appended; finishing regeneration as T6.4-7. +* **T6.5-1 File form.** IDs unchanged; identities change file part only; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; journal appended; finishing regeneration as T6.4-7; the applied-mapping report as T6.4-1 (6.5: both forms report as rename does). * **T6.5-2 Section form text edits.** Byte-exact fixtures: moved text spans opening tag's first character through closing tag's last; origin deletion drops lines left empty/whitespace-only (rule of 3); insertion immediately before the target parent's closing tag (or end of file for top-level `new-id`), followed by U+000A and preceded by one when not at line start; target file created when absent; no other byte changes beyond these edits, the identity and reference rewrites, and the finishing regeneration (6.5). Self-closing arms (1.1, 6.5), byte-exact: moving a self-closing section moves exactly the self-closing tag's own characters; a self-closing target parent is first rewritten to paired form — its `/` and any whitespace immediately before or after the `/` deleted, the closing tag matching the opening tag's name appended immediately after the tag's terminating `>` — and the insertion rule then applies before that closing tag: a `` parent becomes `` + U+000A + the moved text + U+000A + ``. * **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. * **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists; section form whose `` is invalid per 1.4 (the mirrored "new ID is valid" check, 6.5) — a forbidden name (`then`) and a whitespace-bearing segment, one arm each; section form whose `` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; containing `#`; not valid UTF-8 (Linux leg); lacking `.mdx`. Plus the valid-workspace precondition as T6.4-6. -* **T6.5-5 Usage errors (exit 2).** Nonexistent origin file or origin ID, ordering as T6.4-4. +* **T6.5-5 Usage errors (exit 2).** Nonexistent origin file or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y` and `a.mdx#x b.mdx` match neither form — exit 2 — and an operand containing `#` is always a `#` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). * **T6.5-6 Identity terms.** The new-identity checks read in identity terms (6.5): a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid; the exact self-move — `#` equal to `#` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. -### 6.6 Manual restructuring +### 6.6 Previews + +(T6.6-1 is retired: it covered manual restructuring, formerly SPEC.md 6.6, now 6.7 — see T6.7-1.) + +* **T6.6-2 Modifies nothing.** A rename `--preview` and a section-form move `--preview` on workspaces where the real operation would proceed: exit 0, findings `[]`, and every byte of the workspace identical afterward — sources, journal (an absent journal stays absent), derived files, and graph data untouched; a subsequent real run on the same state performs the previewed plan, its applied mapping (T6.4-1) equal to the preview's `mapping`. Preview output is byte-deterministic across repeated runs (H-6) and, under `--json`, the form-exact 12.7 preview document (H-3). +* **T6.6-3 Refusal and scheduling equivalence.** For each refusal of T6.4-3 and T6.5-4 — the invalid-workspace precondition included — staged identically, the `--preview` invocation reports the same findings (same stable codes, locations, identities; 14) and exits 1, its `mapping`, `files`, and `delta` `null` (12.7), modifying nothing; for the usage errors of T6.4-4/T6.5-5 the preview exits 2 identically (argument checks precede either way). The equivalence is over workspace state, never scheduling (6.6): while another mutating command is held (`--test-hold`, 13.5), a `--preview` invocation runs to completion — it takes no exclusivity and never meets the mutual-exclusion refusal of T13.5-2 — and `--test-hold` combined with `--preview` is a usage error, exit 2. +* **T6.6-4 Report content.** Byte-precise fixtures asserted against precomputed pre-operation offsets, form-exact per 12.7: (a) a rename preview reports the complete identity mapping — the renamed ID and every descendant — and, per rewritten file, `id-rewrite` edits spanning each rewritten `id` attribute's own characters and `reference-rewrite` edits spanning each affected occurrence's span (5.7) across MDX and TS; (b) a section-move preview into an existing target file reports the `origin-deletion` as one range spanning the construct's own characters extended over the leftover whitespace and line terminator of each additionally dropped line (contiguous bytes: the adjunct drop lies inside this range, no class of its own), the re-identification's `id-rewrite` edits nested inside that deletion range in the same pre-operation coordinates (containment is geometry, each edit under its own class), `target-insertion` as a zero-length range at the insertion offset, `target-parent-rewrite` spanning a self-closing target parent's tag, `import-addition` as a zero-length range at the exact offset the real operation then uses (6.5; byte-asserted by running the operation on a copy), and `import-removal` spanning the declaration plus its adjunct drops; (c) a file-form move preview reports `import-specifier-rewrite` edits spanning the specifier literals and `file-relocation` spanning the entire moved file, the relocated file's entry under its current, pre-operation path; (d) a section-move preview whose target file does not exist reports, under the path the creation would occupy, exactly one `file-creation` edit at the start of the new file — the only location without pre-operation coordinates; the insertion and any import additions there are subsumed, while the moved text's own rewrites are reported inside the origin file's deletion range. Every edit is class-plus-range only — no replacement text (12.7) — and every reported class is one of the ten 12.7 class names. +* **T6.6-5 Delta.** After a build, a file-form move preview reports the derived-file delta both directions: under `generated` the destination's module, companion, and (emission enabled) Markdown paths — nothing recorded there — and under `removed` the recorded pre-move module, companions, and Markdown the operation would leave no longer generated (6.6); a rename preview on the same workspace reports `[]` in both directions (regeneration rewrites recorded paths in place), and the created-target move of T6.6-4(d) reports the new file's derived paths under `generated`. Record-based, not presence-based: with graph data deleted (T13.3-2's operational definition) the same move preview's `generated` approaches the full regeneration set — nothing being recorded — and the preview still writes nothing: no refresh, graph data still absent afterward. +* **T6.6-6 Unreadable record.** Corrupt the product-written graph data shape-blind (truncation or garbage over T13.3-2's operational path set; H-3/H-4 staging discipline, as T10.1-4 stages sessions): a move `--preview` whose plan is otherwise valid emits the full preview — `mapping` and `files` complete — with `delta` explicitly unavailable as one datum, never read as an empty record, the condition-23 finding (`unreadable-record`, concerned path the graph-data area, no path inside it named) in `findings`, exit 1 (14.23). The real operation on the same state is not refused — it proceeds, its finishing regeneration replacing the corrupt record (`check` clean afterward, T12.2-2) — and a refused preview staged on the same corrupt-record state (an identity-unchanged rename) reports the refusal findings alone, `mapping`/`files`/`delta` `null`, never a condition-23 finding (6.6: a refused preview consults no record). + +### 6.7 Manual restructuring -* **T6.6-1** Renaming an ID by editing the file directly: no journal entry; impact reports a deletion plus an addition (not continuity); dependents referencing the old identity fail validation (14.5) until rewritten. +* **T6.7-1** Renaming an ID by editing the file directly: no journal entry; impact reports a deletion plus an addition (not continuity); dependents referencing the old identity fail validation (14.5) until rewritten. (Formerly T6.6-1, retired when SPEC.md 6.6 became Previews.) ## 7. Project Configuration @@ -358,41 +378,84 @@ All category tests run `impact --base ` against a committed baseline and as * **T10.7-4 coverage sessions.** One `uncovered-requirement` item per uncovered required node of the recorded profile — scope: the node; context: ancestor chain; origin and `blockedBy` empty; item order file path then document order, with absent-scope items after the same file's present ones by scope-node identity then item `id` (10.5 ordering rule; fixture deletes an uncovered node's section after `create`). * **T10.7-5 list.** Reports every session, in byte order of session name — the fixture creates `a2`, then `a`, then `B`, and `list` reports `B`, `a`, `a2`: creation order and ASCII-case-folded order both differ from byte order (10.7, 12.0) — with name, strategy, item counts by stored status (no read-time invalidation applied — a stale-resolved item still counts under its stored status); corrupt sessions reported by name as corrupt; exit 1 iff any corrupt session exists, else 0. * **T10.7-6 status.** Items in item order with id, kind, scope, status, blocked state, plus totals by status (read-time invalidation applied). -* **T10.7-7 next.** Returns the first needing-review unblocked item in item order; when all items are resolved (and for an empty session), exits 0 and reports fully resolved in human and `--json` forms with no item in the JSON payload; `--json` payload is self-contained: scope text, context text, origin before/after text, source ranges, baseline and current hashes. +* **T10.7-7 next.** Returns the first needing-review unblocked item in item order; when all items are resolved (and for an empty session), exits 0 and reports fully resolved in human and `--json` forms with no item in the JSON payload; `--json` payload is self-contained: scope text, context text, origin before/after text, source ranges — every present node's, requirement node and present code location alike (1.7); none for absent nodes — and baseline and current hashes. * **T10.7-8 show/export.** `show ` reports the full item (10.2 fields plus the `next --json` text payload); unknown item ID → exit 2. `export` emits one JSON document — with or without `--json` — containing name, strategy, recorded creation parameters, recorded decompositions, and every item in item order with fields, blocked state, payload, and read-time invalidation applied. * **T10.7-9 split.** Splitting a `subtree-coherence` item whose scope root has children: one `subtree-coherence` item per child subtree (context: child's ancestor chain) plus one `parent-consistency` item for the scope root (context: the child subtrees; `blockedBy`: the child items); existing kind+scope items are reused with `id`/status/state kept (audit case); newly created decomposition items enter `unresolved` (10.2) — asserted on a split of a resolved item, whose status must not propagate to them — and inherit the original's `blockedBy`; every item blocked by the original becomes blocked by all decomposition items; the original is removed and its `id` never reused (assert across subsequent re-derivations, exercised in a path-blocks session and in an audit session — the re-derivation and decomposition rules of 10.5 hold for every strategy); the decomposition is recorded durably and governs re-derivation (T10.5-5); `origin` per decomposition scope (empty in audit). Refused (exit 1): `split` on any other kind; on a childless scope root. * **T10.7-10 resolve.** Sets status and records current relevant state; works on any unblocked item regardless of status (re-resolving `invalidated` and flipping a resolved status both work); resolving a blocked item refused (exit 1); unknown session or item → exit 2; `--note` stored and reported. * **T10.7-11 Coverage re-derivation.** Resolving an `uncovered-requirement` item `updated` re-derives with the session's recorded profile against the current workspace (10.5: every strategy; 10.7): a required node made newly uncovered since `create` (its covering edge removed) gains an `uncovered-requirement` item, created `unresolved` (10.2), in coverage item order; an item whose node was meanwhile covered is no longer generated and remains in the session with its status and recorded state (10.5); a generated item matching an existing kind and scope node keeps its `id`, status, and recorded state. -* **T10.7-12 Payload text contract.** A baseline fixture generating every built-in kind (a coverage session supplying `uncovered-requirement`), texts byte-asserted in `next --json` and identically via `show` and `export` (one payload rule, 10.7), with an embedding inside one asserted text to pin expansion (1.6). Scope text by kind: the scope root's subtree text for `subtree-coherence`; the scope node's subtree text for `uncovered-requirement`; the scope node's own text — not subtree text, the fixture making them differ — for `parent-consistency`, `dependency-consistency`, and `metadata-consistency`; a `code-impact` scope enters as identity and presence alone, with no text and no source range (1.7). Context text: own text where the context is an ancestor chain (`subtree-coherence`, `uncovered-requirement`); subtree text otherwise (`parent-consistency` branch children; `dependency-consistency`, `metadata-consistency`, and `code-impact` targets). Origin text: a before/after pair of the node's own text — before from the item's `baseline`, after from the current graph (the fixture edits an originating node again after `create`, so the sides differ and neither equals the create-time value); for a node added since the baseline the before side, and for a since-deleted node the after side, is the absent side of the pair — presented absent, with no text, like a node contained in no state. Absent-node provenance: a node edited between the baseline and `create`, recorded into an item's scope or context at `create`, then deleted, presents the text of the most recent graph state containing it — the `create`-time derivation, not the baseline (the values differ) — and still does after a later `updated` resolve re-derives the session without it (a state not containing the node contributes nothing); a node deleted since the baseline and never seen by a mutating derivation with newer text presents its `baseline` value. +* **T10.7-12 Payload text contract.** A baseline fixture generating every built-in kind (a coverage session supplying `uncovered-requirement`), texts byte-asserted in `next --json` and identically via `show` and `export` (one payload rule, 10.7), with an embedding inside one asserted text to pin expansion (1.6). Scope text by kind: the scope root's subtree text for `subtree-coherence`; the scope node's subtree text for `uncovered-requirement`; the scope node's own text — not subtree text, the fixture making them differ — for `parent-consistency`, `dependency-consistency`, and `metadata-consistency`; a `code-impact` scope enters as identity, presence, and — when present — its source range, with no text (10.7; review payloads are one of the two range-presenting outputs for code locations, 1.7: the fixture's location is a named unit whose range is byte-asserted per T1.7-2, and a deleted location's entry carries none). Context text: own text where the context is an ancestor chain (`subtree-coherence`, `uncovered-requirement`); subtree text otherwise (`parent-consistency` branch children; `dependency-consistency`, `metadata-consistency`, and `code-impact` targets). Origin text: a before/after pair of the node's own text — before from the item's `baseline`, after from the current graph (the fixture edits an originating node again after `create`, so the sides differ and neither equals the create-time value); for a node added since the baseline the before side, and for a since-deleted node the after side, is the absent side of the pair — presented absent, with no text, like a node contained in no state. Absent-node provenance: a node edited between the baseline and `create`, recorded into an item's scope or context at `create`, then deleted, presents the text of the most recent graph state containing it — the `create`-time derivation, not the baseline (the values differ) — and still does after a later `updated` resolve re-derives the session without it (a state not containing the node contributes nothing); a node deleted since the baseline and never seen by a mutating derivation with newer text presents its `baseline` value. -## 11. Query +## 11. Query Surfaces -All `query` output is JSON-only: a `query` subcommand without `--json` also emits a single JSON document carrying the same information as with `--json`, compared via H-3 adapters — SPEC.md 11 fixes JSON-only output and its information content, not byte-identity between the two invocation forms. +SPEC.md 11's five commands are JSON-only: for `query`, `occurrences`, `view`, `at`, and `inventory`, a single JSON document is the only output form, with or without `--json` — `occurrences`, `view`, `at`, and `inventory` in the form-exact document forms of 12.7 (H-3, T12.7-2), `query` carrying its defining section's information through H-3 adapters. Each surface's flag-less and `--json` invocations are asserted to carry the same information — never byte-identity between the two forms — and an exit-2 error of any of them arrives as the 12.7 error document on stdout (12.0; T12.0-2, T12.7-3). + +### 11.1 `xspec query` * **T11-1 node.** Returns identity, source range, own and subtree text (expanded, 1.6), all four hashes, tags, coverage attribute (absent for roots), and incoming and outgoing edges by kind. * **T11-2 nodes.** Filters `--group`, `--file `, `--tag`, `--coverage` combine conjunctively; `--coverage` matches no root; each row carries identity, source range, tags, coverage attribute (absent for roots); a `--file` pattern resolving outside the workspace root → exit 2 (invalid flag value); `--group` naming a code group → exit 2 (invalid flag value — the wrong-kind group reference of 14.14, 11). * **T11-3 subtree/ancestors.** `subtree` returns the node plus descendants in document order (root query returns the whole file); `ancestors` returns proper ancestors nearest-first ending at the file root, excluding the queried node (empty for a root); rows carry the row fields of T11-2 — identity, source range, tags, coverage attribute (11: one row contract for `nodes`, `subtree`, and `ancestors`) — asserted on `subtree` and `ancestors` rows including a tagged `coverage="none"` node and a root (attribute absent), so a product omitting a row field from either subcommand fails. * **T11-4 edges.** `--from`/`--to` accept requirement nodes and code locations; `--kinds` filters over all four kinds and defaults to no filter (contains edges included); comma-separated list form; unknown kind value → exit 2. * **T11-5 reachable.** Reports existence of a dependency path under the given kinds (default: all three dependency kinds, never `contains`) and one shortest witness path with the 12.0 tie-break (two-equal-paths fixture); equal `--from` and `--to` (a node bearing both incoming and outgoing dependency edges) report that no path exists — a zero-length path is not a path (11); `--kinds contains` → exit 2 (invalid flag value: `reachable` accepts only the three dependency kinds, 11 — `edges` accepts all four, T11-4). -* **T11-6 Identity resolution.** Bare `path` resolves to a root node for a spec-group file and to a code location for a code-group file; `path#unit` and `path#unit@N` address code locations; a path in no configured group → exit 2 (unknown, 12.0). +* **T11-6 Identity resolution.** Bare `path` resolves to a root node for a spec-group file and to a code location for a code-group file; `path#unit` and `path#unit@N` address code locations; a path in no configured group → exit 2 (unknown, 12.0). Wrong-kind operands (12.0): `query node` and `show` (12.4) given a code-group `path` or `path#unit` — a code source named where a requirement-node identity is required — each exit 2. * **T11-7 Ordering.** Every result list is deterministic: repeated runs byte-identical; content-identical workspaces in different directories produce identical output (H-6). +### 11.2 Availability on imperfect files + +Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are 11.3–11.5's, the availability rules this section's. + +* **T11.2-1 Parse-local structure, per-file masking, no writes.** Three spec files: A parseable with findings of both levels — an unresolved `d` reference and a self-cycle (resolution-level); a duplicate-ID pair, a malformed segment, an unknown prop, an invalid construct (per-file structural) — B unparseable, C finding-free. `view` over all three: A's full positional tree, construct ranges, raw attribute spellings, comment ranges, and occurrence positions are all served — structure survives A's own findings and B's invalidity; B contributes no view, its parse-failure finding accompanying; C's view is complete. The workspace fails `build`, so the gated reads report findings without answering (T13.3-3) while these surfaces answer per file — and modify nothing: graph data and derived files byte-identical around each invocation (11.2; the passing-workspace counterpart participates in refresh, T13.3-2). +* **T11.2-2 Spelled identities and interpreted data.** One file, each node's identity datum asserted via `view`: exactly one quoted static `id` → defined; a repeated `id` (values agreeing, and disagreeing — one arm each), a braced `id={"x"}`, a valueless `id`, and no `id` → each spells none, identity explicitly unavailable; two sections both spelling `x` → both unavailable, no winner, while a uniquely spelled `x.y` beneath one of them keeps its defined identity (a defined identity without defined prefix identities); descendants of a no-identity or malformed-identity section are undefined by inheritance; a section uniquely spelling `z` stays defined beside another section's invalid-form `id` attributes (uniqueness compares spelled identities only — an invalid form contests nothing). Interpreted tags and coverage: absent props define the defaults (no tags, coverage-required); a repeated, malformed, or invalid-valued `tags`/`coverage` leaves the interpreted value unavailable, its raw spelling still listed (T11.4-3). +* **T11.2-3 Invalid paths.** (Linux leg) A discovered spec source `a#b.mdx` and a non-UTF-8-named one (14.19): every node identity in each — root included — is explicitly unavailable while tree, ranges, and attributes stay on view; the condition-19 finding accompanies every answer whose domain includes the file; no identity over the invalid path is ever emitted, the non-UTF-8 path itself presented in the marked byte form (12.0, T12.7-1). A code source with `#` in its path defines no identity for its whole-file location or any unit: its spellings still record occurrences, each record's `source` explicitly unavailable (5.7, T11.3-1). Root identity is defined exactly when the file's path is valid. +* **T11.2-4 Resolution and expanded text.** Resolution turns on the referenced identity's own definedness: with duplicate spellings of `a` and a unique `a.b` beneath one bearer, a reference to `a.b` resolves and records its occurrence while a reference to `a` records none — ambiguous, every bearer undefined — reported by its finding's range, never as a record or an unavailable target. Expanded text via `view --text`: a chain A embeds B embeds C with an unresolved embedding in C → A's and B's own/subtree text unavailable (one unresolved spelling on the expansion path, or one embedding cycle — staged separately — poisons the whole value; partial expansion never occurs), sibling nodes with resolved expansions staying defined and byte-exact; removal classification is by syntactic form — after deleting an imported file, the importing file's text values are byte-identical to before (the import removed by form, its 14.15 finding notwithstanding), and a stray element (14.16) is content, preserved byte-for-byte in the enclosing text and located by its finding. +* **T11.2-5 Domain, findings, exits.** `view` naming only C (T11.2-1's finding-free file) → finding-free, exit 0, while A and B stay invalid — the domain is the requested files; naming A → A's findings of both levels accompany, exit 1, the full answer still emitted (the document complete and parseable, H-5); a two-file cycle accompanies whole when either participant is in the domain (14.9). Any finding or explicitly-unavailable datum → exit 1 with the full answer; complete and finding-free → exit 0; argument checks precede answering — unknown ``, wrong-kind ``, invalid glob, malformed `--to`, out-of-range offset each exit 2 whatever findings the named files carry (per-surface arms in T11.3-2/3, T11.4-2, T11.5-2). +* **T11.2-6 Never stale, gate findings never attach.** On a passing workspace, `occurrences`, `view`, and `at` participate in read-time refresh exactly as 13.3's reads (T13.3-2 covers them in its sweep); on a failing one they answer from current sources and write nothing (T11.2-1). A gate condition that is no domain file's finding accompanies no answer: with a garbage journal line (14.13) staged, and separately an obstructed write path (14.22), `view` of a finding-free file answers finding-free, exit 0 — those states surface through `build`, `check`, and the gated reads (13.3), never these answers. + +### 11.3 `xspec occurrences` + +* **T11.3-1 Enumeration.** Over the T5.7-* fixtures: every occurrence in occurrence order, each record carrying every 5.7 datum in the form-exact 12.7 record form (T12.7-1); in T11.2-3's invalid-path code source, records are served with `source` unavailable while `file`, `range`, `kind`, and `target` are present. +* **T11.3-2 `--file`.** A set restriction over discovered files, spec and code alike: a glob admitting a subset restricts the consulted domain — only its findings accompany; a glob matching no discovered file admits the empty set — an empty, finding-free answer, exit 0, no unknown-file usage error on this filter (contrast T11.4-2's operands); an outside-root pattern → exit 2 (invalid flag value, as 11.1); `--file` and `--to` combine conjunctively (a fixture where each filter alone admits more than the intersection). +* **T11.3-3 `--to`.** Acceptance is syntactic: well-formed spellings — `path#id`, bare `path`, an undiscovered file's identity, a masked file's, an undefined bearer's — are accepted and select the empty set (with the domain's findings; never an error); malformed spellings exit 2: more than one `#`, an empty path part, an empty segment (`a#b..c`), a whitespace-bearing or forbidden-name segment (`a#then`), a trailing empty id part (`a.mdx#`) (11.3, 1.4). Selection is exact: a resolving identity selects the occurrences targeting it — not its descendants' — and a bare path selects module-form root references (T2.2-2). +* **T11.3-4 Definitive emptiness.** In a valid workspace with no reference to node X: `occurrences --to X` → empty, finding-free, exit 0 — proof over the domain, absolute without `--file` (the whole discovered set consulted); restricted by `--file` away from a file that does hold a resolving occurrence of X, the answer is still empty, finding-free, exit 0 — the guarantee is domain-wide only, the outside occurrence neither reported nor denied (11.3). + +### 11.4 `xspec view` + +* **T11.4-1 Views and tree.** With neither operands nor `--file`, every discovered spec source is viewed, multi-file order by path bytes, one JSON document; per parseable file: the root and the full positional section tree in document order — a section nested inside an invalid non-section element parents to the innermost enclosing section construct (the enclosure 11.2's chain conditions read), the root when none encloses it; per node, its construct range and the decomposition: opening and closing tag ranges for paired sections, opening only for self-closing, neither for the root — byte-asserted against precomputed offsets (1.7). +* **T11.4-2 Operands vs restriction.** `` operands assert membership: an undiscovered file → exit 2 (unknown); a discovered code source → exit 2 (wrong-kind operand, 12.0); `--file` restricts the domain: a glob matching nothing, or only code sources, admits the empty set — empty, finding-free answer, exit 0; combining `` operands with `--file` → exit 2; the requested files form a set (a file named twice yields one view). +* **T11.4-3 Attributes and per-node data.** Raw attribute spellings as parsed, one entry per spelled attribute in tag order — a repeated `id` (both entries), an unknown prop, a spread attribute (`name` structurally absent, its text the whole braced construct), a valueless prop (bare name) — each with range and source text; inclusion is by form, the invalidity a located finding beside the view, never an omission (14.17). Per-node `identity`, `tags`, `coverage` each plain or explicitly unavailable per T11.2-2; a root's `tags` and `coverage` are structurally absent — the stated `null`, never the unavailability marker, no finding and no exit-1 consequence: a finding-free file's view exits 0 with them `null` (11.4, 12.7). +* **T11.4-4 Imports.** Every import declaration, valid and invalid, with its range; its binding name — the default binding's identifier; structurally absent for the side-effect-only, named-only, and namespace-only forms (never "unavailable"; a named-clause identifier is not this datum) — and its resolved target where specifier form and discovery define one, explicitly unavailable otherwise (`./typo.xspec`; a bare specifier), the invalidity a located 14.15 finding beside it (11.4). +* **T11.4-5 `--text` and the expansion domain.** With `--text`, each node carries own and subtree text per T11.2-4. The consulted domain: requesting only A, whose embeddings reach B and C transitively — B's and C's findings accompany (a deep unresolved spelling's or cycle's finding lies in a consulted file never requested); a non-occurrence-recording spelling is the expansion's boundary — no further file is consulted, the blocking finding lying in a file already consulted; a masked file is never consulted by expansion (no spelling resolves into it), its parse-failure finding accompanying only when itself requested; an unparseable requested file contributes no view; an invalid-path requested file keeps its view (T11.2-3). Without `--text`, requesting A consults A alone: B's findings absent, the exit following A's own findings. +* **T11.4-6 Byte classification.** On a finding-free file with imports, sections, tags, comments, and embeddings: from the view alone — tag ranges, attribute ranges, import ranges, comment ranges, embedding-occurrence container spans (5.7) — the harness classifies every byte as annotation or content and reproduces the compiled Markdown through the rules of 3, byte-equal to the emitted output (the P-2 oracle applied to view data). On an imperfect file, jointly with the findings: an invalid construct (no view entry) and a no-occurrence embedding spelling are located by their findings' ranges — the embedding form's finding spanning its full braced container (14, T14-8) — so view plus findings again position every removable construct. + +### 11.5 `xspec at` + +* **T11.5-1 Total resolution.** A file with imports, comments, nested sections, and between-section prose: offsets inside an import, a comment, deep section content, between sections, and inside opening and closing tags each resolve to the innermost section construct whose range contains the offset — the root where none does — reported with construct range and identity per 11.2; the offset equal to the file's byte length → the root; byte length + 1 → exit 2. Derivability: for every offset of the file, `at`'s resolution equals the resolution computed from the file's `view` data alone (11.5; P-12 generalizes). +* **T11.5-2 Offset spelling and operands.** `007` is accepted as 7 (leading zeros; ASCII decimal digits only); `+7`, `-1`, `" 7"`, `"7 "`, `0x7`, and an empty value each exit 2 — not a digits-only spelling (11.5). `` membership and wrong-kind checks as T11.4-2; the argument checks precede answering: the same errors on a finding-laden file still exit 2 (T11.2-5). +* **T11.5-3 Occurrences and imperfect files.** Offsets at a `d` reference expression's start, at its end − 1, and at its end, and likewise for an embedding container: within-range offsets report the containing occurrence's record and resolved target, the end offset and other outside offsets report none (start-inclusive, end-exclusive, 1.7); an unparseable file → resolution explicitly unavailable, the parse-failure finding accompanying, exit 1; a non-UTF-8-pathed source is nameable by no argument value (12.0) — every `at` spelling for it is an unknown file, exit 2 — the glob-reached view being the one route to its positions (T11.2-3; Linux leg). + +### 11.6 `xspec inventory` + +* **T11.6-1 Anchoring.** From the workspace root, `root` is `.`; from nested `a/b`, `root` is `../..` and `config` `../../xspec.config.ts`; from a sibling directory with `--config`, ascent `..` segments then descent segments, joined with `/`, no `.` segments, no trailing separator (11.6) — asserted byte-exactly, working-directory-dependence being pure invocation input (12.0). Drive-mismatch arm, Windows leg (E-6): a working directory and workspace root on different drive letters (a substituted drive mapping suffices) → the anchoring in the platform's absolute, drive-qualified spelling — the sole absolute-path case and sole platform-separator output — deterministic per invocation; on the Linux leg no absolute form ever appears. +* **T11.6-2 Configuration, sources, derived map.** The resolved view with every default and inferred kind explicit: `markdown` absent → `{"emit": false, "outDir": null}`; a defaulted profile → `targets` `"leaves"`, `edgeKinds` all three, `boundaryKind` explicit though inferred, `targetTags` `null`; group references inside profiles and rules stay configured names resolving against the reported group list; every discovered source with its group memberships (a two-group file); the derived map per spec source — module path, and Markdown destination exactly while emission is enabled, both present before any build has run (determined by configuration and discovery); a spec-group file without `.mdx` (14.19 staged beside it) → both structurally absent; with emission disabled → `markdown` `null` for every source (7.3, 12.7). +* **T11.6-3 Record, area, durables, order.** `recorded` is empty before any generation; after a build it lists the recorded derived paths — modules, companions, Markdown — each companion attributable to its source through the 13.1 naming scheme; after a configuration change without rebuild it lags, reported as recorded, not as configured (11.6). The graph-data area is reported unconditionally — before any build — as `.xspec`, no trailing separator; a foreign file placed under `.xspec/` (neither journal, session-named, nor recorded) appears in no inventory list and is never claimed (unattributed, 11.6). `journal` reports occupancy by presence alone: absent → `false`; a plain file, a directory, and a symlink each → `true`, no content read, no 14.13 from inventory. Sessions are selected by name alone: a product-written session, a garbage-content `S.json`, and a directory named `S2.json` are all listed (content unread, no 14.21 here); `notes.txt` and `.foo.json` never (10.1). Orders: paths byte order; groups, profiles, rules configuration order; session files byte order of file name. +* **T11.6-4 No parse, no write, one finding.** On a workspace whose sources fail every validation family — an unparseable file included — plus a garbage journal line and a corrupt session: `inventory` answers in full, finding-free, exit 0, modifying nothing (byte-compare; no refresh) — it parses no sources and reads no journal or session content, those findings reported where their conditions assign them, never here. Configuration errors keep precedence: missing and invalid configuration → exit 2, the error document, no inventory. The one finding it ever carries: with the record corrupted shape-blind (T6.6-6's staging), `recorded` is explicitly unavailable — never read as empty — with the condition-23 finding (stable code, concerned path the graph-data area), exit 1, every other member emitted in full (14.23). + ## 12. Commands ### 12.0 Global conventions -* **T12.0-1 --json everywhere.** For every command and subcommand this specification covers, `--json` emits exactly one JSON document as the entire standard output, carrying the same information as the human report (adapter-verified per command in the sections above; this test sweeps that every command accepts the flag). -* **T12.0-2 Streams.** A failing `build`'s validation errors and `check`'s findings are standard-output content (exit 1); usage/configuration errors print diagnostics to standard error with empty standard output under `--json` (exit 2); non-JSON diagnostics never contaminate a `--json` stdout. +* **T12.0-1 --json everywhere.** For every command and subcommand this specification covers, `--json` emits exactly one JSON document as the entire standard output, carrying the same information as the human report (adapter-verified per command in the sections above; this test sweeps that every command accepts the flag; the JSON-only surfaces of 10.7, 11, and 12.6 emit the same single document with the flag as without). +* **T12.0-2 Streams.** A failing `build`'s validation errors and `check`'s findings are standard-output content (exit 1); usage and configuration error messages are standard-error content. With JSON output in effect — `--json` among the arguments, even when the arguments are themselves the error (an unknown command; an unknown flag), or a JSON-only surface (10.7, 11, 12.6) — an exit-2 invocation emits the 12.7 error document as its entire stdout (T12.7-3); without JSON in effect, exit-2 stdout is empty. Non-JSON diagnostics never contaminate a JSON stdout, and the output form never changes an exit code. * **T12.0-3 --config.** Every command accepts `--config `; a relative path resolves against the working directory, not the workspace root. * **T12.0-4 Flag repetition.** Repeating a flag on any command → exit 2; list-valued flags take one comma-separated value (`--kinds depends,embeds`). * **T12.0-5 Argument addressing.** ``, ``, ``, and `--file` arguments are workspace-relative with `/` separators, independent of the working directory (run each representative command from a subdirectory); `--test-hold ` resolves against the working directory (13.5). Native-separator negative: an argument spelled with `\` (`specs\A.mdx`) names no workspace file — paths compare byte-wise — and is an unknown-file usage error, exit 2; discriminating on the Windows leg (E-6), where `\` is the native separator. An argument value that is not valid UTF-8 (raw bytes in the OS argument vector, Linux leg) → usage error, exit 2 (12.0). * **T12.0-6 Case and bytes.** IDs, tags, identities, session names, and paths compare byte-wise case-sensitively: `A.mdx` vs `a.mdx` identities are distinct; `--tag Foo` does not match `foo`; no Unicode normalization (NFC vs NFD spellings of one tag are two tags). Single-casing path probe, stageable on any filesystem: in a workspace whose only source is `specs/A.mdx`, an argument `specs/a.mdx` (`show`, representative) names no workspace file — paths compare byte-wise — and is an unknown-file usage error, exit 2; rerun on the Windows leg (E-6), where a product resolving path arguments through case-insensitive filesystem lookups wrongly finds the file. Sole exception: session-name creation collision (T10.1-2). * **T12.0-7 Determinism.** Representative sweep: `build` outputs, generated files, graph data, Markdown, journal entries, session files, and every report are byte-identical across repeated runs and across content-identical workspaces at different absolute paths (no wall-clock, randomness, absolute paths, or environment leakage; run with differing irrelevant environment variables). * **T12.0-8 Shortest-path tie-break.** Where one shortest path is reported (coverage 8.2, impact 9.3, reachable 11), among equal-length candidates the element-wise byte-least node-identity sequence is reported (dedicated fixtures per command). -* **T12.0-9 Exit-code partition.** A table-driven sweep asserting one representative per class per command family: 0 (success and informational reports: `ids`, `show`, `impact` with differences, `query`, review reads including fully-resolved `next`, `coverage` without `--check`); 1 (findings: failing `build`, `check` findings, `coverage --check` uncovered, refused `rename`/`move`, refused review operations, corrupt-session reports); 2 (usage/configuration: unknown command; unknown flag; missing required flag/argument; invalid flag value; unknown profile/session/group/item/node/file; invalid session name; configuration errors; unreadable baseline; mutual-exclusion refusal). -* **T12.0-10 Check ordering.** Covered by T6.4-4/T6.5-5 (rename/move existence checks precede source validation; unparseable-file masking flips to exit 1) and T6.3-4's precedence arm (baseline resolution precedes source validation). +* **T12.0-9 Exit-code partition.** A table-driven sweep asserting one representative per class per command family: 0 (success and informational reports: `ids`, `show`, `impact` with differences, `query`, review reads including fully-resolved `next`, `coverage` without `--check`, `version`, and complete finding-free answers — `occurrences`/`view`/`at` over a clean domain, `inventory`, a successful preview; 11.2, 11.6, 6.6); 1 (findings: failing `build`, `check` findings, `coverage --check` uncovered, refused `rename`/`move` and their refused previews, refused review operations, corrupt-session reports, and answers carrying findings or explicitly-unavailable data — emitted in full; 11.2, 11.6, 6.6); 2 (usage/configuration: unknown command; unknown flag; missing required flag/argument; invalid flag value; unknown profile/session/group/item/node/file — except `occurrences --to`, where only a malformed spelling is a usage error, T11.3-3; wrong-kind operands — a code source where a spec source or a requirement-node identity is required; invalid session name; configuration errors; unreadable baseline; mutual-exclusion refusal). +* **T12.0-10 Argument-check precedence.** Rename/move and baseline arms: T6.4-4/T6.5-5 (existence, kind, and masking) and T6.3-4. Gated reads (12.0): on one workspace failing `build`'s validations, each gated read given a usage-error argument exits 2 with that error and reports no validation findings — `coverage `; `query nodes --group `; `review status `; `show #` and `query node ` (T11-6) — each check judged from what it consults (configuration; the session directory; parse-local spelled identities of the named file, 11.2), with the same names on a valid twin workspace giving the same exit-2 errors. Masking: `show #` on the failing workspace → the gated report, exit 1 (as T6.4-4). Past the gate: on a passing workspace, `review resolve --status updated` reports the corruption, exit 1 — the item ID judged only against session content, which the corruption withholds (10.1; an unknown item ID in a well-formed session stays exit 2, T10.7-10). Within class 2: an unknown command and a repeated flag are reported without loading configuration — identically with the workspace's configuration file invalid or missing — while a configuration error precedes every check that consults configuration or discovery: `coverage ` with invalid configuration reports 14.14, not the unknown profile (12.0). * **T12.0-11 Git is read-only.** SPEC.md's preamble: git data is read only where explicitly stated and never written. On a freshly built git fixture, around each git-reading invocation — `impact --base`, `review create --base`, and `review status`/`next`/`resolve` on the resulting baseline session (whose generator runs reconstruct the recorded baseline, 6.3/10.4) — everything under `.git/` is byte-identical before and after (same file set, same bytes: refs, HEAD, index, and objects untouched), and no workspace file changes except those the command's own specification writes (the session file; none for `impact`). -* **T12.0-12 Git-less operation.** The non-baseline surface — `build`, `check`, `ids`, `show`, `coverage`, `query`, `rename`, `move`, and `review` with the `audit` and `coverage` strategies through `create`/`list`/`status`/`next`/`show`/`split`/`resolve`/`export` — runs to its specified outcomes in a workspace that is not a git repository and has no enclosing repository. Only baseline-taking invocations (`impact --base`, `review create --base`, later commands on a baseline session) require git; T10.6-1's git-less audit is one instance of this sweep. +* **T12.0-12 Git-less operation.** The non-baseline surface — `build`, `check`, `ids`, `show`, `coverage`, `query`, `occurrences`, `view`, `at`, `inventory`, `version`, `rename`, `move` (their `--preview` invocations included), and `review` with the `audit` and `coverage` strategies through `create`/`list`/`status`/`next`/`show`/`split`/`resolve`/`export` — runs to its specified outcomes in a workspace that is not a git repository and has no enclosing repository. Only baseline-taking invocations (`impact --base`, `review create --base`, later commands on a baseline session) require git; T10.6-1's git-less audit is one instance of this sweep. +* **T12.0-13 `#` in operands.** More than one `#` in a ``, ``, `--to`, or move-operand value (`a#b#c`) is a malformed value — exit 2 on `show`, `query node`, `occurrences --to`, and `move` (12.0). A bare `` operand or `--file` glob is a whole path or pattern with no delimiter role for `#`: with a discovered source `specs/a#b.mdx` staged (Linux leg; condition 19), `view specs/a#b.mdx` names that discovered file — membership holds, the view served with identities unavailable, exit 1 (T11.2-3), never a `specs/a` + `b.mdx` pair (which would be exit 2, unknown file); `at specs/a#b.mdx 0` resolves the same way, and `occurrences --file 'specs/a#*'` matches it as a pattern. ### 12.1 `xspec build` @@ -404,7 +467,7 @@ All `query` output is JSON-only: a `query` subcommand without `--json` also emit ### 12.2 `xspec check` * **T12.2-1 Green path.** On a freshly built valid workspace, `check` exits 0. -* **T12.2-2 Scope.** One workspace per finding family asserting `check` reports it with exit 1: all build validations (derived state from a prior valid build persists while the sources have since been edited to be invalid — `check` re-validates from the current sources rather than accepting the stale outputs); stale generated output and orphaned recorded derived file (14.10, `check`-only; asserted after hand-editing a generated file, hand-deleting one, editing a source without rebuilding, and disabling emission without rebuilding); unresolved/non-static references; cycles; journal integrity (14.13); policy (14.12, `check`-only); corrupt sessions (14.21). +* **T12.2-2 Scope.** One workspace per finding family asserting `check` reports it with exit 1: all build validations (derived state from a prior valid build persists while the sources have since been edited to be invalid — `check` re-validates from the current sources rather than accepting the stale outputs); stale generated output and orphaned recorded derived file (14.10, `check`-only; asserted after hand-editing a generated file, hand-deleting one, editing a source without rebuilding, and disabling emission without rebuilding); the unreadable-record unit form (14.10/14.23): with graph data corrupted shape-blind (T6.6-6's staging), `check` reports one condition-10 finding under the unit form alone — concerned path the graph-data area, no path inside it named, never the mismatch form beside it, and the recorded-file form undetectable while the state holds — and a successful `build` replaces the state (`check` clean afterward; `inventory` reports `recorded` again, T11.6-4); unresolved/non-static references; cycles; journal integrity (14.13); policy (14.12, `check`-only); corrupt sessions (14.21). * **T12.2-3 Never refreshes.** `check` on a stale workspace reports staleness and leaves graph data and derived files byte-identical (13.3). ### 12.3 `xspec ids` @@ -418,7 +481,20 @@ All `query` output is JSON-only: a `query` subcommand without `--json` also emit ### 12.5 Dispatch -* **T12.5-1** `coverage`, `impact`, `review`, `query`, `rename`, `move` behave per sections 8, 9, 10, 11, 6 (covered there); an unknown subcommand or command → exit 2. +* **T12.5-1** `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, `at`, `inventory`, `rename`, `move` behave per sections 8, 9, 10, 11, and 6 (covered there); an unknown subcommand or command → exit 2. + +### 12.6 `xspec version` + +* **T12.6-1 Surface and values.** `xspec version` emits, with and without `--json`, a single JSON document as its entire stdout in the 12.7 form: `{"product", "interface"}`, both strings, `interface` exactly `"1"` (form-exact, H-3); both values byte-identical across invocations of one build (fixed per build); usage errors keep exit 2 — an unknown flag on `version` yields the error document (T12.0-2). +* **T12.6-2 Workspace independence.** Byte-identical answers, exit 0: inside a valid workspace; in a directory with no discoverable configuration (where the other commands exit 2, T7-1); with invalid configuration present; and with `--config` naming a nonexistent and a malformed file — accepted, never consulted (12.6). Configuration-error precedence never reaches `version` (14.14): the same invalid-configuration fixture makes `build` exit 2, the discriminating pair. + +### 12.7 JSON document forms + +Assertions here — and wherever these forms appear across the suite — are form-exact (H-3): member names, `null`-vs-omission, `[]`-vs-`null`, and orderings are asserted literally, never adapted. + +* **T12.7-1 Value forms.** A source range is `{"start", "end"}`, non-negative integers, everywhere the 12.7 surfaces carry one. Paths: valid-UTF-8 paths are plain strings; a non-UTF-8 path (Linux leg) is `{"bytes": "…"}` — its exact bytes as lowercase hexadecimal, two digits per byte — asserted at each output the 12.0 rule names: an inventory source path, an occurrence's referencing file, a view's file and an import's resolved target, and a finding's location file and concerned path; a valid-UTF-8 path never takes the byte form. Unavailability is exactly `{"unavailable": true}`, and no object of any other form carries a member named `unavailable` (a structural walk over every 12.7 document the suite captures; S-5 guards the walk). A finding is `{"code", "message", "locations", "path", "identities"}`: `code` the stable token string or `null` where 14 assigns none (a review-refusal finding); `locations` one `{"file", "range"}` per offending construct, ordered by file bytes, then start, then end, `[]` for unlocated conditions; `path` `null` for located conditions, the concerned path otherwise; `identities` contractual where 14 states them — a policy finding carries the rule name, source identity, kind token, and target identity in that order with `locations` `[]` and `path` `null` (14.12), a cross-module call names the foreign module (14.11), a refusal reason its concerned identity (T14-7). +* **T12.7-2 Findings arrays and document forms.** A workspace staging several conditions, and a multi-reason refusal (T14-7): every findings array is ordered by code — numbered conditions in numeric order, then refusal reasons in 14's listed order, then code-less findings — then by locations element-wise (a proper prefix sorting first), then by concerned path (`null` first; byte-form and plain paths in one byte order), then by identities, then by message; identically-staged duplicate findings collapse to one. Document forms: `build`/`check`/gated-read/refused-operation reports are `{"findings": […]}`; a refused preview keeps `{"findings", "mapping", "files", "delta"}` with the three `null` (T6.6-3); `occurrences` is `{"findings", "occurrences"}`; `view` `{"findings", "views"}`, each node `{"identity", "range", "opening", "closing", "attributes", "tags", "coverage", "children"}` plus `ownText`/`subtreeText` exactly when `--text` is given (the stated conditional presence — absent without the flag), `attributes` entries `{"name", "range", "text"}`, imports `{"range", "name", "target"}`; `at` `{"findings", "resolution"}`, `resolution` `{"section", "occurrence"}` with `occurrence` `null` when the offset lies in none; `inventory` and previews per T11.6-* and T6.6-4/5; `version` `{"product", "interface"}`. Member presence: `null` is never omission (a refused preview still carries all four members; an unset `outDir` is `null`); empty lists are `[]`, never `null` (a finding-free `findings`, a root's `attributes`, an empty delta direction); stated `null`s vs structural absence per surface (a root's `tags`/`coverage`, T11.4-3; an absent `targetTags`, T11.6-2). +* **T12.7-3 Error document.** Exit-2 invocations with JSON in effect emit `{"error": …}` holding one finding form as the entire stdout: a configuration error → stable code `configuration-error` and concerned path in the anchoring form (the configuration file; for a failed upward search with no `--config`, `.`); a plain usage error → `code` and `path` `null`; one finding however many defects — a configuration file with several distinct defects yields a single condition-14 finding. JSON is in effect for a JSON-only surface without `--json` (`inventory` with an unknown flag) and whenever `--json` appears among the arguments, the arguments themselves erroneous included (an unknown command beside `--json`) — each the error document on stdout, diagnostics on stderr (T12.0-2). ## 13. Workspace Files @@ -433,9 +509,9 @@ All `query` output is JSON-only: a `query` subcommand without `--json` also emit ### 13.3 Graph data -* **T13.3-1 Serving reads.** After `build`, the read commands (`check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`) answer without error; graph data lives under `.xspec/`. -* **T13.3-2 Refresh.** Delete the graph data — operationally, here and in T13.4-3: every path under `.xspec/` except the durable `.xspec/journal` and `.xspec/reviews/` (13.4); graph-data content is opaque (H-4), so tests only ever remove or compare it whole — (or edit a source) and run each of `ids`, `show`, `coverage`, `impact`, `review status`, `query`: the answer reflects current sources (never stale data), graph data is rewritten as `build` would write it, but no TypeScript or Markdown is generated or removed and recorded derived-file paths are unchanged (a stale generated module stays stale — `check` still reports 14.10 afterwards). -* **T13.3-3 Failed refresh.** With invalid sources, each read command reports the validation errors, exits 1, answers nothing, and modifies nothing (derived files and graph data byte-identical). The mutating `review` subcommands observe the same rule — 13.3 binds `review` whole, and `create`, `resolve`, and `split` all consult the current graph: on a workspace holding a session, a configured coverage profile, and a resolvable commit from when its sources were valid, a source is then edited to fail validation, and `review create` under each of `--base` (the resolvable ref — baseline resolution precedes source validation, 12.0, so the refresh failure is the operative error), `--strategy audit`, and `--coverage`, plus `resolve` and `split` naming the pre-existing session and an unblocked item, each reports the validation errors, exits 1, and modifies nothing: no session file is created, and the existing session file, journal, derived files, and graph data are byte-identical (no status recorded, no decomposition). +* **T13.3-1 Serving reads.** After `build`, the read commands (`check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, `at`) answer without error; graph data lives under `.xspec/`. +* **T13.3-2 Refresh.** Delete the graph data — operationally, here and in T13.4-3: every path under `.xspec/` except the durable `.xspec/journal` and `.xspec/reviews/` (13.4); graph-data content is opaque (H-4), so tests only ever remove or compare it whole — (or edit a source) and run each of `ids`, `show`, `coverage`, `impact`, `review status`, `query`, `occurrences`, `view`, `at`: the answer reflects current sources (never stale data), graph data is rewritten as `build` would write it, but no TypeScript or Markdown is generated or removed and recorded derived-file paths are unchanged (a stale generated module stays stale — `check` still reports 14.10 afterwards). Record discipline (13.3): with the record corrupted shape-blind instead (T6.6-6's staging), each refreshing read answers finding-free — exit 0 on the otherwise clean workspace — reporting nothing for the record and leaving the corrupt state neither read, repaired, nor replaced: `inventory` afterwards still reports `recorded` unavailable (T11.6-4), until a successful `build` or a finishing regeneration (6.4) replaces the state. +* **T13.3-3 Failed refresh.** With invalid sources, each read command reports the validation errors, exits 1, answers nothing, and modifies nothing (derived files and graph data byte-identical). The mutating `review` subcommands observe the same rule — 13.3 binds `review` whole, and `create`, `resolve`, and `split` all consult the current graph: on a workspace holding a session, a configured coverage profile, and a resolvable commit from when its sources were valid, a source is then edited to fail validation, and `review create` under each of `--base` (the resolvable ref — baseline resolution precedes source validation, 12.0, so the refresh failure is the operative error), `--strategy audit`, and `--coverage`, plus `resolve` and `split` naming the pre-existing session and an unblocked item, each reports the validation errors, exits 1, and modifies nothing: no session file is created, and the existing session file, journal, derived files, and graph data are byte-identical (no status recorded, no decomposition). The never-gated contrast: on the same failing workspace `occurrences`, `view`, and `at` answer per file under 11.2 (T11.2-1) and `inventory` answers whatever the sources' validity (T11.6-4) — none of them modifying anything. * **T13.3-4 Determinism.** Graph data files are byte-deterministic across rebuilds of an identical workspace (content otherwise unasserted, H-4). ### 13.4 Derived and durable files @@ -462,13 +538,16 @@ All mutual-exclusion tests use the `--test-hold ` seam for determinism. ## 14. Validation Errors -Sections 1–13 exercise each numbered condition in its home context; this section adds the reporting-contract tests. Primary tests per condition (not exhaustive; the H-7 map is the complete record): 14.1 (T1.3-1), 14.2 (T1.3-2/3/4/6), 14.3 (T1.3-5), 14.4 (T1.4-1/4), 14.5/14.6/14.7 (T14-2), 14.8 (T2.4-2/3, T4.3-2, T4.5-3), 14.9 (T2.1-5, T5.3-1/2), 14.10 (T12.2-2), 14.11 (T4.4-1), 14.12 (T7.5-2/6), 14.13 (T6.1-3, T13.4-6), 14.14 (T7-1..T7.5-1), 14.15 (T2.1-2/3, T4-2), 14.16 (T2.7-1), 14.17 (T2.5-3, T2.7-3), 14.18 (T4.5-5), 14.19 (T1.5-2, T7.1-1), 14.20 (T1.6-5, T14-3, T14-5), 14.21 (T10.1-4), 14.22 (T13.4-6). +Sections 1–13 exercise each numbered condition in its home context; this section adds the reporting-contract tests. Primary tests per condition (not exhaustive; the H-7 map is the complete record): 14.1 (T1.3-1), 14.2 (T1.3-2/3/4/6), 14.3 (T1.3-5), 14.4 (T1.4-1/4), 14.5/14.6/14.7 (T14-2), 14.8 (T2.4-2/3, T4.3-2, T4.5-3), 14.9 (T2.1-5, T5.3-1/2), 14.10 (T12.2-2), 14.11 (T4.4-1), 14.12 (T7.5-2/6), 14.13 (T6.1-3, T13.4-6), 14.14 (T7-1..T7.5-1), 14.15 (T2.1-2/3, T4-2), 14.16 (T2.7-1), 14.17 (T2.5-3, T2.7-3), 14.18 (T4.5-5), 14.19 (T1.5-2, T7.1-1), 14.20 (T1.6-5, T14-3, T14-5), 14.21 (T10.1-4), 14.22 (T13.4-6), 14.23 (T6.6-6, T11.6-4, T12.2-2, T13.3-2); the refusal reasons and their codes (T14-7; staged at T6.4-3, T6.5-4, T6.5-6, T6.6-3); the stable-code and location-cardinality contracts (T14-6, T14-8). * **T14-1 Actionable and complete reporting.** A workspace seeded with several independent error conditions across files: `build` and `check` report each of them (not only the first), and every report identifies file and location and states a correction-oriented message (information presence, not wording). * **T14-2 Unresolved references.** A `d` reference, a `text(...)` target, and a TypeScript marker/`text` call that do not resolve → 14.5, 14.6, 14.7 respectively; the TS case is also a type error against the generated module (asserted when a prior valid generation exists). * **T14-3 Masking.** An unparseable file (14.20 — malformed MDX; malformed TS under the grammar its name selects: a TSX-only construct in a `.ts` file; invalid UTF-8; BOM) masks conditions inside itself, and every reference into it from other files reports as unresolved (14.5–14.7); the parse-failure location is reported. A configuration error suppresses all source analysis: only 14.14 is reported (exit 2) even with invalid sources present. -* **T14-4 Reporter matrix.** 14.10 and 14.12 reported by `check` only (a stale workspace `build`s successfully by regenerating; a policy-violating workspace `build`s successfully); 14.21 reported by `check`, by `review` subcommands naming the session, and by `review list` — not by `build`; every other condition reported by both `build` and `check`. +* **T14-4 Reporter matrix.** 14.10 and 14.12 reported by `check` only (a stale workspace `build`s successfully by regenerating; a policy-violating workspace `build`s successfully); 14.21 reported by `check`, by `review` subcommands naming the session, and by `review list` — not by `build`; 14.23 reported by `inventory` and `rename`/`move` previews only — `check` reports the state as 14.10's unit form, and `build` and the refreshing reads never do (the rebuild replaces the record; the reads leave it unconsulted, T13.3-2); 14.14 delivered as an exit-2 error by every command that loads configuration — never `version` (T12.6-2); every other condition reported by both `build` and `check`, and as a domain file's finding accompanying `occurrences`/`view`/`at` answers (11.2, T11.2-5). * **T14-5 Grammar selection.** A file matched by a code group and named `.tsx`, containing TSX-only syntax (not parseable as plain TypeScript — T14-3's construct) inside a named unit that also holds a dependency marker and a `text(...)` call: `build` succeeds — `.tsx` parses as TSX (14.20) — and the marker's `references` edge and the call's `embeds` edge are recorded and attributed to that unit per 4.6. The negative direction, the same TSX-only construct in a `.ts` file failing 14.20, is T14-3's; a further arm stages it in a code-group file of another name (`.mts`), failing 14.20 identically — any name but `.tsx` selects plain TypeScript (14.20), discriminating against products keying specifically on `.ts`. +* **T14-6 Stable codes.** For each of the 23 conditions, staged via its primary test's fixture and read from its stated reporter (T14-4): the finding carries the exact token 14 lists (`missing-id` … `unreadable-record`) as its `code` in the JSON report form, where 12.7 pins it — the value is the token string alone, the ordinal numeral no part of it — so a product omitting or misspelling a code fails even where exit class and located information are right. A plain usage error and a review-operation refusal carry no stable code — `code` `null` (14, T12.7-1/3). +* **T14-7 Refusal reasons.** Staged refusals asserting each stable code with its concerned file, range, or identity (14): `refused-invalid-id` (concerning the invalid identity; intrinsic form only — a structurally misplaced but intrinsically valid new ID reports `refused-structural-parent` alone, never both); `refused-identity-unchanged`, reported alone by an identity-unchanged rename — no collision reason beside it (6.4: the after-removal check collides with nothing); `refused-id-collision`, locating every colliding bearer; `refused-structural-parent` (the violated identity); `refused-cycle`, locating the would-be cycle's full path; `refused-destination-exists` (the occupied path; the section form's non-spec-source occupant included); `refused-missing-target-parent` (the target-parent identity); `refused-invalid-destination` (the destination path; the destination-side directory-component case of 6.5 reports this code, never 14.22 — a plain file staged as a destination directory component). `refused-unresolvable-reference` admits no fixture (T6.4-3/T6.5-6) and is asserted only as the always-passing side of successful operations. Every applicable reason reports together, one finding per reason: a section move staged to both collide (`` present in the target file) and create a dependency cycle reports both findings, never only the first. The invalid-workspace refusal reports the workspace's numbered findings alone — on a workspace failing validation, a rename staged to also collide reports the validation findings only, exit 1, no refusal reason evaluated or reported beside them (6.4, 14). +* **T14-8 Location cardinality.** A condition several constructs jointly violate is one finding locating every participant, each in its containing file: a triple-duplicated ID → one condition-3 finding with three locations (one per bearer, no representative chosen); an import-binding collision → one condition-15 finding locating every colliding declaration; a cross-file dependency cycle → one condition-9 finding locating its full path, every participating reference spelling, and a spec import cycle every participating import declaration; a no-occurrence MDX embedding spelling → its condition-6 finding's range the full braced container — the span its occurrence would occupy (5.7), keeping T11.4-6's byte classification exact; a policy finding → `locations` `[]`, `path` `null`, its context identities alone (T12.7-1). Location order within a finding is file bytes, then start, then end (12.7). ## 15. Example @@ -488,6 +567,8 @@ Property tests generate inputs from seeded, reproducible generators (H-10), asse * **P-8 Parser robustness.** Fuzzed byte inputs (mutated MDX/TS/config, invalid UTF-8, BOMs, giant nesting, pathological line terminators): every command terminates, never emits a partial JSON document on `--json`, and always exits 0, 1, or 2 per the 12.0 partition; `build` failures modify nothing. * **P-9 Review session invariants.** Random sequences of valid review operations (create/next/resolve/split/re-derive triggers) interleaved with workspace edits: at most one item per kind and scope node; `blockedBy` acyclic; retired `id`s never reused; `next` always returns an unblocked needing-review item or reports fully resolved; reads never change session bytes; stored sessions always re-read as non-corrupt. * **P-10 Concurrency.** Randomized schedules of concurrent readers and one mutating command (via `--test-hold` and process kills): readers observe only prior-or-complete file states (T13.5-5); mutual exclusion never loses a journal append or a resolution (post-hoc: journal lines = successful `rename`/`move` operations — the journal's only writers, 6.1; session statuses = successful resolves). +* **P-11 Availability robustness.** Fuzzed and mutated spec and code sources (P-8's generators — the availability contract is precisely an imperfect-input surface) driven through `occurrences`, `view` (with and without `--text`), and `at` at random offsets: every invocation terminates; stdout is one complete JSON document, never partial; the exit is 0 or 1 per 11.2 (2 only for staged argument errors); every datum is exactly one of plain value, `null`, or `{"unavailable": true}` (11.4, 12.7); any finding or unavailable datum implies exit 1 with the full document emitted, and exit 0 implies a finding-free document carrying none. +* **P-12 at ≡ view; occurrence order.** For random workspaces: for every file and every offset 0…byte length, `at`'s resolution — section identity, construct range, containing occurrence — equals the resolution computed from that file's `view` document alone (11.5, T11.5-1); and the workspace-wide `occurrences` enumeration equals the view-collected occurrences sorted by file bytes, range start, range end — total, duplicate-free, byte-identical across runs (5.7). ## 17. Self-Tests and Certification @@ -510,7 +591,7 @@ Confidence that the harness itself is correct comes primarily from certification * **E-3 Parallelism.** The suite runs its tests in parallel and MUST pass under parallel execution; multiple suite instances can run on one machine concurrently (H-1, T13.5-6). * **E-4 No production keys, no external services.** The suite uses no credentials and contacts no hosted services (there are none to test; git fixtures are local). * **E-5 Determinism of the suite.** Two consecutive full runs on one machine produce the same pass/fail results; flaky tests are defects. Property tests run a fixed seed set in CI (plus an optional randomized local mode reporting seeds). -* **E-6 Windows leg.** SPEC.md 1.5 requires `/`-separated workspace-relative paths in identities, outputs, and stored data on every platform, and a Linux runner cannot discriminate a product emitting native separators — `/` is the native separator there. The same by-construction masking covers case sensitivity: 12.0 (byte-wise, case-sensitive comparison, no case folding), 10.1 (every subcommand but `create` matches session names exactly), and 7 (case-sensitive glob matching) bind on every platform, but on Linux the case-sensitive filesystem enforces the distinctions on behalf of a product that resolves session names, path arguments, or glob matches through case-insensitive filesystem lookups; only a case-insensitive filesystem exposes such a product. A second GitHub CI leg on Windows runners therefore runs the platform-sensitive subset: the path and identity assertions T1.5-1, T1.5-3, and T12.0-5 (less its Linux-leg arm); the single-casing case-mismatch probes — stageable on any filesystem, each staging one casing and probing another — of T10.1-2 (`status Foo` against stored session `foo` → exit 2), T10.1-3 (`NAME.JSON` is no session: `status NAME` → exit 2), T12.0-6 (sole source `specs/A.mdx`, argument `specs/a.mdx` → exit 2), and T7-4 (glob `SPECS/*.mdx` over directory `specs/` → zero sources); plus one representative fixture exercising `build`, `check`, `query`, `coverage`, `impact`, a journaled `rename`, a journaled file-form `move`, and an `audit` review session (`review create --strategy audit`, `next --json`, a `resolve`, and `export`), whose reports, move-rewritten sources, generated files, emitted Markdown, graph data, journal, and session file are asserted byte-identical to the same fixture's results on the Linux leg (12.0: no environment-dependent content; session files and review payloads are stored data and output carrying identities and source ranges, 1.5; a product-to-itself comparison, permitted by H-4). The `move` is the subset's specifier-computation probe: it crosses directories in both rewrite directions — the moved file's own import specifiers and another file's import of its generated module are recomputed (6.5), the one operation that computes new relative specifiers between files, which a native-path-API product writes `\`-separated only on Windows — and `check` is clean after it (T6.4-7); `rename` rewrites IDs but computes no specifier paths, so it cannot stand in for this probe. Byte-identity is promised only for byte-identical input (12.0), and `impact --base` reads the git baseline: the fixture's repository is therefore scripted with pinned, platform-independent commit metadata — fixed author, committer, and timestamps over identical file bytes and messages — so both legs realize identical commit identities and every invocation, baseline-taking ones included, runs on byte-identical input; input-derived content a conforming product may echo (the resolved baseline commit in an impact report or its JSON, H-3) then compares equal too. The subset depends on no case-sensitive filesystem (each case probe stages a single casing), symlink creation, or POSIX signal semantics; the Windows leg carries only the Linux-masked classes (native separators, filesystem-mediated case distinctions, native-path specifier computation); everything else remains fully exercised on the Linux leg, and the local-only set stays empty (H-9, E-2). +* **E-6 Windows leg.** SPEC.md 1.5 requires `/`-separated workspace-relative paths in identities, outputs, and stored data on every platform, and a Linux runner cannot discriminate a product emitting native separators — `/` is the native separator there. The same by-construction masking covers case sensitivity: 12.0 (byte-wise, case-sensitive comparison, no case folding), 10.1 (every subcommand but `create` matches session names exactly), and 7 (case-sensitive glob matching) bind on every platform, but on Linux the case-sensitive filesystem enforces the distinctions on behalf of a product that resolves session names, path arguments, or glob matches through case-insensitive filesystem lookups; only a case-insensitive filesystem exposes such a product. A second GitHub CI leg on Windows runners therefore runs the platform-sensitive subset: the path and identity assertions T1.5-1, T1.5-3, and T12.0-5 (less its Linux-leg arm); the single-casing case-mismatch probes — stageable on any filesystem, each staging one casing and probing another — of T10.1-2 (`status Foo` against stored session `foo` → exit 2), T10.1-3 (`NAME.JSON` is no session: `status NAME` → exit 2), T12.0-6 (sole source `specs/A.mdx`, argument `specs/a.mdx` → exit 2), and T7-4 (glob `SPECS/*.mdx` over directory `specs/` → zero sources); plus the drive-mismatch anchoring arm of T11.6-1 — the sole platform-form output, stageable on no Linux runner — and one representative fixture exercising `build`, `check`, `query`, `coverage`, `impact`, `occurrences`, `view --text`, `at`, `inventory` (run from a nested working directory, pinning the relative `/`-joined anchoring), `version`, a `move --preview`, a journaled `rename`, a journaled file-form `move`, and an `audit` review session (`review create --strategy audit`, `next --json`, a `resolve`, and `export`), whose reports and JSON documents — the path- and range-dense occurrence, view, at, inventory, and preview documents included — move-rewritten sources, generated files, emitted Markdown, graph data, journal, and session file are asserted byte-identical to the same fixture's results on the Linux leg (12.0: no environment-dependent content; session files and review payloads are stored data and output carrying identities and source ranges, 1.5; a product-to-itself comparison, permitted by H-4). The `move` is the subset's specifier-computation probe: it crosses directories in both rewrite directions — the moved file's own import specifiers and another file's import of its generated module are recomputed (6.5), the one operation that computes new relative specifiers between files, which a native-path-API product writes `\`-separated only on Windows — and `check` is clean after it (T6.4-7); `rename` rewrites IDs but computes no specifier paths, so it cannot stand in for this probe. Byte-identity is promised only for byte-identical input (12.0), and `impact --base` reads the git baseline: the fixture's repository is therefore scripted with pinned, platform-independent commit metadata — fixed author, committer, and timestamps over identical file bytes and messages — so both legs realize identical commit identities and every invocation, baseline-taking ones included, runs on byte-identical input; input-derived content a conforming product may echo (the resolved baseline commit in an impact report or its JSON, H-3) then compares equal too. The subset depends on no case-sensitive filesystem (each case probe stages a single casing), symlink creation, or POSIX signal semantics — the drive-mismatch arm needs only a substituted drive mapping; the Windows leg carries only the Linux-masked classes (native separators, filesystem-mediated case distinctions, native-path specifier computation, the drive-mismatch anchoring form); everything else remains fully exercised on the Linux leg, and the local-only set stays empty (H-9, E-2). From 9efbd867d46b3227fe4ac7e9bd9b7f2a1bc11c39 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 16:43:24 +0000 Subject: [PATCH 032/226] sdg(phase-6): close literal-\$, read-gate, occupant, and directory-creation coverage gaps (iter 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied all review items; none rejected. - C1: T7.5-5 gains literal-$ arms ($0, trailing $, $ before non-digit in from and to: no 14.14, literal-byte matching); P-7 generators extended to the $ capture boundary. - C2: T13.3-3 gains gate arms on T11.2-6's fixtures — garbage journal line (14.13) and obstructed write path (14.22) each gate the six reads: finding reported, exit 1, nothing answered, nothing modified. - C3: T12.2-2 gains occupant-kind arms — symlink to byte-identical generated content, and a directory, each judged stale by check (14.10). - C4: new T13.4-8 — writes create missing intermediate directories for the three 13.4-named cases (file-form move, created target file, first emission under nested outDir). - I1: T1.3-6 gains repeated-id and braced-id arms — 14.17 on the bearer, no 14.1, condition-2 masked for immediate children only. - I2: T6.5-4 file-form destination-exists split into plain-file, symlink, and broken-symlink occupant arms (refused-destination-exists). - O1: T4.5-2 gains the upstream root-marker arm (effectiveHash-only change, transitively impacted). - O2: T13.4-6 gains the 14.22 finding-cardinality arm (one per distinct offending component). - O3: retirement notes at 6.6/6.7 trimmed to the bare ID-ledger fact. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index bfc9acf..e2bfb48 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -48,7 +48,7 @@ These requirements bind the harness implementation regardless of test framework * **T1.3-3 Level skipping.** A child whose ID adds two segments (`a` containing `a.b.c` with no `a.b` section) fails with 14.2. * **T1.3-4 Top-level segment count.** A top-level section with a multi-segment ID fails; a one-segment top-level ID passes (checked against the empty prefix, 14.2). * **T1.3-5 Duplicate IDs.** Two sections with the same ID in one file fail with 14.3; the same ID in two different files is valid (uniqueness is per file, identities differ by path, 1.5). -* **T1.3-6 Missing-id masking.** A section lacking `id` with children: the immediate children report no 14.2 (masked by 14.1), while their other conditions and the grandchildren's structural checks still report (14.2 masking note). +* **T1.3-6 No-identity masking.** A section lacking `id` with children: the immediate children report no 14.2 (masked by 14.1), while their other conditions and the grandchildren's structural checks still report (14.2 masking note). Invalid-form arms (14.1: a repeated `id` attribute or a non-quoted-static value is condition 17, never condition 1, and masks condition 2 for the immediate children the same way): a repeated-`id` section and a braced-`id` (`id={"x"}`) section, each with an immediate child whose ID the structural rule would otherwise judge — each bearer reports 14.17 and no 14.1, its immediate children report no 14.2, and their own children's structural checks still report (14.2). ### 1.4 ID segments and tags @@ -170,7 +170,7 @@ Consumer programs in this section are compiled and run under standard TypeScript ### 4.5 Dependency markers * **T4.5-1 Marker semantics.** A bare requirement reference as an expression statement records a `references` edge from the enclosing code location; at runtime the program behaves as if the line were absent (harmless property read) with no additional tooling installed. -* **T4.5-2 Root marker.** A bare reference to the default export records a `references` edge to the root; it grants no coverage in any profile (roots never appear in coverage paths and root-targeted edges never extend one, 8; T8-5), but the code location is impacted (9.2) by any text edit in the document — an edit changing the root's subtreeHash or effectiveHash (4.5). +* **T4.5-2 Root marker.** A bare reference to the default export records a `references` edge to the root; it grants no coverage in any profile (roots never appear in coverage paths and root-targeted edges never extend one, 8; T8-5), but the code location is impacted (9.2) by any text edit in the document — an edit changing the root's subtreeHash or effectiveHash (4.5). Upstream arm (4.5: in the document or upstream of it): with the marker's document bearing a root-sourced dependency edge into another file (a top-level `{text(...)}`, as T8-5), an edit in that file changing only the root's effectiveHash leaves the location impacted — transitively (9.2), no node of the marker's document `changed`. * **T4.5-3 Static rule in TS.** A non-static bare reference in expression-statement position (computed index by variable, optional chaining, etc.) fails with 14.8 (invalid argument, not 14.18). * **T4.5-4 Shadowing.** A local declaration shadowing the import binding: chains rooted at the local are not spec references — no edge, no error, program builds. * **T4.5-5 Sanctioned uses only.** Each fails with 14.18: aliasing a node to a variable; destructuring the module; re-exporting the binding; storing a node in an array/object; passing a node to a function other than a spec module's `text` export; using `text` as a value (passing/storing it) other than as a callee. @@ -266,13 +266,13 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T6.5-1 File form.** IDs unchanged; identities change file part only; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; journal appended; finishing regeneration as T6.4-7; the applied-mapping report as T6.4-1 (6.5: both forms report as rename does). * **T6.5-2 Section form text edits.** Byte-exact fixtures: moved text spans opening tag's first character through closing tag's last; origin deletion drops lines left empty/whitespace-only (rule of 3); insertion immediately before the target parent's closing tag (or end of file for top-level `new-id`), followed by U+000A and preceded by one when not at line start; target file created when absent; no other byte changes beyond these edits, the identity and reference rewrites, and the finishing regeneration (6.5). Self-closing arms (1.1, 6.5), byte-exact: moving a self-closing section moves exactly the self-closing tag's own characters; a self-closing target parent is first rewritten to paired form — its `/` and any whitespace immediately before or after the `/` deleted, the closing tag matching the opening tag's name appended immediately after the tag's terminating `>` — and the insertion rule then applies before that closing tag: a `` parent becomes `` + U+000A + the moved text + U+000A + ``. * **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. -* **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists; section form whose `` is invalid per 1.4 (the mirrored "new ID is valid" check, 6.5) — a forbidden name (`then`) and a whitespace-bearing segment, one arm each; section form whose `` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; containing `#`; not valid UTF-8 (Linux leg); lacking `.mdx`. Plus the valid-workspace precondition as T6.4-6. +* **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link (target absent), one arm each (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose `` is invalid per 1.4 (the mirrored "new ID is valid" check, 6.5) — a forbidden name (`then`) and a whitespace-bearing segment, one arm each; section form whose `` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; containing `#`; not valid UTF-8 (Linux leg); lacking `.mdx`. Plus the valid-workspace precondition as T6.4-6. * **T6.5-5 Usage errors (exit 2).** Nonexistent origin file or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y` and `a.mdx#x b.mdx` match neither form — exit 2 — and an operand containing `#` is always a `#` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). * **T6.5-6 Identity terms.** The new-identity checks read in identity terms (6.5): a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid; the exact self-move — `#` equal to `#` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. ### 6.6 Previews -(T6.6-1 is retired: it covered manual restructuring, formerly SPEC.md 6.6, now 6.7 — see T6.7-1.) +(T6.6-1 is retired.) * **T6.6-2 Modifies nothing.** A rename `--preview` and a section-form move `--preview` on workspaces where the real operation would proceed: exit 0, findings `[]`, and every byte of the workspace identical afterward — sources, journal (an absent journal stays absent), derived files, and graph data untouched; a subsequent real run on the same state performs the previewed plan, its applied mapping (T6.4-1) equal to the preview's `mapping`. Preview output is byte-deterministic across repeated runs (H-6) and, under `--json`, the form-exact 12.7 preview document (H-3). * **T6.6-3 Refusal and scheduling equivalence.** For each refusal of T6.4-3 and T6.5-4 — the invalid-workspace precondition included — staged identically, the `--preview` invocation reports the same findings (same stable codes, locations, identities; 14) and exits 1, its `mapping`, `files`, and `delta` `null` (12.7), modifying nothing; for the usage errors of T6.4-4/T6.5-5 the preview exits 2 identically (argument checks precede either way). The equivalence is over workspace state, never scheduling (6.6): while another mutating command is held (`--test-hold`, 13.5), a `--preview` invocation runs to completion — it takes no exclusivity and never meets the mutual-exclusion refusal of T13.5-2 — and `--test-hold` combined with `--preview` is a usage error, exit 2. @@ -282,7 +282,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ### 6.7 Manual restructuring -* **T6.7-1** Renaming an ID by editing the file directly: no journal entry; impact reports a deletion plus an addition (not continuity); dependents referencing the old identity fail validation (14.5) until rewritten. (Formerly T6.6-1, retired when SPEC.md 6.6 became Previews.) +* **T6.7-1** Renaming an ID by editing the file directly: no journal entry; impact reports a deletion plus an addition (not continuity); dependents referencing the old identity fail validation (14.5) until rewritten. ## 7. Project Configuration @@ -301,7 +301,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T7.5-2 forbidden.** An edge whose source matches `from` and target matches `to` is a finding of `check` (rule name + offending edge, exit 1); non-matching edges are not; `kinds` restricts which edges are evaluated. * **T7.5-3 allowedOnly.** Every edge from a `from`-matching source must have a `to`-matching target; each violating edge is a separate finding. * **T7.5-4 Selectors.** `group` (with `kind` where needed) matches nodes of spec groups and code locations of code groups; `files` matches by glob; `tags` matches nodes carrying at least one listed tag. -* **T7.5-5 Captures.** `$1-$2.ts` against `a-b-c.ts` captures `a` and `b-c`; `*$1*` against `abc` captures `a`; a capture never matches `/` or the empty string; a `to` with captures matches only when expansions agree (mirror-structure policy fixture passes for agreeing pairs, violates for disagreeing ones); left-to-right shortest-match disambiguation is deterministic (repeat runs identical). +* **T7.5-5 Captures.** `$1-$2.ts` against `a-b-c.ts` captures `a` and `b-c`; `*$1*` against `abc` captures `a`; a capture never matches `/` or the empty string; a `to` with captures matches only when expansions agree (mirror-structure policy fixture passes for agreeing pairs, violates for disagreeing ones); left-to-right shortest-match disambiguation is deterministic (repeat runs identical). Literal `$` forms (7.5: a capture is exactly `$` followed by one digit `1`–`9` — every other `$` is a literal byte in either pattern, never a capture or a capture violation): patterns containing `$0`, a trailing `$`, and `$` before a non-digit, staged in `from` and in `to` (one arm each), load without 14.14 — a `to` containing `$0` or ending in `$` references no absent capture — and match exactly the paths spelling those literal bytes: `a$0.ts` matches the file `a$0.ts` and never `ab.ts` (what a capture reading would match), and a trailing-`$` pattern matches only the `$`-suffixed name. * **T7.5-6 build vs check.** `build` succeeds and regenerates output on a workspace full of policy violations (12.1); only `check` reports them (14.12). ## 8. Coverage @@ -467,7 +467,7 @@ Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are ### 12.2 `xspec check` * **T12.2-1 Green path.** On a freshly built valid workspace, `check` exits 0. -* **T12.2-2 Scope.** One workspace per finding family asserting `check` reports it with exit 1: all build validations (derived state from a prior valid build persists while the sources have since been edited to be invalid — `check` re-validates from the current sources rather than accepting the stale outputs); stale generated output and orphaned recorded derived file (14.10, `check`-only; asserted after hand-editing a generated file, hand-deleting one, editing a source without rebuilding, and disabling emission without rebuilding); the unreadable-record unit form (14.10/14.23): with graph data corrupted shape-blind (T6.6-6's staging), `check` reports one condition-10 finding under the unit form alone — concerned path the graph-data area, no path inside it named, never the mismatch form beside it, and the recorded-file form undetectable while the state holds — and a successful `build` replaces the state (`check` clean afterward; `inventory` reports `recorded` again, T11.6-4); unresolved/non-static references; cycles; journal integrity (14.13); policy (14.12, `check`-only); corrupt sessions (14.21). +* **T12.2-2 Scope.** One workspace per finding family asserting `check` reports it with exit 1: all build validations (derived state from a prior valid build persists while the sources have since been edited to be invalid — `check` re-validates from the current sources rather than accepting the stale outputs); stale generated output and orphaned recorded derived file (14.10, `check`-only; asserted after hand-editing a generated file, hand-deleting one, editing a source without rebuilding, and disabling emission without rebuilding; occupant-kind arms — 14.10: the per-file comparison judges the path's occupant itself, never traversing a symbolic link — a generated module's path occupied by a symbolic link whose target holds byte-identical generated content, the discriminating arm a link-following product wrongly passes, and by a directory: each stale, exactly as a missing or content-differing file); the unreadable-record unit form (14.10/14.23): with graph data corrupted shape-blind (T6.6-6's staging), `check` reports one condition-10 finding under the unit form alone — concerned path the graph-data area, no path inside it named, never the mismatch form beside it, and the recorded-file form undetectable while the state holds — and a successful `build` replaces the state (`check` clean afterward; `inventory` reports `recorded` again, T11.6-4); unresolved/non-static references; cycles; journal integrity (14.13); policy (14.12, `check`-only); corrupt sessions (14.21). * **T12.2-3 Never refreshes.** `check` on a stale workspace reports staleness and leaves graph data and derived files byte-identical (13.3). ### 12.3 `xspec ids` @@ -511,7 +511,7 @@ Assertions here — and wherever these forms appear across the suite — are for * **T13.3-1 Serving reads.** After `build`, the read commands (`check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, `at`) answer without error; graph data lives under `.xspec/`. * **T13.3-2 Refresh.** Delete the graph data — operationally, here and in T13.4-3: every path under `.xspec/` except the durable `.xspec/journal` and `.xspec/reviews/` (13.4); graph-data content is opaque (H-4), so tests only ever remove or compare it whole — (or edit a source) and run each of `ids`, `show`, `coverage`, `impact`, `review status`, `query`, `occurrences`, `view`, `at`: the answer reflects current sources (never stale data), graph data is rewritten as `build` would write it, but no TypeScript or Markdown is generated or removed and recorded derived-file paths are unchanged (a stale generated module stays stale — `check` still reports 14.10 afterwards). Record discipline (13.3): with the record corrupted shape-blind instead (T6.6-6's staging), each refreshing read answers finding-free — exit 0 on the otherwise clean workspace — reporting nothing for the record and leaving the corrupt state neither read, repaired, nor replaced: `inventory` afterwards still reports `recorded` unavailable (T11.6-4), until a successful `build` or a finishing regeneration (6.4) replaces the state. -* **T13.3-3 Failed refresh.** With invalid sources, each read command reports the validation errors, exits 1, answers nothing, and modifies nothing (derived files and graph data byte-identical). The mutating `review` subcommands observe the same rule — 13.3 binds `review` whole, and `create`, `resolve`, and `split` all consult the current graph: on a workspace holding a session, a configured coverage profile, and a resolvable commit from when its sources were valid, a source is then edited to fail validation, and `review create` under each of `--base` (the resolvable ref — baseline resolution precedes source validation, 12.0, so the refresh failure is the operative error), `--strategy audit`, and `--coverage`, plus `resolve` and `split` naming the pre-existing session and an unblocked item, each reports the validation errors, exits 1, and modifies nothing: no session file is created, and the existing session file, journal, derived files, and graph data are byte-identical (no status recorded, no decomposition). The never-gated contrast: on the same failing workspace `occurrences`, `view`, and `at` answer per file under 11.2 (T11.2-1) and `inventory` answers whatever the sources' validity (T11.6-4) — none of them modifying anything. +* **T13.3-3 Failed refresh.** With invalid sources, each read command reports the validation errors, exits 1, answers nothing, and modifies nothing (derived files and graph data byte-identical). The mutating `review` subcommands observe the same rule — 13.3 binds `review` whole, and `create`, `resolve`, and `split` all consult the current graph: on a workspace holding a session, a configured coverage profile, and a resolvable commit from when its sources were valid, a source is then edited to fail validation, and `review create` under each of `--base` (the resolvable ref — baseline resolution precedes source validation, 12.0, so the refresh failure is the operative error), `--strategy audit`, and `--coverage`, plus `resolve` and `split` naming the pre-existing session and an unblocked item, each reports the validation errors, exits 1, and modifies nothing: no session file is created, and the existing session file, journal, derived files, and graph data are byte-identical (no status recorded, no decomposition). The gate is over every finding a `build` would report, source validity or not (13.3: source validation errors, journal errors, and refused writes alike): on an otherwise-valid workspace with a garbage journal line staged, and separately with an obstructed write path staged (T11.2-6's two fixtures), each of `ids`, `show`, `coverage`, `impact`, `review status`, and `query` reports that finding — the journal error (14.13) naming the line, the refused write (14.22) its offending component — exits 1, answers nothing, and modifies nothing (journal, sessions, derived files, and graph data byte-identical), discriminating a product that gates on source validity alone and answers `query` from a broken journal with exit 0 (refresh consumes the journal for canonical identities, 5.4). The never-gated contrast: on the same failing workspaces `occurrences`, `view`, and `at` answer per file under 11.2 (T11.2-1, T11.2-6) and `inventory` answers whatever the sources' validity (T11.6-4) — none of them modifying anything. * **T13.3-4 Determinism.** Graph data files are byte-deterministic across rebuilds of an identical workspace (content otherwise unasserted, H-4). ### 13.4 Derived and durable files @@ -521,8 +521,9 @@ Assertions here — and wherever these forms appear across the suite — are for * **T13.4-3 Orphan knowledge boundary.** Build so a derived file exists and is recorded; delete all graph data (T13.3-2's operational definition), taking the recorded derived-file paths with it; change the configuration so that file is no longer generated; `build`: the orphaned file is outside xspec's knowledge and is not removed; it may be deleted manually (asserted: subsequent builds leave the stray file alone). * **T13.4-4 Derived paths belong to xspec.** A user-created file at a derived path is replaced by `build`; a symbolic link at a derived file's own path is replaced as the occupant — nothing is written through it (link target byte-identical after build, link gone, plain file present; not an error). * **T13.4-5 Durable protection.** `build` and read commands never modify or delete the journal or session files (byte-compare); durable files are never regenerated (deleting a session file: xspec does not recreate it; review naming it → exit 2 unknown session). -* **T13.4-6 Symlink write rules.** A write whose path has a symbolic link at a workspace-relative directory component is refused before anything is modified (14.22; command exits 1 with the report, workspace byte-identical); `check` reports the same without writing; a durable path occupied by a symlink or non-plain file → journal error (14.13) / corrupt session (14.21) — never read, appended, or replaced (link and target byte-identical after the attempt). Positive counterpart: path components above the workspace root are unrestricted (13.4) — a workspace whose absolute path traverses a symbolic link above its root builds, mutates, and `check`s normally. +* **T13.4-6 Symlink write rules.** A write whose path has a symbolic link at a workspace-relative directory component is refused before anything is modified (14.22; command exits 1 with the report, workspace byte-identical); `check` reports the same without writing; finding cardinality (14.22: one finding per distinct offending component, whatever write paths it refuses) — one non-directory occupant at a component under which two derived files would be written yields one finding, concerned path that component, and two distinct offending components yield two findings (asserted via `check`); a durable path occupied by a symlink or non-plain file → journal error (14.13) / corrupt session (14.21) — never read, appended, or replaced (link and target byte-identical after the attempt). Positive counterpart: path components above the workspace root are unrestricted (13.4) — a workspace whose absolute path traverses a symbolic link above its root builds, mutates, and `check`s normally. * **T13.4-7 Source exclusion.** T7-6 covers `.xspec.`/`.xspec/`/emit-destination exclusion from groups. +* **T13.4-8 Writes create missing directories.** A missing intermediate directory never refuses or fails a write: the nonexistent workspace-relative directory components of a written path come into existence as directories (13.4), each named case staged with its directories absent beforehand and asserted present as real directories afterward — a file-form move to `new/deep/b.mdx` (destination in a configured spec group, `new/` absent) succeeds, the moved file and its regenerated derived files under the fresh directories; a section-form move whose created target file (6.5) lies under an absent directory succeeds likewise; a first emission under a nested nonexistent `markdown.outDir` (7.3) writes every destination, creating the chain. ### 13.5 Concurrency and isolation @@ -563,7 +564,7 @@ Property tests generate inputs from seeded, reproducible generators (H-10), asse * **P-4 Hash laws.** For random workspaces and random single edits: subtreeHash changed iff the 5.5 condition holds; metadataHash changed iff `d`/`coverage`/`tags` changed; ownHash insensitive to embedded-target edits; effectiveHash monotone over the dependency closure (any dependency-target effectiveHash change propagates); identical workspaces hash identically. * **P-5 Rename/move purity.** Random valid workspaces, random journaled rename/file-move sequences: all hashes byte-stable, impact against any prior commit in the sequence reports no categories, and all references still resolve; random section moves: only the predicted parents gain categories. * **P-6 Baseline replay.** Random edit/rename/move/commit interleavings: impact categories against each historical baseline equal an oracle diff of the two graphs with identities mapped through the journal suffix. -* **P-7 Glob and capture matching.** Random patterns and paths over the 7/7.5 grammar, with generators including the glob metacharacters of common dialects (`[` `]` `{` `}` `!` `+` `(` `)`), which the 7 grammar treats as literals: match decisions and capture values equal a spec oracle; every match is unique under the left-to-right shortest-match rule; captures never span `/` and never match empty. +* **P-7 Glob and capture matching.** Random patterns and paths over the 7/7.5 grammar, with generators including the glob metacharacters of common dialects (`[` `]` `{` `}` `!` `+` `(` `)`), which the 7 grammar treats as literals, and `$` forms at the capture boundary (`$0`, `$` before a non-digit, trailing `$` — literals in 7.5 patterns; T7.5-5): match decisions and capture values equal a spec oracle; every match is unique under the left-to-right shortest-match rule; captures never span `/` and never match empty. * **P-8 Parser robustness.** Fuzzed byte inputs (mutated MDX/TS/config, invalid UTF-8, BOMs, giant nesting, pathological line terminators): every command terminates, never emits a partial JSON document on `--json`, and always exits 0, 1, or 2 per the 12.0 partition; `build` failures modify nothing. * **P-9 Review session invariants.** Random sequences of valid review operations (create/next/resolve/split/re-derive triggers) interleaved with workspace edits: at most one item per kind and scope node; `blockedBy` acyclic; retired `id`s never reused; `next` always returns an unblocked needing-review item or reports fully resolved; reads never change session bytes; stored sessions always re-read as non-corrupt. * **P-10 Concurrency.** Randomized schedules of concurrent readers and one mutating command (via `--test-hold` and process kills): readers observe only prior-or-complete file states (T13.5-5); mutual exclusion never loses a journal append or a resolution (post-hoc: journal lines = successful `rename`/`move` operations — the journal's only writers, 6.1; session statuses = successful resolves). From 638cfd0784d3abbfbb4a67a3a7884341e6dececa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 17:36:20 +0000 Subject: [PATCH 033/226] sdg(phase-6): close move-refusal, gate-vs-corruption, presence, and MDX-boundary gaps (iter 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied all items of the iteration-3 review of TEST-SPEC.md: - C1: derived-path arm of refused-invalid-destination staged in T6.5-4 (markdown.outDir emit-destination component obstructed — the separable fixture, module/companion paths sharing the destination's directory); T14-7 cross-ref extended. - C2: new T10.1-5 — failing workspace holding a corrupt session: gated review subcommands and list report the gate's findings alone (no 14.21, session bytes untouched) while check reports 14.21 beside them; T14-4 reporter matrix and the 14.21 primary-test list updated. - I1: named section-form target-path occupant arms in T6.5-4 (directory; symlink resolving to a discovered spec source; out-of-group .mdx file). - I2: pure context-node and origin-node presence-invalidation arms added to T10.4-2 (metadata-consistency and dependency-consistency recipes). - I3: T3-1 grammar-boundary sharpening (construct-like bytes in fences and inline code are content: no nodes, no edges, no findings, bytes preserved); P-2 generators sharpened to match. - O1: zero-/two-argument TS text(...) arity arms added to T4.3-2. - O2: embedding half of 10.5's added-target note staged in T10.5-3. - O3: new P-13 coverage-reachability property with independent oracle; S-6 extended to vet that oracle. Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index e2bfb48..c4b088f 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -128,7 +128,7 @@ These requirements bind the harness implementation regardless of test framework All tests here run with `markdown: { emit: true }` and byte-assert emitted files, except T3-6 (emission scope). -* **T3-1 Removals.** Imports, ``/`` opening and closing tags with all their props, and MDX comments are removed by exact textual deletion in place; all other Markdown content and author whitespace is preserved byte-for-byte (fixture with tables, code fences, trailing spaces, blank lines). +* **T3-1 Removals.** Imports, ``/`` opening and closing tags with all their props, and MDX comments are removed by exact textual deletion in place; all other Markdown content and author whitespace is preserved byte-for-byte (fixture with tables, code fences, trailing spaces, blank lines). Grammar boundary: constructs exist only where the MDX parse yields them (2.7, 14.16, 14.20) — fenced code blocks and inline code spans are literal text — so the fixture's fences and an inline code span contain construct-like bytes (``, `
`, `import X from "./X.xspec"`, and `{text("a")}`): they create no node and no edge (`query nodes`/`query edges`), trigger no finding of any kind (`build` and `check` exit 0), and are preserved into the output byte-for-byte, discriminating a product that removes constructs by textual pattern rather than by parse. * **T3-2 Replacement.** Each `text(...)` expression is replaced by the target's compiled subtree text, fully expanded through chained embeddings (A embeds B embeds C). * **T3-3 Line-drop rule.** A line that contained non-whitespace in the source and is left empty or whitespace-only purely by removals is dropped with its terminator: covers a line holding only an import; a line holding only an opening tag; a line holding only a closing tag; a line holding only a comment; a line holding only a `text(...)` whose expansion is empty. Counter-cases: a line that was already empty in the source is kept; a line keeping any content keeps its terminator; a removal-affected line that retains other content is kept; a line holding only a `text(...)` whose expansion is whitespace-only but non-empty — target subtree text a single space, e.g. an in-line section whose sole content is one space — is kept with that expansion and its terminator: neither drop cause applies (the line is not left whitespace-only purely by removals, and the expansion is not empty), discriminating a product that drops any whitespace-only result line whose source line held non-whitespace. Class boundaries (1.4): a line left holding only U+00A0, U+0085, or U+2028 after removals is kept — those code points are neither whitespace nor line terminators — while a line left holding only U+0009 or U+0020 drops. Multi-line constructs: a construct whose own characters include a line terminator (a multi-line MDX comment) is deleted exactly, merging the surrounding lines' residues into one line — a fixture with retained non-whitespace on both sides (`foo {/* …` on one line, `… */} bar` on the next) compiles to `foo bar` on one line, byte-asserted; an own-lines multi-line comment (empty residues) leaves the merged line empty purely by removals, and it drops with its terminator. * **T3-4 Line terminators.** CRLF, lone LF, and lone CR terminators are each recognized as one terminator by the drop rule; a final line without a terminator survives compilation without gaining one (byte-asserted fixtures for each). @@ -160,7 +160,7 @@ Consumer programs in this section are compiled and run under standard TypeScript ### 4.3 text * **T4.3-1** `text(node)` returns the node's subtree text as a `string` at runtime (byte-compared to expected expansion) and records an `embeds` edge from the calling code location to the node (`query edges`). -* **T4.3-2** A string argument to `text` in a TypeScript file fails with 14.8; so does a dynamic node-form argument there — a computed index by variable and an optional-chaining chain, each as the `text` argument (2.4, 4.5). +* **T4.3-2** A string argument to `text` in a TypeScript file fails with 14.8; so does a dynamic node-form argument there — a computed index by variable and an optional-chaining chain, each as the `text` argument (2.4, 4.5); and so do a zero-argument and a two-argument `text(...)` call in a TypeScript file (14.8's arity clause holds in either language, 2.4/4.5 — the MDX arms are T2.4-3). ### 4.4 Module branding @@ -266,7 +266,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T6.5-1 File form.** IDs unchanged; identities change file part only; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; journal appended; finishing regeneration as T6.4-7; the applied-mapping report as T6.4-1 (6.5: both forms report as rename does). * **T6.5-2 Section form text edits.** Byte-exact fixtures: moved text spans opening tag's first character through closing tag's last; origin deletion drops lines left empty/whitespace-only (rule of 3); insertion immediately before the target parent's closing tag (or end of file for top-level `new-id`), followed by U+000A and preceded by one when not at line start; target file created when absent; no other byte changes beyond these edits, the identity and reference rewrites, and the finishing regeneration (6.5). Self-closing arms (1.1, 6.5), byte-exact: moving a self-closing section moves exactly the self-closing tag's own characters; a self-closing target parent is first rewritten to paired form — its `/` and any whitespace immediately before or after the `/` deleted, the closing tag matching the opening tag's name appended immediately after the tag's terminating `>` — and the insertion rule then applies before that closing tag: a `` parent becomes `` + U+000A + the moved text + U+000A + ``. * **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. -* **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link (target absent), one arm each (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose `` is invalid per 1.4 (the mirrored "new ID is valid" check, 6.5) — a forbidden name (`then`) and a whitespace-bearing segment, one arm each; section form whose `` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; containing `#`; not valid UTF-8 (Linux leg); lacking `.mdx`. Plus the valid-workspace precondition as T6.4-6. +* **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link (target absent), one arm each (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose target path is occupied by anything other than a discovered spec source — neither an insertion target nor an absent path to create, refused `refused-destination-exists` (6.5, T14-7) — one arm each: a directory; a symbolic link resolving to a discovered spec source (the link-following discriminator: discovery never yields a symlink, 7 — a product resolving the target path through the filesystem finds a spec source there and inserts through the link); and an existing `.mdx` file outside every configured spec group (present, right extension, still no discovered spec source); section form whose `` is invalid per 1.4 (the mirrored "new ID is valid" check, 6.5) — a forbidden name (`then`) and a whitespace-bearing segment, one arm each; section form whose `` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; containing `#`; not valid UTF-8 (Linux leg); lacking `.mdx`; and the derived-path arm of `refused-invalid-destination` (6.5: a workspace-relative directory component of a derived path the destination would generate — 13.1, 13.2, 7.3 — occupied by a non-directory): with emission enabled under `markdown.outDir`, a file-form move whose destination `new/b.mdx` is otherwise valid, its own directory components unobstructed (`new/` absent — a nonexistent component is never a refusal cause, 13.4, T13.4-8), but whose emit destination `/new/b.md` has its component `/new` occupied by a plain file lying under no current source's write path (so the workspace passes `build`'s validations) — refused `refused-invalid-destination`, never 14.22 (14, T14-7), discriminating a product that vets only the destination path's own components; the module and companion paths share the destination's directory (13.1), so the `outDir` emit destination is the separable derived-path fixture. Plus the valid-workspace precondition as T6.4-6. * **T6.5-5 Usage errors (exit 2).** Nonexistent origin file or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y` and `a.mdx#x b.mdx` match neither form — exit 2 — and an operand containing `#` is always a `#` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). * **T6.5-6 Identity terms.** The new-identity checks read in identity terms (6.5): a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid; the exact self-move — `#` equal to `#` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. @@ -334,6 +334,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T10.1-2 Names.** Valid: letters, digits, `.`, `_`, `-`, not beginning with `.`. Invalid names (`/`, space, empty, leading `.`, non-ASCII) → usage error, exit 2, nothing created. Names are case-sensitive for all subcommands (`status Foo` does not find `foo` → exit 2 — a single-casing probe rerun on the Windows leg, E-6, where a case-insensitive filesystem exposes a product matching session names via filesystem lookup), but `create` refuses a name matching an existing session ignoring ASCII case (exit 1, refused operation per 10.7/12.0). * **T10.1-3 Non-session files.** A stray file `.xspec/reviews/notes.txt` and a subdirectory are ignored by `list`, `check`, and every subcommand. The valid-name qualifier of 10.1 discriminates: a garbage-content `.json` file whose stem is an invalid session name — `.foo.json` (leading `.`) and `a b.json` (whitespace) — and a wrong-case extension `NAME.JSON` (paths compare byte-wise, 12.0) are not sessions: `list` reports them neither as sessions nor as corrupt and exits 0, `check` reports no 14.21, and naming them finds no session (`status` on `.foo` or `a b` → exit 2 invalid name; on `NAME` → exit 2 unknown session — the `NAME.JSON` probe stages a single casing and reruns on the Windows leg, E-6). * **T10.1-4 Corruption.** Each corrupt state → every `review` subcommand naming the session reports corruption, exits 1, modifies nothing; `list` reports the session corrupt in place of its fields and exits 1; `check` reports 14.21: unparseable JSON; missing 10.2 field; unknown status; duplicate item `id`s; `blockedBy` naming an absent item; a `blockedBy` cycle; two items with same kind and scope node; malformed recorded creation parameters or decompositions; a session path that is a directory or symlink (13.4). Staging is blackbox: SPEC.md leaves the session file's concrete shape opaque, so every shape-dependent corrupt fixture starts from a session file the product itself wrote and is corrupted through the H-3 adapter layer (shape-aware, value-blind: duplicating an item entry, rewriting a status to an unknown value, redirecting `blockedBy` into a cycle or at an absent id, deleting a field, garbling recorded parameters); shape-independent states (unparseable bytes, truncation, a directory or symlink at the path) are staged directly. The harness never writes a session file from an assumed layout. +* **T10.1-5 Failing workspace: gate precedence over corruption.** One workspace: create a session on a valid build, corrupt it shape-independently (T10.1-4's garbage-bytes staging), then edit a source to fail `build`'s validations. Every `review` subcommand naming the session — `status`, `next`, `show`, `export`, and `resolve`/`split` with any item ID (the ID is judged only against session content, never reached here, 12.0) — and `review list` report exactly the gate's findings: the validation errors, no condition-21 finding beside them, exit 1, nothing modified, the corrupt session's bytes untouched — no session file is read on a failing workspace, so corruption is reported exactly where sessions are read (10.1, 13.3, 14.21), and for `list` the gate's report replaces the per-session report whole (10.7). `check` on the same workspace reports 14.21 together with the validation findings (14.21: beside a failing workspace's other findings) — the discriminating pair against a product that opens the session first, reports corruption from a gated `review` subcommand, or drops 14.21 from `check` on the failing side. ### 10.2 Items @@ -350,7 +351,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ### 10.4 Relevant hashes and invalidation * **T10.4-1 Per-kind sensitivity.** For each kind, every relevant hash listed in 10.4 is exercised as an invalidating case, plus a non-invalidating control — an edit touching none of the item's relevant state and leaving its generated context set unchanged. `subtree-coherence`: a text edit inside the scope subtree (a scope node's subtreeHash); a metadata-only edit on the scope root and, separately, on a descendant (the relevant metadataHash is each scope node's — the descendant's metadata edit changes no subtreeHash and MUST still invalidate); control: an edit outside the subtree. `parent-consistency`: an own-text edit of the scope node (ownHash); a metadata edit of the scope node (metadataHash); a deep text edit under a context child (a context node's subtreeHash); control: an edit in a sibling subtree of the scope node. `dependency-consistency`: an own-text edit of the scope node (ownHash); a metadata edit of the scope node (metadataHash); a text edit under an upstream target in context (target subtreeHash); control: an edit to an unrelated node. `metadata-consistency`: a metadata edit of the scope node (metadataHash only); control: a text edit of the scope node does not invalidate. `code-impact`: a text edit of an impact-edge target (target subtreeHash); an upstream edit changing only a target's effectiveHash; control: an edit to a node that is no impact-edge target and upstream of none. `uncovered-requirement`: a text edit in the scope node's subtree (subtreeHash); a metadata edit of the scope node (metadataHash); control: an edit elsewhere. -* **T10.4-2 Presence changes.** Deleting a scope node after resolve invalidates; restoring it invalidates a resolution recorded against absence; a node already absent at resolve time does not invalidate by remaining absent (deletion review stays resolvable). +* **T10.4-2 Presence changes.** Deleting a scope node after resolve invalidates; restoring it invalidates a resolution recorded against absence; a node already absent at resolve time does not invalidate by remaining absent (deletion review stays resolvable). Non-scope recordings (10.4: presence is recorded for every scope, context, and origin node), each arm pure — no recorded relevant hash of the item and no generated context set changes, so only the named node's presence divergence can invalidate, and a product recording presence for scope nodes alone reports the item still resolved. Context arm (`metadata-consistency`): baseline `D` bearing a `d` reference to sibling `T`; one edit removes the reference and deletes `T`'s section; `review create --base` — `D`'s item's context is the removed target `T`, recorded absent; resolve it; re-author `T`: the item reads `invalidated` (`D`'s metadataHash and the context set are unchanged; only the context node's absent-to-present flip diverges). Origin arm (`dependency-consistency`): baseline `X` depends on `T`, `T` depends on `D` (a section in its own file); a `d`-list edit on `D` makes `X`'s item — scope `X`, context `{T}`, origin `{D}` (10.5); resolve it; one edit then removes `T`'s reference to `D` and deletes `D`'s section: `X`'s ownHash and metadataHash and `T`'s subtreeHash are unchanged (`d`-prop edits touch no own content, 1.6/5.5) and the context set stays `{T}` (`T`'s effectiveHash still changed against the baseline), so the item reads `invalidated` through the origin node's present-to-absent flip alone. * **T10.4-3 Context-set change.** A change that alters the item's generator-derived context set (e.g. a new changed branch under a resolved `parent-consistency` item's scope) invalidates without any recorded hash changing. * **T10.4-4 Rename immunity.** `xspec rename`/`move` on scoped or context nodes: no duplicate items, no lost statuses, nothing invalidated by the identity mapping alone; reads present recorded nodes under current identities (mapped forward), for present and absent nodes alike. Item order follows current identities (10.5): a journaled file `move` that flips which of two same-depth, same-kind items' scope file paths sorts first flips their order in `status`/`next`/`export`, with statuses and recorded state intact. Reintroduction arm (10.4: recorded nodes compare as canonical identities, 5.4 — the journal-position pairing included): in an audit session over a file with top-level leaf sections `a` and `s`, resolve `a`'s item; `xspec rename` `a`→`b`; author a new top-level leaf section `a`; resolve `s`'s item `updated` (re-derivation, 10.5/10.6). The item recorded against old-`a` keeps its `id` and resolved status, presented under scope `b`; new-`a`'s item enters as a distinct item, `unresolved` (10.2); the root item's `blockedBy` gains it. A product matching by walked-back identity string collapses the two generated items (both bearers walk to `a`) into the resolved one, losing new-`a`'s item. * **T10.4-5 Reads never write.** `status`, `next`, `show`, `export` leave the session file byte-identical, including when they compute and report invalidation; a stale resolution is reported `invalidated` on read, and the stored status is only rewritten by mutating subcommands. @@ -359,7 +360,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T10.5-1 Generation.** SPEC.md §15's worked change (leaf text edit) yields exactly the four listed items with specified scope/context/origin. Extended fixture: a `changed` node with a `changed` ancestor generates no own item (skipping rule); scope of `subtree-coherence` is the node plus all descendants; multiple changed nodes sharing an ancestor A yield one `parent-consistency` item for A against the union of branches. * **T10.5-2 Blocking chains.** A's `parent-consistency` item is blocked by, per changed branch, the child's `subtree-coherence` item (child is the changed node) or the child's `parent-consistency` item (deeper change); chains extend to the root; only those two kinds block `parent-consistency` items; `metadata-consistency`, `dependency-consistency`, and `code-impact` items have empty `blockedBy`. -* **T10.5-3 Metadata/dependency/code items.** One `metadata-consistency` per `metadata-changed` node (context: added and removed `d` targets; `coverage`/`tags` changes described in `reason`); one `dependency-consistency` per node with a dependency edge to a target present on both sides of the baseline (5.6) whose effectiveHash changed (context: those targets; origin: originating nodes) — an edge to a target added since the baseline yields no such item, the change being reviewed at its source (10.5): a fixture node whose only affected target was added since the baseline gets no `dependency-consistency` item, its new `d` edge surfacing as its own `metadata-consistency` item; one `code-impact` per impacted location (context: the impact-edge targets that make it impacted, added and deleted included). +* **T10.5-3 Metadata/dependency/code items.** One `metadata-consistency` per `metadata-changed` node (context: added and removed `d` targets; `coverage`/`tags` changes described in `reason`); one `dependency-consistency` per node with a dependency edge to a target present on both sides of the baseline (5.6) whose effectiveHash changed (context: those targets; origin: originating nodes) — an edge to a target added since the baseline yields no such item, the change being reviewed at its source — both halves of 10.5's note staged (a new `d` edge makes the source `metadata-changed`, a new embedding makes it `changed`): a fixture node whose only affected target was added since the baseline gets no `dependency-consistency` item, its new `d` edge surfacing as its own `metadata-consistency` item; a second node whose only affected target entered through a new `{text(...)}` embedding likewise gets no `dependency-consistency` item — the new embedded reference changes its own content (5.5), it is `changed`, and the change is reviewed via its own `subtree-coherence` item; one `code-impact` per impacted location (context: the impact-edge targets that make it impacted, added and deleted included). * **T10.5-4 Item order.** A fixture with items of all kinds across two files asserts the total order: requirement-scoped first by depth deepest-first (roots 0), then kind order `subtree-coherence`, `metadata-consistency`, `dependency-consistency`, `parent-consistency`, then file path bytes, then document order; `code-impact` items last by location identity; after deleting a scope node, absent-scope items order after present ones by identity then item `id` (10.5 ordering rule); `status`/`next`/`export` all present this order. * **T10.5-5 Re-derivation on updated.** Resolving an item `updated` re-derives: a matching kind+scope item keeps `id`, status, recorded state; a context-set change marks it per 10.4; items no longer generated remain with their `blockedBy` and retain their recorded context set (10.4) — `show`/`export` after the re-derivation present that context unchanged; a newly `changed` node's item appears in order, created `unresolved` (10.2 — the discriminating fixture: the triggering item resolved `updated`, whose status must not propagate to the new item); `blockedBy` is recomputed with decomposed references replaced by decompositions (after a `split`); a decomposed kind+scope is never re-added — its decomposition applies recursively; sibling subtrees enter only through re-derivation (resolving with `no-change`/`skipped` does not re-derive — a concurrent workspace edit surfaces as invalidation, not new items, until an `updated` resolve). * **T10.5-6 Baseline recording.** The session records the resolved commit identity of `--base`; later `HEAD` movement or branch renames do not change what generators run against (re-derivation still diffs against the recorded commit). @@ -539,15 +540,15 @@ All mutual-exclusion tests use the `--test-hold ` seam for determinism. ## 14. Validation Errors -Sections 1–13 exercise each numbered condition in its home context; this section adds the reporting-contract tests. Primary tests per condition (not exhaustive; the H-7 map is the complete record): 14.1 (T1.3-1), 14.2 (T1.3-2/3/4/6), 14.3 (T1.3-5), 14.4 (T1.4-1/4), 14.5/14.6/14.7 (T14-2), 14.8 (T2.4-2/3, T4.3-2, T4.5-3), 14.9 (T2.1-5, T5.3-1/2), 14.10 (T12.2-2), 14.11 (T4.4-1), 14.12 (T7.5-2/6), 14.13 (T6.1-3, T13.4-6), 14.14 (T7-1..T7.5-1), 14.15 (T2.1-2/3, T4-2), 14.16 (T2.7-1), 14.17 (T2.5-3, T2.7-3), 14.18 (T4.5-5), 14.19 (T1.5-2, T7.1-1), 14.20 (T1.6-5, T14-3, T14-5), 14.21 (T10.1-4), 14.22 (T13.4-6), 14.23 (T6.6-6, T11.6-4, T12.2-2, T13.3-2); the refusal reasons and their codes (T14-7; staged at T6.4-3, T6.5-4, T6.5-6, T6.6-3); the stable-code and location-cardinality contracts (T14-6, T14-8). +Sections 1–13 exercise each numbered condition in its home context; this section adds the reporting-contract tests. Primary tests per condition (not exhaustive; the H-7 map is the complete record): 14.1 (T1.3-1), 14.2 (T1.3-2/3/4/6), 14.3 (T1.3-5), 14.4 (T1.4-1/4), 14.5/14.6/14.7 (T14-2), 14.8 (T2.4-2/3, T4.3-2, T4.5-3), 14.9 (T2.1-5, T5.3-1/2), 14.10 (T12.2-2), 14.11 (T4.4-1), 14.12 (T7.5-2/6), 14.13 (T6.1-3, T13.4-6), 14.14 (T7-1..T7.5-1), 14.15 (T2.1-2/3, T4-2), 14.16 (T2.7-1), 14.17 (T2.5-3, T2.7-3), 14.18 (T4.5-5), 14.19 (T1.5-2, T7.1-1), 14.20 (T1.6-5, T14-3, T14-5), 14.21 (T10.1-4, T10.1-5), 14.22 (T13.4-6), 14.23 (T6.6-6, T11.6-4, T12.2-2, T13.3-2); the refusal reasons and their codes (T14-7; staged at T6.4-3, T6.5-4, T6.5-6, T6.6-3); the stable-code and location-cardinality contracts (T14-6, T14-8). * **T14-1 Actionable and complete reporting.** A workspace seeded with several independent error conditions across files: `build` and `check` report each of them (not only the first), and every report identifies file and location and states a correction-oriented message (information presence, not wording). * **T14-2 Unresolved references.** A `d` reference, a `text(...)` target, and a TypeScript marker/`text` call that do not resolve → 14.5, 14.6, 14.7 respectively; the TS case is also a type error against the generated module (asserted when a prior valid generation exists). * **T14-3 Masking.** An unparseable file (14.20 — malformed MDX; malformed TS under the grammar its name selects: a TSX-only construct in a `.ts` file; invalid UTF-8; BOM) masks conditions inside itself, and every reference into it from other files reports as unresolved (14.5–14.7); the parse-failure location is reported. A configuration error suppresses all source analysis: only 14.14 is reported (exit 2) even with invalid sources present. -* **T14-4 Reporter matrix.** 14.10 and 14.12 reported by `check` only (a stale workspace `build`s successfully by regenerating; a policy-violating workspace `build`s successfully); 14.21 reported by `check`, by `review` subcommands naming the session, and by `review list` — not by `build`; 14.23 reported by `inventory` and `rename`/`move` previews only — `check` reports the state as 14.10's unit form, and `build` and the refreshing reads never do (the rebuild replaces the record; the reads leave it unconsulted, T13.3-2); 14.14 delivered as an exit-2 error by every command that loads configuration — never `version` (T12.6-2); every other condition reported by both `build` and `check`, and as a domain file's finding accompanying `occurrences`/`view`/`at` answers (11.2, T11.2-5). +* **T14-4 Reporter matrix.** 14.10 and 14.12 reported by `check` only (a stale workspace `build`s successfully by regenerating; a policy-violating workspace `build`s successfully); 14.21 reported by `check`, by `review` subcommands naming the session, and by `review list` — not by `build`, and on a workspace failing `build`'s validations by `check` alone, beside the gate's findings (T10.1-5); 14.23 reported by `inventory` and `rename`/`move` previews only — `check` reports the state as 14.10's unit form, and `build` and the refreshing reads never do (the rebuild replaces the record; the reads leave it unconsulted, T13.3-2); 14.14 delivered as an exit-2 error by every command that loads configuration — never `version` (T12.6-2); every other condition reported by both `build` and `check`, and as a domain file's finding accompanying `occurrences`/`view`/`at` answers (11.2, T11.2-5). * **T14-5 Grammar selection.** A file matched by a code group and named `.tsx`, containing TSX-only syntax (not parseable as plain TypeScript — T14-3's construct) inside a named unit that also holds a dependency marker and a `text(...)` call: `build` succeeds — `.tsx` parses as TSX (14.20) — and the marker's `references` edge and the call's `embeds` edge are recorded and attributed to that unit per 4.6. The negative direction, the same TSX-only construct in a `.ts` file failing 14.20, is T14-3's; a further arm stages it in a code-group file of another name (`.mts`), failing 14.20 identically — any name but `.tsx` selects plain TypeScript (14.20), discriminating against products keying specifically on `.ts`. * **T14-6 Stable codes.** For each of the 23 conditions, staged via its primary test's fixture and read from its stated reporter (T14-4): the finding carries the exact token 14 lists (`missing-id` … `unreadable-record`) as its `code` in the JSON report form, where 12.7 pins it — the value is the token string alone, the ordinal numeral no part of it — so a product omitting or misspelling a code fails even where exit class and located information are right. A plain usage error and a review-operation refusal carry no stable code — `code` `null` (14, T12.7-1/3). -* **T14-7 Refusal reasons.** Staged refusals asserting each stable code with its concerned file, range, or identity (14): `refused-invalid-id` (concerning the invalid identity; intrinsic form only — a structurally misplaced but intrinsically valid new ID reports `refused-structural-parent` alone, never both); `refused-identity-unchanged`, reported alone by an identity-unchanged rename — no collision reason beside it (6.4: the after-removal check collides with nothing); `refused-id-collision`, locating every colliding bearer; `refused-structural-parent` (the violated identity); `refused-cycle`, locating the would-be cycle's full path; `refused-destination-exists` (the occupied path; the section form's non-spec-source occupant included); `refused-missing-target-parent` (the target-parent identity); `refused-invalid-destination` (the destination path; the destination-side directory-component case of 6.5 reports this code, never 14.22 — a plain file staged as a destination directory component). `refused-unresolvable-reference` admits no fixture (T6.4-3/T6.5-6) and is asserted only as the always-passing side of successful operations. Every applicable reason reports together, one finding per reason: a section move staged to both collide (`` present in the target file) and create a dependency cycle reports both findings, never only the first. The invalid-workspace refusal reports the workspace's numbered findings alone — on a workspace failing validation, a rename staged to also collide reports the validation findings only, exit 1, no refusal reason evaluated or reported beside them (6.4, 14). +* **T14-7 Refusal reasons.** Staged refusals asserting each stable code with its concerned file, range, or identity (14): `refused-invalid-id` (concerning the invalid identity; intrinsic form only — a structurally misplaced but intrinsically valid new ID reports `refused-structural-parent` alone, never both); `refused-identity-unchanged`, reported alone by an identity-unchanged rename — no collision reason beside it (6.4: the after-removal check collides with nothing); `refused-id-collision`, locating every colliding bearer; `refused-structural-parent` (the violated identity); `refused-cycle`, locating the would-be cycle's full path; `refused-destination-exists` (the occupied path; the section form's non-spec-source occupant included); `refused-missing-target-parent` (the target-parent identity); `refused-invalid-destination` (the destination path; the destination-side directory-component cases of 6.5 report this code, never 14.22 — a plain file staged as a destination directory component and, in T6.5-4's derived-path arm, as a directory component of the destination's `outDir` emit destination). `refused-unresolvable-reference` admits no fixture (T6.4-3/T6.5-6) and is asserted only as the always-passing side of successful operations. Every applicable reason reports together, one finding per reason: a section move staged to both collide (`` present in the target file) and create a dependency cycle reports both findings, never only the first. The invalid-workspace refusal reports the workspace's numbered findings alone — on a workspace failing validation, a rename staged to also collide reports the validation findings only, exit 1, no refusal reason evaluated or reported beside them (6.4, 14). * **T14-8 Location cardinality.** A condition several constructs jointly violate is one finding locating every participant, each in its containing file: a triple-duplicated ID → one condition-3 finding with three locations (one per bearer, no representative chosen); an import-binding collision → one condition-15 finding locating every colliding declaration; a cross-file dependency cycle → one condition-9 finding locating its full path, every participating reference spelling, and a spec import cycle every participating import declaration; a no-occurrence MDX embedding spelling → its condition-6 finding's range the full braced container — the span its occurrence would occupy (5.7), keeping T11.4-6's byte classification exact; a policy finding → `locations` `[]`, `path` `null`, its context identities alone (T12.7-1). Location order within a finding is file bytes, then start, then end (12.7). ## 15. Example @@ -559,7 +560,7 @@ Sections 1–13 exercise each numbered condition in its home context; this secti Property tests generate inputs from seeded, reproducible generators (H-10), assert spec-derived invariants, and shrink failures. Each property is also anchored by the deterministic fixtures of sections 1–15; properties exist to search the input space, not to replace them. * **P-1 Segment/tag validity.** Generator over code points (weighted toward boundaries: whitespace/control classes of 1.4, U+00A0/U+0085/U+2028, `.`/`#`, forbidden names, and glob metacharacters of common dialects — `[` `]` `{` `}` `!` `+` `(` `)` — which are ordinary valid segment characters): a generated segment is accepted by `build` iff it satisfies 1.4; likewise tags, with `.` allowed and whitespace never reaching tag validation — a generated value containing whitespace stages as multiple tags (2.6 splits on runs of whitespace), so the tag property asserts acceptance iff every resulting token satisfies 1.4 (zero tokens: accepted as an omitted prop, T2.6-2). -* **P-2 Markdown compilation.** Random documents composed of prose blocks, nested sections, imports, comments (single- and multi-line), and embeddings, over mixed line terminators, with content weighted toward the whitespace/non-whitespace boundary code points of 1.4 (U+00A0, U+0085, and U+2028 included): compiled output equals an independent oracle implementing the removal/replacement/line-drop rules of 3 (the oracle lives in the harness); compilation is deterministic; content bytes outside removed constructs are preserved. +* **P-2 Markdown compilation.** Random documents composed of prose blocks — fenced code blocks and inline code spans spelling tag-, import-, and expression-like bytes included (T3-1's grammar boundary: such bytes are content) — nested sections, imports, comments (single- and multi-line), and embeddings, over mixed line terminators, with content weighted toward the whitespace/non-whitespace boundary code points of 1.4 (U+00A0, U+0085, and U+2028 included): compiled output equals an independent oracle implementing the removal/replacement/line-drop rules of 3 (the oracle lives in the harness); compilation is deterministic; content bytes outside removed constructs are preserved. * **P-3 Text algebra.** For random documents: root subtree text equals compiled Markdown output; a node's subtree text equals its own-text runs interleaved with its children's subtree texts in document order (1.6); N children yield N+1 runs. * **P-4 Hash laws.** For random workspaces and random single edits: subtreeHash changed iff the 5.5 condition holds; metadataHash changed iff `d`/`coverage`/`tags` changed; ownHash insensitive to embedded-target edits; effectiveHash monotone over the dependency closure (any dependency-target effectiveHash change propagates); identical workspaces hash identically. * **P-5 Rename/move purity.** Random valid workspaces, random journaled rename/file-move sequences: all hashes byte-stable, impact against any prior commit in the sequence reports no categories, and all references still resolve; random section moves: only the predicted parents gain categories. @@ -570,6 +571,7 @@ Property tests generate inputs from seeded, reproducible generators (H-10), asse * **P-10 Concurrency.** Randomized schedules of concurrent readers and one mutating command (via `--test-hold` and process kills): readers observe only prior-or-complete file states (T13.5-5); mutual exclusion never loses a journal append or a resolution (post-hoc: journal lines = successful `rename`/`move` operations — the journal's only writers, 6.1; session statuses = successful resolves). * **P-11 Availability robustness.** Fuzzed and mutated spec and code sources (P-8's generators — the availability contract is precisely an imperfect-input surface) driven through `occurrences`, `view` (with and without `--text`), and `at` at random offsets: every invocation terminates; stdout is one complete JSON document, never partial; the exit is 0 or 1 per 11.2 (2 only for staged argument errors); every datum is exactly one of plain value, `null`, or `{"unavailable": true}` (11.4, 12.7); any finding or unavailable datum implies exit 1 with the full document emitted, and exit 0 implies a finding-free document carrying none. * **P-12 at ≡ view; occurrence order.** For random workspaces: for every file and every offset 0…byte length, `at`'s resolution — section identity, construct range, containing occurrence — equals the resolution computed from that file's `view` document alone (11.5, T11.5-1); and the workspace-wide `occurrences` enumeration equals the view-collected occurrences sorted by file bytes, range start, range end — total, duplicate-free, byte-identical across runs (5.7). +* **P-13 Coverage oracle.** Random workspaces (spec and code groups; `depends`, `embeds`, and `references` edges; tags; `coverage="none"`; root-sourced and root-targeted edges) and random profiles (`mode`, `targets`, `targetTags`, `edgeKinds`, spec and code boundaries): `xspec coverage`'s required, covered, uncovered, and ignored sets — exclusion reasons included — equal an independent oracle implementing 8.1's required set and 8's reachability (direct: one edge; transitive: one or more; only the profile's `edgeKinds`; `contains` never grants; roots never boundary, intermediate, or target), and every reported covering path is a permitted path of the profile from a boundary node to its target, shortest with the 12.0 tie-break — guarding what the deterministic T8-* matrix samples pointwise. ## 17. Self-Tests and Certification @@ -582,7 +584,7 @@ Confidence that the harness itself is correct comes primarily from certification * **S-3 Subprocess driver.** Captures exit codes and keeps stdout/stderr separated (verified against a known-behavior stand-in command); enforces per-test working directories; detects hangs via timeout and reports them as failures, not skips. * **S-4 TypeScript tooling driver.** Detects a known type error, a known definition location, and a known hover text in a hand-written non-xspec fixture project, so section 4's consumer assertions cannot pass vacuously. * **S-5 Output adapters.** Each adapter (H-3) rejects documents missing required information (fed synthetic wrong-shape documents) rather than defaulting. -* **S-6 Oracles.** The Markdown oracle (P-2) and glob/capture oracle (P-7) pass their own fixed vector suites derived from SPEC.md's examples (3, 7.5) before being trusted by property tests. +* **S-6 Oracles.** The Markdown oracle (P-2), the glob/capture oracle (P-7), and the coverage-reachability oracle (P-13) pass their own fixed vector suites derived from SPEC.md's examples (3, 7.5, 15) before being trusted by property tests. * **S-7 Red-green sweep.** Against an empty stub product (every command exits with an unexpected code and no output), every product-facing test fails with a diagnosed assertion and the suite completes without harness errors (H-8). ## 18. Execution and CI From 213b935e365bccc5a824c49bba0f19a927f91080 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 18:12:45 +0000 Subject: [PATCH 034/226] sdg(phase-6): positively assert 14.10's graph-data unit form at check (iter 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: - C1: T12.2-2 gains isolated missing- and mismatch-arm fixtures asserting check's exactly-one condition-10 unit-form finding (concerned path the graph-data area, no path inside it named); T12.2-3 now pins its never-refreshes assertion to those states plus the combined edited-source state. - O2: section 11 preamble reworded so the same-information bound reads as "byte-identity is not asserted", not as an assertion the forms differ. Rejected: - O1: wrong on the facts — SPEC.md §15 describes the worked change and lists the four items verbatim ("contains exactly: a subtree-coherence item for print.hello, ..."), so T10.5-1/T15-1's citations have an exact antecedent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index c4b088f..9683bdd 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -388,7 +388,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ## 11. Query Surfaces -SPEC.md 11's five commands are JSON-only: for `query`, `occurrences`, `view`, `at`, and `inventory`, a single JSON document is the only output form, with or without `--json` — `occurrences`, `view`, `at`, and `inventory` in the form-exact document forms of 12.7 (H-3, T12.7-2), `query` carrying its defining section's information through H-3 adapters. Each surface's flag-less and `--json` invocations are asserted to carry the same information — never byte-identity between the two forms — and an exit-2 error of any of them arrives as the 12.7 error document on stdout (12.0; T12.0-2, T12.7-3). +SPEC.md 11's five commands are JSON-only: for `query`, `occurrences`, `view`, `at`, and `inventory`, a single JSON document is the only output form, with or without `--json` — `occurrences`, `view`, `at`, and `inventory` in the form-exact document forms of 12.7 (H-3, T12.7-2), `query` carrying its defining section's information through H-3 adapters. Each surface's flag-less and `--json` invocations are asserted to carry the same information — byte-identity between the two forms is not asserted (SPEC.md does not require it) — and an exit-2 error of any of them arrives as the 12.7 error document on stdout (12.0; T12.0-2, T12.7-3). ### 11.1 `xspec query` @@ -468,8 +468,8 @@ Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are ### 12.2 `xspec check` * **T12.2-1 Green path.** On a freshly built valid workspace, `check` exits 0. -* **T12.2-2 Scope.** One workspace per finding family asserting `check` reports it with exit 1: all build validations (derived state from a prior valid build persists while the sources have since been edited to be invalid — `check` re-validates from the current sources rather than accepting the stale outputs); stale generated output and orphaned recorded derived file (14.10, `check`-only; asserted after hand-editing a generated file, hand-deleting one, editing a source without rebuilding, and disabling emission without rebuilding; occupant-kind arms — 14.10: the per-file comparison judges the path's occupant itself, never traversing a symbolic link — a generated module's path occupied by a symbolic link whose target holds byte-identical generated content, the discriminating arm a link-following product wrongly passes, and by a directory: each stale, exactly as a missing or content-differing file); the unreadable-record unit form (14.10/14.23): with graph data corrupted shape-blind (T6.6-6's staging), `check` reports one condition-10 finding under the unit form alone — concerned path the graph-data area, no path inside it named, never the mismatch form beside it, and the recorded-file form undetectable while the state holds — and a successful `build` replaces the state (`check` clean afterward; `inventory` reports `recorded` again, T11.6-4); unresolved/non-static references; cycles; journal integrity (14.13); policy (14.12, `check`-only); corrupt sessions (14.21). -* **T12.2-3 Never refreshes.** `check` on a stale workspace reports staleness and leaves graph data and derived files byte-identical (13.3). +* **T12.2-2 Scope.** One workspace per finding family asserting `check` reports it with exit 1: all build validations (derived state from a prior valid build persists while the sources have since been edited to be invalid — `check` re-validates from the current sources rather than accepting the stale outputs); stale generated output and orphaned recorded derived file (14.10, `check`-only; asserted after hand-editing a generated file, hand-deleting one, editing a source without rebuilding, and disabling emission without rebuilding; occupant-kind arms — 14.10: the per-file comparison judges the path's occupant itself, never traversing a symbolic link — a generated module's path occupied by a symbolic link whose target holds byte-identical generated content, the discriminating arm a link-following product wrongly passes, and by a directory: each stale, exactly as a missing or content-differing file); the graph-data unit form, missing and mismatch arms each positively isolated (12.2/14.10: `check` verifies graph data against the current sources and configuration) — missing: on a freshly built, otherwise clean workspace, delete the graph data (T13.3-2's operational definition): `check` exits 1 with exactly one condition-10 finding, the unit form — concerned path the graph-data area, no path inside it named — and no per-file finding beside it (every generated file present and matching; the absent record leaves the recorded-file form nothing to report), discriminating a product that treats absent graph data as nothing to verify; mismatch: build, edit a source, run one refreshing read — graph data then reflects the edit while the generated files go stale (13.3) — and revert the edit: the generated files again match the current sources while graph data does not, and `check` exits 1 with exactly one condition-10 finding, the unit form under the same concerned-path contract, no per-file finding beside it, discriminating a product that runs the per-file and record-readability checks but never compares graph data against the current sources and configuration; the unreadable-record unit form (14.10/14.23): with graph data corrupted shape-blind (T6.6-6's staging), `check` reports one condition-10 finding under the unit form alone — concerned path the graph-data area, no path inside it named, never the mismatch form beside it, and the recorded-file form undetectable while the state holds — and a successful `build` replaces the state (`check` clean afterward; `inventory` reports `recorded` again, T11.6-4); unresolved/non-static references; cycles; journal integrity (14.13); policy (14.12, `check`-only); corrupt sessions (14.21). +* **T12.2-3 Never refreshes.** `check` reports staleness and modifies nothing, pinned per state (13.3): on T12.2-2's missing-arm state graph data stays absent — `check` never rewrites it, where every refreshing read would (T13.3-2); on its isolated mismatch state, and on an edited-source-without-rebuild state carrying per-file and unit staleness together, graph data and every derived file are byte-identical around the invocation. ### 12.3 `xspec ids` From b0984f3b3cacb185d286bd1b5ceffedcf01c22bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 18:44:34 +0000 Subject: [PATCH 035/226] sdg(phase-6): cover undefined-source occurrences and unknown code units (iter 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: - C1: T11.2-4 gains the spec-source source-side arm — resolving spellings inside a duplicate-id bearer and an id-less section record occurrences with source exactly the one unavailable datum, the enclosing construct still on view, findings accompanying, exit 1; T11.3-1 enumerates the same records beside the invalid-path code-source arm. - C2: T11-6 gains unknown-code-unit arms — an unspelled unit and an out-of-range #unit@N on a discovered code source exit 2 across the graph-node flags, judged parse-locally over the file's named units (4.6); T12.0-10's gated sweep and consulted-domain list extended with the code-unit check. - O1: T12.0-10's syntax-alone class gains its malformed-value member (show a#b#c against invalid configuration reports the usage error, never 14.14). - O2: T6.4-4 pins 6.4's second existence mechanism — an old-ID bearer beneath an ancestor spelling no identity establishes existence, the invalid-workspace refusal reporting exit 1, never exit 2. Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index 9683bdd..736fd5c 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -256,7 +256,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T6.4-1 Rewrites.** Renaming a mid-tree ID rewrites: its `id`, all descendant `id`s by prefix replacement, local string references, external chain references in other files, `text(...)` targets in MDX and TS, and TS markers — workspace builds and all edges retarget (query-asserted); mapping appended to journal. The command's own report is the applied mapping — every identity pair the operation journaled, the information of the preview's `mapping` (6.4) — carried in JSON per 12.0 (H-3 adapter; the successful operation's report shape is unpinned). * **T6.4-2 Minimal edits.** Quote style (single vs double) and access form (dot vs computed) of untouched reference parts are preserved byte-wise; only the affected parts change. Where the form cannot be kept: a new segment that is not a TS identifier is written as double-quoted computed access; a valid-identifier segment as dot access; string literals double-quoted. * **T6.4-3 Validation refusals (exit 1).** New ID invalid (1.4); equal to old; colliding with an existing ID; violating structural parent rules. Each refusal modifies nothing (workspace byte-compare). The remaining 6.4 clause — all rewritten references resolve — admits no discriminating fixture: rename rewrites only valid workspaces (T6.4-6) and retargets every affected reference to identities that exist after the operation, so a non-resolving rewritten reference is unconstructible; the clause is exercised as the always-passing side of every successful rename (T6.4-1). -* **T6.4-4 Usage errors (exit 2).** Nonexistent ``; nonexistent old ID; a discovered code source as `` — a wrong-kind operand, judged like existence before any content question (6.4). Checked before source validation: same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering); but an old ID inside an unparseable origin file is masked — validation findings reported, exit 1. Old-ID existence is parse-local over spelled identities (6.4, 11.2): renaming an ID two sections both spell is no usage error — the bearers establish existence, their undefined node identities notwithstanding, and the duplicate-ID finding refuses instead (exit 1, the invalid-workspace refusal, T14-7); an old ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent — exit 2 even beside that file's findings. +* **T6.4-4 Usage errors (exit 2).** Nonexistent ``; nonexistent old ID; a discovered code source as `` — a wrong-kind operand, judged like existence before any content question (6.4). Checked before source validation: same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering); but an old ID inside an unparseable origin file is masked — validation findings reported, exit 1. Old-ID existence is parse-local over spelled identities (6.4, 11.2): renaming an ID two sections both spell is no usage error — the bearers establish existence, their undefined node identities notwithstanding, and the duplicate-ID finding refuses instead (exit 1, the invalid-workspace refusal, T14-7); so is renaming an ID whose sole bearer spells it beneath an ancestor spelling no identity (an undefined ancestor chain — 6.4's second parenthesized mechanism, 11.2): the bearer establishes existence and the ancestor's finding refuses (exit 1, never exit 2); an old ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent — exit 2 even beside that file's findings. * **T6.4-5 Type-level references.** A `typeof`-level reference to the old identity is not rewritten; the workspace stays xspec-valid (the consumer type error is outside xspec's validations — `build` and `check` report no finding for it). * **T6.4-6 Valid-workspace precondition.** With a pre-existing validation error elsewhere, rename refuses (exit 1) before modifying anything. * **T6.4-7 Finishing regeneration.** After a successful rename, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the rewritten sources; `check` immediately after reports no staleness (14.10). @@ -397,7 +397,7 @@ SPEC.md 11's five commands are JSON-only: for `query`, `occurrences`, `view`, `a * **T11-3 subtree/ancestors.** `subtree` returns the node plus descendants in document order (root query returns the whole file); `ancestors` returns proper ancestors nearest-first ending at the file root, excluding the queried node (empty for a root); rows carry the row fields of T11-2 — identity, source range, tags, coverage attribute (11: one row contract for `nodes`, `subtree`, and `ancestors`) — asserted on `subtree` and `ancestors` rows including a tagged `coverage="none"` node and a root (attribute absent), so a product omitting a row field from either subcommand fails. * **T11-4 edges.** `--from`/`--to` accept requirement nodes and code locations; `--kinds` filters over all four kinds and defaults to no filter (contains edges included); comma-separated list form; unknown kind value → exit 2. * **T11-5 reachable.** Reports existence of a dependency path under the given kinds (default: all three dependency kinds, never `contains`) and one shortest witness path with the 12.0 tie-break (two-equal-paths fixture); equal `--from` and `--to` (a node bearing both incoming and outgoing dependency edges) report that no path exists — a zero-length path is not a path (11); `--kinds contains` → exit 2 (invalid flag value: `reachable` accepts only the three dependency kinds, 11 — `edges` accepts all four, T11-4). -* **T11-6 Identity resolution.** Bare `path` resolves to a root node for a spec-group file and to a code location for a code-group file; `path#unit` and `path#unit@N` address code locations; a path in no configured group → exit 2 (unknown, 12.0). Wrong-kind operands (12.0): `query node` and `show` (12.4) given a code-group `path` or `path#unit` — a code source named where a requirement-node identity is required — each exit 2. +* **T11-6 Identity resolution.** Bare `path` resolves to a root node for a spec-group file and to a code location for a code-group file; `path#unit` and `path#unit@N` address code locations; a path in no configured group → exit 2 (unknown, 12.0). Wrong-kind operands (12.0): `query node` and `show` (12.4) given a code-group `path` or `path#unit` — a code source named where a requirement-node identity is required — each exit 2. Unknown code units (12.0: an unknown node identity named in arguments, the check judged parse-local over the named file's named units, 4.6): on a discovered code source, `query edges --from #` → exit 2 — likewise `edges --to` and `reachable --from`/`--to` given the same identity — and an out-of-range disambiguator, `#@2` where the chain occurs once in the file (4.6), is equally unknown, exit 2: never a bare edgeless graph node with an empty answer, exit 0 (the failing-workspace arm: T12.0-10). * **T11-7 Ordering.** Every result list is deterministic: repeated runs byte-identical; content-identical workspaces in different directories produce identical output (H-6). ### 11.2 Availability on imperfect files @@ -407,13 +407,13 @@ Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are * **T11.2-1 Parse-local structure, per-file masking, no writes.** Three spec files: A parseable with findings of both levels — an unresolved `d` reference and a self-cycle (resolution-level); a duplicate-ID pair, a malformed segment, an unknown prop, an invalid construct (per-file structural) — B unparseable, C finding-free. `view` over all three: A's full positional tree, construct ranges, raw attribute spellings, comment ranges, and occurrence positions are all served — structure survives A's own findings and B's invalidity; B contributes no view, its parse-failure finding accompanying; C's view is complete. The workspace fails `build`, so the gated reads report findings without answering (T13.3-3) while these surfaces answer per file — and modify nothing: graph data and derived files byte-identical around each invocation (11.2; the passing-workspace counterpart participates in refresh, T13.3-2). * **T11.2-2 Spelled identities and interpreted data.** One file, each node's identity datum asserted via `view`: exactly one quoted static `id` → defined; a repeated `id` (values agreeing, and disagreeing — one arm each), a braced `id={"x"}`, a valueless `id`, and no `id` → each spells none, identity explicitly unavailable; two sections both spelling `x` → both unavailable, no winner, while a uniquely spelled `x.y` beneath one of them keeps its defined identity (a defined identity without defined prefix identities); descendants of a no-identity or malformed-identity section are undefined by inheritance; a section uniquely spelling `z` stays defined beside another section's invalid-form `id` attributes (uniqueness compares spelled identities only — an invalid form contests nothing). Interpreted tags and coverage: absent props define the defaults (no tags, coverage-required); a repeated, malformed, or invalid-valued `tags`/`coverage` leaves the interpreted value unavailable, its raw spelling still listed (T11.4-3). * **T11.2-3 Invalid paths.** (Linux leg) A discovered spec source `a#b.mdx` and a non-UTF-8-named one (14.19): every node identity in each — root included — is explicitly unavailable while tree, ranges, and attributes stay on view; the condition-19 finding accompanies every answer whose domain includes the file; no identity over the invalid path is ever emitted, the non-UTF-8 path itself presented in the marked byte form (12.0, T12.7-1). A code source with `#` in its path defines no identity for its whole-file location or any unit: its spellings still record occurrences, each record's `source` explicitly unavailable (5.7, T11.3-1). Root identity is defined exactly when the file's path is valid. -* **T11.2-4 Resolution and expanded text.** Resolution turns on the referenced identity's own definedness: with duplicate spellings of `a` and a unique `a.b` beneath one bearer, a reference to `a.b` resolves and records its occurrence while a reference to `a` records none — ambiguous, every bearer undefined — reported by its finding's range, never as a record or an unavailable target. Expanded text via `view --text`: a chain A embeds B embeds C with an unresolved embedding in C → A's and B's own/subtree text unavailable (one unresolved spelling on the expansion path, or one embedding cycle — staged separately — poisons the whole value; partial expansion never occurs), sibling nodes with resolved expansions staying defined and byte-exact; removal classification is by syntactic form — after deleting an imported file, the importing file's text values are byte-identical to before (the import removed by form, its 14.15 finding notwithstanding), and a stray element (14.16) is content, preserved byte-for-byte in the enclosing text and located by its finding. +* **T11.2-4 Resolution and expanded text.** Resolution turns on the referenced identity's own definedness: with duplicate spellings of `a` and a unique `a.b` beneath one bearer, a reference to `a.b` resolves and records its occurrence while a reference to `a` records none — ambiguous, every bearer undefined — reported by its finding's range, never as a record or an unavailable target. Source-side unavailability (5.7, 11.2): resolving spellings themselves live in undefined-identity sections — a `d` entry naming `a.b` on the other duplicate bearer of `a`, and a `{text("a.b")}` embedding inside a section spelling no identity (`id` absent) — and each still records its occurrence: the record carries `file`, its own `range`, `kind`, and `target` (`a.b`), with `source` exactly the unavailability marker — identity and range withheld together as one datum (12.7; enumerated so in T11.3-1), never a picked bearer's identity and never a dropped record — while the view still positions each enclosing construct, its identity unavailable (11.4, T11.2-2); the file's findings — the duplicate-`id` and missing-`id` conditions among them — accompany, exit 1. Expanded text via `view --text`: a chain A embeds B embeds C with an unresolved embedding in C → A's and B's own/subtree text unavailable (one unresolved spelling on the expansion path, or one embedding cycle — staged separately — poisons the whole value; partial expansion never occurs), sibling nodes with resolved expansions staying defined and byte-exact; removal classification is by syntactic form — after deleting an imported file, the importing file's text values are byte-identical to before (the import removed by form, its 14.15 finding notwithstanding), and a stray element (14.16) is content, preserved byte-for-byte in the enclosing text and located by its finding. * **T11.2-5 Domain, findings, exits.** `view` naming only C (T11.2-1's finding-free file) → finding-free, exit 0, while A and B stay invalid — the domain is the requested files; naming A → A's findings of both levels accompany, exit 1, the full answer still emitted (the document complete and parseable, H-5); a two-file cycle accompanies whole when either participant is in the domain (14.9). Any finding or explicitly-unavailable datum → exit 1 with the full answer; complete and finding-free → exit 0; argument checks precede answering — unknown ``, wrong-kind ``, invalid glob, malformed `--to`, out-of-range offset each exit 2 whatever findings the named files carry (per-surface arms in T11.3-2/3, T11.4-2, T11.5-2). * **T11.2-6 Never stale, gate findings never attach.** On a passing workspace, `occurrences`, `view`, and `at` participate in read-time refresh exactly as 13.3's reads (T13.3-2 covers them in its sweep); on a failing one they answer from current sources and write nothing (T11.2-1). A gate condition that is no domain file's finding accompanies no answer: with a garbage journal line (14.13) staged, and separately an obstructed write path (14.22), `view` of a finding-free file answers finding-free, exit 0 — those states surface through `build`, `check`, and the gated reads (13.3), never these answers. ### 11.3 `xspec occurrences` -* **T11.3-1 Enumeration.** Over the T5.7-* fixtures: every occurrence in occurrence order, each record carrying every 5.7 datum in the form-exact 12.7 record form (T12.7-1); in T11.2-3's invalid-path code source, records are served with `source` unavailable while `file`, `range`, `kind`, and `target` are present. +* **T11.3-1 Enumeration.** Over the T5.7-* fixtures: every occurrence in occurrence order, each record carrying every 5.7 datum in the form-exact 12.7 record form (T12.7-1); in T11.2-3's invalid-path code source, and equally at T11.2-4's spec-source arm (resolving spellings inside a duplicate-`id` bearer and an id-less section), records are served with `source` unavailable while `file`, `range`, `kind`, and `target` are present. * **T11.3-2 `--file`.** A set restriction over discovered files, spec and code alike: a glob admitting a subset restricts the consulted domain — only its findings accompany; a glob matching no discovered file admits the empty set — an empty, finding-free answer, exit 0, no unknown-file usage error on this filter (contrast T11.4-2's operands); an outside-root pattern → exit 2 (invalid flag value, as 11.1); `--file` and `--to` combine conjunctively (a fixture where each filter alone admits more than the intersection). * **T11.3-3 `--to`.** Acceptance is syntactic: well-formed spellings — `path#id`, bare `path`, an undiscovered file's identity, a masked file's, an undefined bearer's — are accepted and select the empty set (with the domain's findings; never an error); malformed spellings exit 2: more than one `#`, an empty path part, an empty segment (`a#b..c`), a whitespace-bearing or forbidden-name segment (`a#then`), a trailing empty id part (`a.mdx#`) (11.3, 1.4). Selection is exact: a resolving identity selects the occurrences targeting it — not its descendants' — and a bare path selects module-form root references (T2.2-2). * **T11.3-4 Definitive emptiness.** In a valid workspace with no reference to node X: `occurrences --to X` → empty, finding-free, exit 0 — proof over the domain, absolute without `--file` (the whole discovered set consulted); restricted by `--file` away from a file that does hold a resolving occurrence of X, the answer is still empty, finding-free, exit 0 — the guarantee is domain-wide only, the outside occurrence neither reported nor denied (11.3). @@ -453,7 +453,7 @@ Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are * **T12.0-7 Determinism.** Representative sweep: `build` outputs, generated files, graph data, Markdown, journal entries, session files, and every report are byte-identical across repeated runs and across content-identical workspaces at different absolute paths (no wall-clock, randomness, absolute paths, or environment leakage; run with differing irrelevant environment variables). * **T12.0-8 Shortest-path tie-break.** Where one shortest path is reported (coverage 8.2, impact 9.3, reachable 11), among equal-length candidates the element-wise byte-least node-identity sequence is reported (dedicated fixtures per command). * **T12.0-9 Exit-code partition.** A table-driven sweep asserting one representative per class per command family: 0 (success and informational reports: `ids`, `show`, `impact` with differences, `query`, review reads including fully-resolved `next`, `coverage` without `--check`, `version`, and complete finding-free answers — `occurrences`/`view`/`at` over a clean domain, `inventory`, a successful preview; 11.2, 11.6, 6.6); 1 (findings: failing `build`, `check` findings, `coverage --check` uncovered, refused `rename`/`move` and their refused previews, refused review operations, corrupt-session reports, and answers carrying findings or explicitly-unavailable data — emitted in full; 11.2, 11.6, 6.6); 2 (usage/configuration: unknown command; unknown flag; missing required flag/argument; invalid flag value; unknown profile/session/group/item/node/file — except `occurrences --to`, where only a malformed spelling is a usage error, T11.3-3; wrong-kind operands — a code source where a spec source or a requirement-node identity is required; invalid session name; configuration errors; unreadable baseline; mutual-exclusion refusal). -* **T12.0-10 Argument-check precedence.** Rename/move and baseline arms: T6.4-4/T6.5-5 (existence, kind, and masking) and T6.3-4. Gated reads (12.0): on one workspace failing `build`'s validations, each gated read given a usage-error argument exits 2 with that error and reports no validation findings — `coverage `; `query nodes --group `; `review status `; `show #` and `query node ` (T11-6) — each check judged from what it consults (configuration; the session directory; parse-local spelled identities of the named file, 11.2), with the same names on a valid twin workspace giving the same exit-2 errors. Masking: `show #` on the failing workspace → the gated report, exit 1 (as T6.4-4). Past the gate: on a passing workspace, `review resolve --status updated` reports the corruption, exit 1 — the item ID judged only against session content, which the corruption withholds (10.1; an unknown item ID in a well-formed session stays exit 2, T10.7-10). Within class 2: an unknown command and a repeated flag are reported without loading configuration — identically with the workspace's configuration file invalid or missing — while a configuration error precedes every check that consults configuration or discovery: `coverage ` with invalid configuration reports 14.14, not the unknown profile (12.0). +* **T12.0-10 Argument-check precedence.** Rename/move and baseline arms: T6.4-4/T6.5-5 (existence, kind, and masking) and T6.3-4. Gated reads (12.0): on one workspace failing `build`'s validations, each gated read given a usage-error argument exits 2 with that error and reports no validation findings — `coverage `; `query nodes --group `; `review status `; `show #`, `query node `, and `query edges --from #` (T11-6) — each check judged from what it consults (configuration; the session directory; parse-local spelled identities or named units of the named file, 11.2, 4.6), with the same names on a valid twin workspace giving the same exit-2 errors. Masking: `show #` on the failing workspace → the gated report, exit 1 (as T6.4-4). Past the gate: on a passing workspace, `review resolve --status updated` reports the corruption, exit 1 — the item ID judged only against session content, which the corruption withholds (10.1; an unknown item ID in a well-formed session stays exit 2, T10.7-10). Within class 2: an unknown command, a repeated flag, and a malformed value (`show a#b#c`, the multi-`#` spelling of T12.0-13) are reported without loading configuration — identically with the workspace's configuration file invalid or missing — while a configuration error precedes every check that consults configuration or discovery: `coverage ` with invalid configuration reports 14.14, not the unknown profile (12.0). * **T12.0-11 Git is read-only.** SPEC.md's preamble: git data is read only where explicitly stated and never written. On a freshly built git fixture, around each git-reading invocation — `impact --base`, `review create --base`, and `review status`/`next`/`resolve` on the resulting baseline session (whose generator runs reconstruct the recorded baseline, 6.3/10.4) — everything under `.git/` is byte-identical before and after (same file set, same bytes: refs, HEAD, index, and objects untouched), and no workspace file changes except those the command's own specification writes (the session file; none for `impact`). * **T12.0-12 Git-less operation.** The non-baseline surface — `build`, `check`, `ids`, `show`, `coverage`, `query`, `occurrences`, `view`, `at`, `inventory`, `version`, `rename`, `move` (their `--preview` invocations included), and `review` with the `audit` and `coverage` strategies through `create`/`list`/`status`/`next`/`show`/`split`/`resolve`/`export` — runs to its specified outcomes in a workspace that is not a git repository and has no enclosing repository. Only baseline-taking invocations (`impact --base`, `review create --base`, later commands on a baseline session) require git; T10.6-1's git-less audit is one instance of this sweep. * **T12.0-13 `#` in operands.** More than one `#` in a ``, ``, `--to`, or move-operand value (`a#b#c`) is a malformed value — exit 2 on `show`, `query node`, `occurrences --to`, and `move` (12.0). A bare `` operand or `--file` glob is a whole path or pattern with no delimiter role for `#`: with a discovered source `specs/a#b.mdx` staged (Linux leg; condition 19), `view specs/a#b.mdx` names that discovered file — membership holds, the view served with identities unavailable, exit 1 (T11.2-3), never a `specs/a` + `b.mdx` pair (which would be exit 2, unknown file); `at specs/a#b.mdx 0` resolves the same way, and `occurrences --file 'specs/a#*'` matches it as a pattern. From 8a24dad32dd93a74ec071bbdbbb1c54715f9e29e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:45:53 +0000 Subject: [PATCH 036/226] sdg(phase-6): correct P-5's section-move oracle; string-literal keys, move unstageability notes (iter 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P-5: replace the false parents-only invariant with the 6.2/5.6 oracle (parents and moved-subtree nodes changed per own-content difference, cascades and attributions per 5.6, no categories elsewhere) [C1] - T7-2: positive arm — non-identifier group names declared under string-literal keys, discovered and referenced from a profile and a policy selector [C2] - T6.5-6: record the move-side unstageability of the mirrored 'structural parent rules remain satisfied' check [I1] - T6.6-4: record when the class-name-bytes edit-order tie-break can become observable (coincident zero-length insertion offsets under 6.5 latitude) and how the comparator is asserted [O1] Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index 736fd5c..d25d6b8 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -268,7 +268,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. * **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link (target absent), one arm each (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose target path is occupied by anything other than a discovered spec source — neither an insertion target nor an absent path to create, refused `refused-destination-exists` (6.5, T14-7) — one arm each: a directory; a symbolic link resolving to a discovered spec source (the link-following discriminator: discovery never yields a symlink, 7 — a product resolving the target path through the filesystem finds a spec source there and inserts through the link); and an existing `.mdx` file outside every configured spec group (present, right extension, still no discovered spec source); section form whose `` is invalid per 1.4 (the mirrored "new ID is valid" check, 6.5) — a forbidden name (`then`) and a whitespace-bearing segment, one arm each; section form whose `` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; containing `#`; not valid UTF-8 (Linux leg); lacking `.mdx`; and the derived-path arm of `refused-invalid-destination` (6.5: a workspace-relative directory component of a derived path the destination would generate — 13.1, 13.2, 7.3 — occupied by a non-directory): with emission enabled under `markdown.outDir`, a file-form move whose destination `new/b.mdx` is otherwise valid, its own directory components unobstructed (`new/` absent — a nonexistent component is never a refusal cause, 13.4, T13.4-8), but whose emit destination `/new/b.md` has its component `/new` occupied by a plain file lying under no current source's write path (so the workspace passes `build`'s validations) — refused `refused-invalid-destination`, never 14.22 (14, T14-7), discriminating a product that vets only the destination path's own components; the module and companion paths share the destination's directory (13.1), so the `outDir` emit destination is the separable derived-path fixture. Plus the valid-workspace precondition as T6.4-6. * **T6.5-5 Usage errors (exit 2).** Nonexistent origin file or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y` and `a.mdx#x b.mdx` match neither form — exit 2 — and an operand containing `#` is always a `#` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). -* **T6.5-6 Identity terms.** The new-identity checks read in identity terms (6.5): a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid; the exact self-move — `#` equal to `#` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. +* **T6.5-6 Identity terms.** The new-identity checks read in identity terms (6.5): a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid; the exact self-move — `#` equal to `#` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. So is the mirrored "structural parent rules remain satisfied" check, in both forms: the file form changes no ID and no within-file nesting; in the section form the target parent is located as the target file's section bearing `` minus its final segment — absent, or lying within the moved subtree, it is `refused-missing-target-parent` (T6.5-4) — prefix replacement preserves the subtree's relative nesting, a single-segment `` inserts at top level (exactly one segment, 1.3), and the origin's removal disturbs no remaining ID; on a workspace passing the valid-workspace precondition, 1.3 therefore holds by construction after every non-refused move, the check is exercised as the always-passing side of every successful move (T6.5-1/2/3), and `refused-structural-parent` is staged through rename alone (T6.4-3, T14-7). ### 6.6 Previews @@ -276,7 +276,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T6.6-2 Modifies nothing.** A rename `--preview` and a section-form move `--preview` on workspaces where the real operation would proceed: exit 0, findings `[]`, and every byte of the workspace identical afterward — sources, journal (an absent journal stays absent), derived files, and graph data untouched; a subsequent real run on the same state performs the previewed plan, its applied mapping (T6.4-1) equal to the preview's `mapping`. Preview output is byte-deterministic across repeated runs (H-6) and, under `--json`, the form-exact 12.7 preview document (H-3). * **T6.6-3 Refusal and scheduling equivalence.** For each refusal of T6.4-3 and T6.5-4 — the invalid-workspace precondition included — staged identically, the `--preview` invocation reports the same findings (same stable codes, locations, identities; 14) and exits 1, its `mapping`, `files`, and `delta` `null` (12.7), modifying nothing; for the usage errors of T6.4-4/T6.5-5 the preview exits 2 identically (argument checks precede either way). The equivalence is over workspace state, never scheduling (6.6): while another mutating command is held (`--test-hold`, 13.5), a `--preview` invocation runs to completion — it takes no exclusivity and never meets the mutual-exclusion refusal of T13.5-2 — and `--test-hold` combined with `--preview` is a usage error, exit 2. -* **T6.6-4 Report content.** Byte-precise fixtures asserted against precomputed pre-operation offsets, form-exact per 12.7: (a) a rename preview reports the complete identity mapping — the renamed ID and every descendant — and, per rewritten file, `id-rewrite` edits spanning each rewritten `id` attribute's own characters and `reference-rewrite` edits spanning each affected occurrence's span (5.7) across MDX and TS; (b) a section-move preview into an existing target file reports the `origin-deletion` as one range spanning the construct's own characters extended over the leftover whitespace and line terminator of each additionally dropped line (contiguous bytes: the adjunct drop lies inside this range, no class of its own), the re-identification's `id-rewrite` edits nested inside that deletion range in the same pre-operation coordinates (containment is geometry, each edit under its own class), `target-insertion` as a zero-length range at the insertion offset, `target-parent-rewrite` spanning a self-closing target parent's tag, `import-addition` as a zero-length range at the exact offset the real operation then uses (6.5; byte-asserted by running the operation on a copy), and `import-removal` spanning the declaration plus its adjunct drops; (c) a file-form move preview reports `import-specifier-rewrite` edits spanning the specifier literals and `file-relocation` spanning the entire moved file, the relocated file's entry under its current, pre-operation path; (d) a section-move preview whose target file does not exist reports, under the path the creation would occupy, exactly one `file-creation` edit at the start of the new file — the only location without pre-operation coordinates; the insertion and any import additions there are subsumed, while the moved text's own rewrites are reported inside the origin file's deletion range. Every edit is class-plus-range only — no replacement text (12.7) — and every reported class is one of the ten 12.7 class names. +* **T6.6-4 Report content.** Byte-precise fixtures asserted against precomputed pre-operation offsets, form-exact per 12.7: (a) a rename preview reports the complete identity mapping — the renamed ID and every descendant — and, per rewritten file, `id-rewrite` edits spanning each rewritten `id` attribute's own characters and `reference-rewrite` edits spanning each affected occurrence's span (5.7) across MDX and TS; (b) a section-move preview into an existing target file reports the `origin-deletion` as one range spanning the construct's own characters extended over the leftover whitespace and line terminator of each additionally dropped line (contiguous bytes: the adjunct drop lies inside this range, no class of its own), the re-identification's `id-rewrite` edits nested inside that deletion range in the same pre-operation coordinates (containment is geometry, each edit under its own class), `target-insertion` as a zero-length range at the insertion offset, `target-parent-rewrite` spanning a self-closing target parent's tag, `import-addition` as a zero-length range at the exact offset the real operation then uses (6.5; byte-asserted by running the operation on a copy), and `import-removal` spanning the declaration plus its adjunct drops; (c) a file-form move preview reports `import-specifier-rewrite` edits spanning the specifier literals and `file-relocation` spanning the entire moved file, the relocated file's entry under its current, pre-operation path; (d) a section-move preview whose target file does not exist reports, under the path the creation would occupy, exactly one `file-creation` edit at the start of the new file — the only location without pre-operation coordinates; the insertion and any import additions there are subsumed, while the moved text's own rewrites are reported inside the origin file's deletion range. Every edit is class-plus-range only — no replacement text (12.7) — and every reported class is one of the ten 12.7 class names. The edit ordering's final tie-break — class-name bytes after range start and range end (12.7) — admits no product-independent discriminating fixture: distinct nonzero-range edits rewrite or remove distinct constructs and never share both endpoints, so an identical-range pair arises only between zero-length insertion points — an import addition whose implementation-chosen offset (6.5) coincides with the target insertion, possible only at end of file for a top-level ``, an import being grammar-permitted nowhere inside a section construct — a coincidence implementation latitude alone decides; the harness asserts the full 12.7 comparator over whatever edits are emitted (`import-addition` ordering before `target-insertion` on coincidence), exercising the tie-break exactly where a product makes it observable. * **T6.6-5 Delta.** After a build, a file-form move preview reports the derived-file delta both directions: under `generated` the destination's module, companion, and (emission enabled) Markdown paths — nothing recorded there — and under `removed` the recorded pre-move module, companions, and Markdown the operation would leave no longer generated (6.6); a rename preview on the same workspace reports `[]` in both directions (regeneration rewrites recorded paths in place), and the created-target move of T6.6-4(d) reports the new file's derived paths under `generated`. Record-based, not presence-based: with graph data deleted (T13.3-2's operational definition) the same move preview's `generated` approaches the full regeneration set — nothing being recorded — and the preview still writes nothing: no refresh, graph data still absent afterward. * **T6.6-6 Unreadable record.** Corrupt the product-written graph data shape-blind (truncation or garbage over T13.3-2's operational path set; H-3/H-4 staging discipline, as T10.1-4 stages sessions): a move `--preview` whose plan is otherwise valid emits the full preview — `mapping` and `files` complete — with `delta` explicitly unavailable as one datum, never read as an empty record, the condition-23 finding (`unreadable-record`, concerned path the graph-data area, no path inside it named) in `findings`, exit 1 (14.23). The real operation on the same state is not refused — it proceeds, its finishing regeneration replacing the corrupt record (`check` clean afterward, T12.2-2) — and a refused preview staged on the same corrupt-record state (an identity-unchanged rename) reports the refusal findings alone, `mapping`/`files`/`delta` `null`, never a condition-23 finding (6.6: a refused preview consults no record). @@ -287,7 +287,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ## 7. Project Configuration * **T7-1 Location.** Configuration found by upward search from a nested working directory; `--config ` (resolved against the working directory, 12.0) overrides the search; no configuration reachable → configuration error (14.14, exit 2). -* **T7-2 Declarative form.** Each fails with 14.14 (exit 2): a configuration file that is not well-formed TypeScript (a syntax error); missing `defineConfig` import; import from a specifier other than `"xspec"`; extra statements; a non-literal argument (spread, computed key, template literal, identifier reference, function call, number where boolean expected); a default export that is not one call to the (optionally aliased) binding. An aliased `defineConfig` import is valid. +* **T7-2 Declarative form.** Each fails with 14.14 (exit 2): a configuration file that is not well-formed TypeScript (a syntax error); missing `defineConfig` import; import from a specifier other than `"xspec"`; extra statements; a non-literal argument (spread, computed key, template literal, identifier reference, function call, number where boolean expected); a default export that is not one call to the (optionally aliased) binding. An aliased `defineConfig` import is valid. String-literal keys are part of the accepted form (7): a configuration declaring a spec group and a code group under string-literal keys whose names are not TypeScript identifiers (`"my-group"`, `"test-code"`) loads without error, both groups discover their globs' files, and the names resolve wherever group names are referenced — a coverage profile with `target: "my-group"`, `boundary: "test-code"` reports its coverage (8), and a policy rule's selector `{ group: "my-group" }` matches the group's nodes (7.5) — discriminating a product that accepts identifier keys alone, which refuses a valid configuration no other spelling can declare (a non-identifier group name has only the string-literal form). * **T7-3 Keys.** `specs` missing → 14.14. Omitted optional keys, each with its stated observation: `code` — no code groups: a marker-bearing `.ts` file is undiscovered, the unfiltered `query edges` list carries no edge from it, and naming it in `--from` is unknown (exit 2, 11); `markdown` — no emission: no `.md` is written for any source (T3-6); `coverage` — no profiles: `xspec coverage` reports zero profiles and exits 0; `policy` — no rules: `check` on a workspace whose edges would violate T7.5-2's rule, with the rule omitted, reports no policy findings and exits 0. Empty lists (7): `coverage: []` and `policy: []` are valid and equivalent to omitting the key — zero profiles reported, no policy findings. Unknown keys at top level, in `markdown`, in a profile, in a rule, and in a selector each → 14.14. * **T7-4 Globs.** Semantics fixtures: `*` any possibly empty run of bytes within one path segment; `?` exactly one byte within a segment; `**` whole segments including none; case-sensitive matching — including a single-casing probe stageable on any filesystem: a group whose only pattern is `SPECS/*.mdx` over a workspace directory `specs/` holding `A.mdx` discovers zero sources (rerun on the Windows leg, E-6, where a product matching globs through case-insensitive filesystem lookups wrongly discovers the file); byte semantics (7: paths match as their UTF-8 bytes), on the Linux leg — a file whose name contains a two-byte code point (`é.mdx`): `?.mdx` does not match it while `??.mdx` and `*.mdx` do, discriminating bytes from characters; a dotfile matched only by a pattern segment written with a leading `.` — wildcards never match dot-segments: `a/**/b.mdx` does not match `a/.h/b.mdx`, `*` does not match `.hidden`, `?x` does not match `.x`; a pattern resolving outside the workspace root → 14.14. All paths resolve relative to the configuration file's directory. Literal metacharacters (7: globs support exactly `*`, `?`, `**` — every other character is a literal): a pattern segment containing `[1]`, `{a,c}`, `!`, or `+(x)` matches exactly the file name containing those characters and never what a character-class, brace-expansion, negation, or extglob dialect would match — `a[1].mdx` matches `a[1].mdx` and not `a1.mdx`; `b{a,c}.mdx` matches `b{a,c}.mdx` and not `ba.mdx` or `bc.mdx`. * **T7-5 Symbolic links.** A symlinked file matched by a glob is not discovered; a symlinked directory is not traversed (contents undiscovered); broken links ignored; a symlink cycle does not hang discovery; workspace-external content behind a link never enters the discovered set. @@ -563,7 +563,7 @@ Property tests generate inputs from seeded, reproducible generators (H-10), asse * **P-2 Markdown compilation.** Random documents composed of prose blocks — fenced code blocks and inline code spans spelling tag-, import-, and expression-like bytes included (T3-1's grammar boundary: such bytes are content) — nested sections, imports, comments (single- and multi-line), and embeddings, over mixed line terminators, with content weighted toward the whitespace/non-whitespace boundary code points of 1.4 (U+00A0, U+0085, and U+2028 included): compiled output equals an independent oracle implementing the removal/replacement/line-drop rules of 3 (the oracle lives in the harness); compilation is deterministic; content bytes outside removed constructs are preserved. * **P-3 Text algebra.** For random documents: root subtree text equals compiled Markdown output; a node's subtree text equals its own-text runs interleaved with its children's subtree texts in document order (1.6); N children yield N+1 runs. * **P-4 Hash laws.** For random workspaces and random single edits: subtreeHash changed iff the 5.5 condition holds; metadataHash changed iff `d`/`coverage`/`tags` changed; ownHash insensitive to embedded-target edits; effectiveHash monotone over the dependency closure (any dependency-target effectiveHash change propagates); identical workspaces hash identically. -* **P-5 Rename/move purity.** Random valid workspaces, random journaled rename/file-move sequences: all hashes byte-stable, impact against any prior commit in the sequence reports no categories, and all references still resolve; random section moves: only the predicted parents gain categories. +* **P-5 Rename/move purity; section-move categories.** Random valid workspaces, random journaled rename/file-move sequences: all hashes byte-stable, impact against any prior commit in the sequence reports no categories, and all references still resolve. Random section moves: impact against a baseline committed immediately before the move equals an oracle of 6.2/5.6, anchored by T6.2-3/T6.2-4. The oracle's `changed` set is drawn from exactly the origin parent, the target parent, and the moved subtree's nodes — each `changed` iff its own content sequence (1.6) differs across the move: distinct parents necessarily (one loses a child reference, one gains one), a coincident parent iff the re-insertion fails to reproduce its sequence (a final child re-inserted at its own former position is pure in effect, 6.2), a moved-subtree node iff the straddling-line drops of 6.2 change its runs, computed by the line-drop rules of 3 (P-2's oracle) — with `metadata-changed` on no node (6.2: every moved node keeps its metadataHash, and canonical identities preserve every other node's), `descendant-changed` and `upstream-changed` exactly per 5.6's cascades from the changed nodes, attributions included, and no node carrying any category the oracle does not predict. * **P-6 Baseline replay.** Random edit/rename/move/commit interleavings: impact categories against each historical baseline equal an oracle diff of the two graphs with identities mapped through the journal suffix. * **P-7 Glob and capture matching.** Random patterns and paths over the 7/7.5 grammar, with generators including the glob metacharacters of common dialects (`[` `]` `{` `}` `!` `+` `(` `)`), which the 7 grammar treats as literals, and `$` forms at the capture boundary (`$0`, `$` before a non-digit, trailing `$` — literals in 7.5 patterns; T7.5-5): match decisions and capture values equal a spec oracle; every match is unique under the left-to-right shortest-match rule; captures never span `/` and never match empty. * **P-8 Parser robustness.** Fuzzed byte inputs (mutated MDX/TS/config, invalid UTF-8, BOMs, giant nesting, pathological line terminators): every command terminates, never emits a partial JSON document on `--json`, and always exits 0, 1, or 2 per the 12.0 partition; `build` failures modify nothing. From 2e236c7b25ebf9e3a57c8fe00c3f9ec817fe940b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:21:34 +0000 Subject: [PATCH 037/226] sdg(phase-6): byte-assert the move's real import edits and conversions; vet the P-5/P-6 oracles (iter 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied C1: new T6.5-7 asserts the section move's rewritten origin and target files byte-equal to expected bytes composed from 6.5's and 3's rules, in the latitude-free to-local direction — import-removal extents (own-line drop and shared-line declaration-only deletion), 6.4's pinned double-quoted conversion spellings, and quote-preserving prefix re-identification — with a pointer added in T6.5-2 where the exclusion lives. Applied I1: S-6 extended to vet the section-move category oracle (P-5) against 6.2's worked case plus T6.2-3/T6.2-4, and the baseline graph-diff oracle (P-6) against 5.6's three worked examples plus T5.6-6's added/deleted convention. Applied O1: P-5's changed-set rule now states a created target file's root enters as an added node, changed by addition with no other category (5.6). Applied O2: T13.4-1's sorted-keys assertion cites 13.4 with 12.0 supplying the byte order. Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index d25d6b8..0c3e58b 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -264,12 +264,14 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ### 6.5 Move * **T6.5-1 File form.** IDs unchanged; identities change file part only; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; journal appended; finishing regeneration as T6.4-7; the applied-mapping report as T6.4-1 (6.5: both forms report as rename does). -* **T6.5-2 Section form text edits.** Byte-exact fixtures: moved text spans opening tag's first character through closing tag's last; origin deletion drops lines left empty/whitespace-only (rule of 3); insertion immediately before the target parent's closing tag (or end of file for top-level `new-id`), followed by U+000A and preceded by one when not at line start; target file created when absent; no other byte changes beyond these edits, the identity and reference rewrites, and the finishing regeneration (6.5). Self-closing arms (1.1, 6.5), byte-exact: moving a self-closing section moves exactly the self-closing tag's own characters; a self-closing target parent is first rewritten to paired form — its `/` and any whitespace immediately before or after the `/` deleted, the closing tag matching the opening tag's name appended immediately after the tag's terminating `>` — and the insertion rule then applies before that closing tag: a `` parent becomes `` + U+000A + the moved text + U+000A + ``. +* **T6.5-2 Section form text edits.** Byte-exact fixtures: moved text spans opening tag's first character through closing tag's last; origin deletion drops lines left empty/whitespace-only (rule of 3); insertion immediately before the target parent's closing tag (or end of file for top-level `new-id`), followed by U+000A and preceded by one when not at line start; target file created when absent; no other byte changes beyond these edits, the identity and reference rewrites (their bytes: T6.5-7), and the finishing regeneration (6.5). Self-closing arms (1.1, 6.5), byte-exact: moving a self-closing section moves exactly the self-closing tag's own characters; a self-closing target parent is first rewritten to paired form — its `/` and any whitespace immediately before or after the `/` deleted, the closing tag matching the opening tag's name appended immediately after the tag's terminating `>` — and the insertion rule then applies before that closing tag: a `` parent becomes `` + U+000A + the moved text + U+000A + ``. * **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. * **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link (target absent), one arm each (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose target path is occupied by anything other than a discovered spec source — neither an insertion target nor an absent path to create, refused `refused-destination-exists` (6.5, T14-7) — one arm each: a directory; a symbolic link resolving to a discovered spec source (the link-following discriminator: discovery never yields a symlink, 7 — a product resolving the target path through the filesystem finds a spec source there and inserts through the link); and an existing `.mdx` file outside every configured spec group (present, right extension, still no discovered spec source); section form whose `` is invalid per 1.4 (the mirrored "new ID is valid" check, 6.5) — a forbidden name (`then`) and a whitespace-bearing segment, one arm each; section form whose `` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; containing `#`; not valid UTF-8 (Linux leg); lacking `.mdx`; and the derived-path arm of `refused-invalid-destination` (6.5: a workspace-relative directory component of a derived path the destination would generate — 13.1, 13.2, 7.3 — occupied by a non-directory): with emission enabled under `markdown.outDir`, a file-form move whose destination `new/b.mdx` is otherwise valid, its own directory components unobstructed (`new/` absent — a nonexistent component is never a refusal cause, 13.4, T13.4-8), but whose emit destination `/new/b.md` has its component `/new` occupied by a plain file lying under no current source's write path (so the workspace passes `build`'s validations) — refused `refused-invalid-destination`, never 14.22 (14, T14-7), discriminating a product that vets only the destination path's own components; the module and companion paths share the destination's directory (13.1), so the `outDir` emit destination is the separable derived-path fixture. Plus the valid-workspace precondition as T6.4-6. * **T6.5-5 Usage errors (exit 2).** Nonexistent origin file or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y` and `a.mdx#x b.mdx` match neither form — exit 2 — and an operand containing `#` is always a `#` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). * **T6.5-6 Identity terms.** The new-identity checks read in identity terms (6.5): a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid; the exact self-move — `#` equal to `#` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. So is the mirrored "structural parent rules remain satisfied" check, in both forms: the file form changes no ID and no within-file nesting; in the section form the target parent is located as the target file's section bearing `` minus its final segment — absent, or lying within the moved subtree, it is `refused-missing-target-parent` (T6.5-4) — prefix replacement preserves the subtree's relative nesting, a single-segment `` inserts at top level (exactly one segment, 1.3), and the origin's removal disturbs no remaining ID; on a workspace passing the valid-workspace precondition, 1.3 therefore holds by construction after every non-refused move, the check is exercised as the always-passing side of every successful move (T6.5-1/2/3), and `refused-structural-parent` is staged through rename alone (T6.4-3, T14-7). +* **T6.5-7 Operation-side rewrite bytes.** The real move's import-edit extents and reference-conversion spellings, byte-asserted against independently composed expected files — staged so no import is added, the one rewrite direction free of implementation latitude (6.5: identifier choice and insertion offset attach to added imports alone). Fixture: the origin file imports the target file under two bindings (valid, 2.1), one declaration alone on its line, the other following a retained, still-referenced third-module import on a shared line; every reference through the two bindings — a `d` chain and a `text(...)` embedding — lies inside the moved subtree, which also holds a single-quoted local string reference to a moved descendant; no reference to a moved node lies outside the subtree. After the section move into the target file: both target-file imports are left unreferenced and removed with 6.5's exact extent — the own-line declaration's line dropped with its terminator (3), the shared-line declaration's own characters alone deleted, the retained import kept byte-for-byte on its kept line; the moved references convert to local form in 6.4's pinned spelling for converted references, double-quoted string literals; the local reference is re-identified by prefix replacement with its single-quote spelling preserved (6.4: minimal in-place edits); and the rewritten origin and target files are each asserted byte-equal to expected bytes composed from the rules of 6.5 and 3. This is the operation-side assertion T6.5-2 sets aside (its no-other-byte-changes check excludes the identity and reference rewrites) and T6.6-4 makes only of the preview's report — failing a product that emits a spec-perfect preview while the real edit leaves an emptied import line behind, normalizes whitespace around a removed declaration, or spells a converted reference single-quoted. + ### 6.6 Previews (T6.6-1 is retired.) @@ -517,7 +519,7 @@ Assertions here — and wherever these forms appear across the suite — are for ### 13.4 Derived and durable files -* **T13.4-1 Plain committable files.** Every file xspec writes is a plain file; writing the workspace into git and back (commit, clean checkout) round-trips builds and reads. Sorted keys (13.4): every JSON object in a product-written session file — read after `create` and again after a `resolve` rewrites it (10.4) — has its keys in byte-sorted order (12.0), asserted shape- and value-blind over whatever objects and keys are present (H-3: no shape or values pinned). The session file is the one written class where the clause is discriminable: journal entry content (6.1) and graph data (13.3) are opaque, and generated TypeScript and Markdown carry no keyed serialization; the sibling stable-ordering clause is covered by the determinism protocol (H-6, T10.1-1, T12.0-7, T13.3-4). +* **T13.4-1 Plain committable files.** Every file xspec writes is a plain file; writing the workspace into git and back (commit, clean checkout) round-trips builds and reads. Sorted keys (13.4): every JSON object in a product-written session file — read after `create` and again after a `resolve` rewrites it (10.4) — has its keys in byte-sorted order (13.4, the byte order per 12.0), asserted shape- and value-blind over whatever objects and keys are present (H-3: no shape or values pinned). The session file is the one written class where the clause is discriminable: journal entry content (6.1) and graph data (13.3) are opaque, and generated TypeScript and Markdown carry no keyed serialization; the sibling stable-ordering clause is covered by the determinism protocol (H-6, T10.1-1, T12.0-7, T13.3-4). * **T13.4-2 Derived reproducibility.** Delete, truncate, and garbage-overwrite each class of derived file (module, companion, Markdown, graph data): `build` restores all byte-exactly. * **T13.4-3 Orphan knowledge boundary.** Build so a derived file exists and is recorded; delete all graph data (T13.3-2's operational definition), taking the recorded derived-file paths with it; change the configuration so that file is no longer generated; `build`: the orphaned file is outside xspec's knowledge and is not removed; it may be deleted manually (asserted: subsequent builds leave the stray file alone). * **T13.4-4 Derived paths belong to xspec.** A user-created file at a derived path is replaced by `build`; a symbolic link at a derived file's own path is replaced as the occupant — nothing is written through it (link target byte-identical after build, link gone, plain file present; not an error). @@ -563,7 +565,7 @@ Property tests generate inputs from seeded, reproducible generators (H-10), asse * **P-2 Markdown compilation.** Random documents composed of prose blocks — fenced code blocks and inline code spans spelling tag-, import-, and expression-like bytes included (T3-1's grammar boundary: such bytes are content) — nested sections, imports, comments (single- and multi-line), and embeddings, over mixed line terminators, with content weighted toward the whitespace/non-whitespace boundary code points of 1.4 (U+00A0, U+0085, and U+2028 included): compiled output equals an independent oracle implementing the removal/replacement/line-drop rules of 3 (the oracle lives in the harness); compilation is deterministic; content bytes outside removed constructs are preserved. * **P-3 Text algebra.** For random documents: root subtree text equals compiled Markdown output; a node's subtree text equals its own-text runs interleaved with its children's subtree texts in document order (1.6); N children yield N+1 runs. * **P-4 Hash laws.** For random workspaces and random single edits: subtreeHash changed iff the 5.5 condition holds; metadataHash changed iff `d`/`coverage`/`tags` changed; ownHash insensitive to embedded-target edits; effectiveHash monotone over the dependency closure (any dependency-target effectiveHash change propagates); identical workspaces hash identically. -* **P-5 Rename/move purity; section-move categories.** Random valid workspaces, random journaled rename/file-move sequences: all hashes byte-stable, impact against any prior commit in the sequence reports no categories, and all references still resolve. Random section moves: impact against a baseline committed immediately before the move equals an oracle of 6.2/5.6, anchored by T6.2-3/T6.2-4. The oracle's `changed` set is drawn from exactly the origin parent, the target parent, and the moved subtree's nodes — each `changed` iff its own content sequence (1.6) differs across the move: distinct parents necessarily (one loses a child reference, one gains one), a coincident parent iff the re-insertion fails to reproduce its sequence (a final child re-inserted at its own former position is pure in effect, 6.2), a moved-subtree node iff the straddling-line drops of 6.2 change its runs, computed by the line-drop rules of 3 (P-2's oracle) — with `metadata-changed` on no node (6.2: every moved node keeps its metadataHash, and canonical identities preserve every other node's), `descendant-changed` and `upstream-changed` exactly per 5.6's cascades from the changed nodes, attributions included, and no node carrying any category the oracle does not predict. +* **P-5 Rename/move purity; section-move categories.** Random valid workspaces, random journaled rename/file-move sequences: all hashes byte-stable, impact against any prior commit in the sequence reports no categories, and all references still resolve. Random section moves: impact against a baseline committed immediately before the move equals an oracle of 6.2/5.6, anchored by T6.2-3/T6.2-4. The oracle's `changed` set is drawn from exactly the origin parent, the target parent, and the moved subtree's nodes — each `changed` iff its own content sequence (1.6) differs across the move: distinct parents necessarily (one loses a child reference, one gains one; a created target file's root, present on no baseline side, is instead `changed` as an added node — by addition, not comparison — and per 5.6 carries no other category), a coincident parent iff the re-insertion fails to reproduce its sequence (a final child re-inserted at its own former position is pure in effect, 6.2), a moved-subtree node iff the straddling-line drops of 6.2 change its runs, computed by the line-drop rules of 3 (P-2's oracle) — with `metadata-changed` on no node (6.2: every moved node keeps its metadataHash, and canonical identities preserve every other node's), `descendant-changed` and `upstream-changed` exactly per 5.6's cascades from the changed nodes, attributions included, and no node carrying any category the oracle does not predict. * **P-6 Baseline replay.** Random edit/rename/move/commit interleavings: impact categories against each historical baseline equal an oracle diff of the two graphs with identities mapped through the journal suffix. * **P-7 Glob and capture matching.** Random patterns and paths over the 7/7.5 grammar, with generators including the glob metacharacters of common dialects (`[` `]` `{` `}` `!` `+` `(` `)`), which the 7 grammar treats as literals, and `$` forms at the capture boundary (`$0`, `$` before a non-digit, trailing `$` — literals in 7.5 patterns; T7.5-5): match decisions and capture values equal a spec oracle; every match is unique under the left-to-right shortest-match rule; captures never span `/` and never match empty. * **P-8 Parser robustness.** Fuzzed byte inputs (mutated MDX/TS/config, invalid UTF-8, BOMs, giant nesting, pathological line terminators): every command terminates, never emits a partial JSON document on `--json`, and always exits 0, 1, or 2 per the 12.0 partition; `build` failures modify nothing. @@ -584,7 +586,7 @@ Confidence that the harness itself is correct comes primarily from certification * **S-3 Subprocess driver.** Captures exit codes and keeps stdout/stderr separated (verified against a known-behavior stand-in command); enforces per-test working directories; detects hangs via timeout and reports them as failures, not skips. * **S-4 TypeScript tooling driver.** Detects a known type error, a known definition location, and a known hover text in a hand-written non-xspec fixture project, so section 4's consumer assertions cannot pass vacuously. * **S-5 Output adapters.** Each adapter (H-3) rejects documents missing required information (fed synthetic wrong-shape documents) rather than defaulting. -* **S-6 Oracles.** The Markdown oracle (P-2), the glob/capture oracle (P-7), and the coverage-reachability oracle (P-13) pass their own fixed vector suites derived from SPEC.md's examples (3, 7.5, 15) before being trusted by property tests. +* **S-6 Oracles.** The Markdown oracle (P-2), the glob/capture oracle (P-7), the coverage-reachability oracle (P-13), the section-move category oracle (P-5), and the baseline graph-diff oracle (P-6) pass their own fixed vector suites before being trusted by property tests, each derived from SPEC.md's worked material: 3, 7.5, and 15 respectively for the first three; 6.2's worked straddling-line case plus the clean-boundary and final-position cases of T6.2-3/T6.2-4 for the section-move oracle; 5.6's three worked examples plus the added/deleted convention of T5.6-6 for the graph-diff oracle. * **S-7 Red-green sweep.** Against an empty stub product (every command exits with an unexpected code and no output), every product-facing test fails with a diagnosed assertion and the suite completes without harness errors (H-8). ## 18. Execution and CI From 320f063279bb09b7f087306cfca303aa6059e8cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:56:32 +0000 Subject: [PATCH 038/226] sdg(phase-6): align T14-4 with 11.2's non-attachment of 13/22; test 12.0's stderr invariance; kind the 14.22 occupant (iter 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: - I1: T14-4's final row no longer claims conditions 13/22 accompany occurrences/view/at answers — they are findings of no domain file (SPEC.md 11.2; T11.2-6, T13.3-3 already assert non-attachment) — and the accompaniment claim is now per surface domain: occurrences alone for code-source stagings (14.7/14.11/14.18 locate only there; view/at domains hold spec sources only). - I2: T12.0-2 now byte-compares stderr across the --json flip for a representative exit-2 error and a failing build, covering 12.0's untested standard-error-content clause (product-to-itself, H-4). - O1: T13.4-6 gains an explicit plain-file directory-component arm under a build write path (first emission's outDir component), closing the 14.22 occupant-kind matrix; title updated to match. Rejected: - O2: no change — the hold-file nonexistent-parent corner constrains no test (the harness controls every hold path it passes), blocks nothing, and the reviewer's own safe stance is the document's current stance; not a blocking SPEC.md problem. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index 0c3e58b..5efd45a 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -447,7 +447,7 @@ Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are ### 12.0 Global conventions * **T12.0-1 --json everywhere.** For every command and subcommand this specification covers, `--json` emits exactly one JSON document as the entire standard output, carrying the same information as the human report (adapter-verified per command in the sections above; this test sweeps that every command accepts the flag; the JSON-only surfaces of 10.7, 11, and 12.6 emit the same single document with the flag as without). -* **T12.0-2 Streams.** A failing `build`'s validation errors and `check`'s findings are standard-output content (exit 1); usage and configuration error messages are standard-error content. With JSON output in effect — `--json` among the arguments, even when the arguments are themselves the error (an unknown command; an unknown flag), or a JSON-only surface (10.7, 11, 12.6) — an exit-2 invocation emits the 12.7 error document as its entire stdout (T12.7-3); without JSON in effect, exit-2 stdout is empty. Non-JSON diagnostics never contaminate a JSON stdout, and the output form never changes an exit code. +* **T12.0-2 Streams.** A failing `build`'s validation errors and `check`'s findings are standard-output content (exit 1); usage and configuration error messages are standard-error content. With JSON output in effect — `--json` among the arguments, even when the arguments are themselves the error (an unknown command; an unknown flag), or a JSON-only surface (10.7, 11, 12.6) — an exit-2 invocation emits the 12.7 error document as its entire stdout (T12.7-3); without JSON in effect, exit-2 stdout is empty. Non-JSON diagnostics never contaminate a JSON stdout, and the output form never changes an exit code or standard-error content: a representative exit-2 usage error and a failing `build` (exit 1), each run with and without `--json`, exit identically with standard error byte-identical across the two forms (12.0; a product-to-itself comparison, H-4) — failing a product that appends or substitutes stderr diagnostics when JSON output is in effect. * **T12.0-3 --config.** Every command accepts `--config `; a relative path resolves against the working directory, not the workspace root. * **T12.0-4 Flag repetition.** Repeating a flag on any command → exit 2; list-valued flags take one comma-separated value (`--kinds depends,embeds`). * **T12.0-5 Argument addressing.** ``, ``, ``, and `--file` arguments are workspace-relative with `/` separators, independent of the working directory (run each representative command from a subdirectory); `--test-hold ` resolves against the working directory (13.5). Native-separator negative: an argument spelled with `\` (`specs\A.mdx`) names no workspace file — paths compare byte-wise — and is an unknown-file usage error, exit 2; discriminating on the Windows leg (E-6), where `\` is the native separator. An argument value that is not valid UTF-8 (raw bytes in the OS argument vector, Linux leg) → usage error, exit 2 (12.0). @@ -524,7 +524,7 @@ Assertions here — and wherever these forms appear across the suite — are for * **T13.4-3 Orphan knowledge boundary.** Build so a derived file exists and is recorded; delete all graph data (T13.3-2's operational definition), taking the recorded derived-file paths with it; change the configuration so that file is no longer generated; `build`: the orphaned file is outside xspec's knowledge and is not removed; it may be deleted manually (asserted: subsequent builds leave the stray file alone). * **T13.4-4 Derived paths belong to xspec.** A user-created file at a derived path is replaced by `build`; a symbolic link at a derived file's own path is replaced as the occupant — nothing is written through it (link target byte-identical after build, link gone, plain file present; not an error). * **T13.4-5 Durable protection.** `build` and read commands never modify or delete the journal or session files (byte-compare); durable files are never regenerated (deleting a session file: xspec does not recreate it; review naming it → exit 2 unknown session). -* **T13.4-6 Symlink write rules.** A write whose path has a symbolic link at a workspace-relative directory component is refused before anything is modified (14.22; command exits 1 with the report, workspace byte-identical); `check` reports the same without writing; finding cardinality (14.22: one finding per distinct offending component, whatever write paths it refuses) — one non-directory occupant at a component under which two derived files would be written yields one finding, concerned path that component, and two distinct offending components yield two findings (asserted via `check`); a durable path occupied by a symlink or non-plain file → journal error (14.13) / corrupt session (14.21) — never read, appended, or replaced (link and target byte-identical after the attempt). Positive counterpart: path components above the workspace root are unrestricted (13.4) — a workspace whose absolute path traverses a symbolic link above its root builds, mutates, and `check`s normally. +* **T13.4-6 Obstructed write paths.** A write whose path has a symbolic link at a workspace-relative directory component is refused before anything is modified (14.22; command exits 1 with the report, workspace byte-identical); `check` reports the same without writing; occupant kinds (14.22: a plain file, a symbolic link whatever it targets, or any other non-directory occupant) — a plain file occupying a directory component of a `build` write path, a first emission's `outDir` component with no move operand involved (a plain-file component under a move's destination or its derived paths reports `refused-invalid-destination` instead, T6.5-4, T14-7), is refused identically: `build` exits 1 with the condition-22 finding, concerned path that component, modifying nothing, and `check` reports it without writing; finding cardinality (14.22: one finding per distinct offending component, whatever write paths it refuses) — one non-directory occupant at a component under which two derived files would be written yields one finding, concerned path that component, and two distinct offending components yield two findings (asserted via `check`); a durable path occupied by a symlink or non-plain file → journal error (14.13) / corrupt session (14.21) — never read, appended, or replaced (link and target byte-identical after the attempt). Positive counterpart: path components above the workspace root are unrestricted (13.4) — a workspace whose absolute path traverses a symbolic link above its root builds, mutates, and `check`s normally. * **T13.4-7 Source exclusion.** T7-6 covers `.xspec.`/`.xspec/`/emit-destination exclusion from groups. * **T13.4-8 Writes create missing directories.** A missing intermediate directory never refuses or fails a write: the nonexistent workspace-relative directory components of a written path come into existence as directories (13.4), each named case staged with its directories absent beforehand and asserted present as real directories afterward — a file-form move to `new/deep/b.mdx` (destination in a configured spec group, `new/` absent) succeeds, the moved file and its regenerated derived files under the fresh directories; a section-form move whose created target file (6.5) lies under an absent directory succeeds likewise; a first emission under a nested nonexistent `markdown.outDir` (7.3) writes every destination, creating the chain. @@ -547,7 +547,7 @@ Sections 1–13 exercise each numbered condition in its home context; this secti * **T14-1 Actionable and complete reporting.** A workspace seeded with several independent error conditions across files: `build` and `check` report each of them (not only the first), and every report identifies file and location and states a correction-oriented message (information presence, not wording). * **T14-2 Unresolved references.** A `d` reference, a `text(...)` target, and a TypeScript marker/`text` call that do not resolve → 14.5, 14.6, 14.7 respectively; the TS case is also a type error against the generated module (asserted when a prior valid generation exists). * **T14-3 Masking.** An unparseable file (14.20 — malformed MDX; malformed TS under the grammar its name selects: a TSX-only construct in a `.ts` file; invalid UTF-8; BOM) masks conditions inside itself, and every reference into it from other files reports as unresolved (14.5–14.7); the parse-failure location is reported. A configuration error suppresses all source analysis: only 14.14 is reported (exit 2) even with invalid sources present. -* **T14-4 Reporter matrix.** 14.10 and 14.12 reported by `check` only (a stale workspace `build`s successfully by regenerating; a policy-violating workspace `build`s successfully); 14.21 reported by `check`, by `review` subcommands naming the session, and by `review list` — not by `build`, and on a workspace failing `build`'s validations by `check` alone, beside the gate's findings (T10.1-5); 14.23 reported by `inventory` and `rename`/`move` previews only — `check` reports the state as 14.10's unit form, and `build` and the refreshing reads never do (the rebuild replaces the record; the reads leave it unconsulted, T13.3-2); 14.14 delivered as an exit-2 error by every command that loads configuration — never `version` (T12.6-2); every other condition reported by both `build` and `check`, and as a domain file's finding accompanying `occurrences`/`view`/`at` answers (11.2, T11.2-5). +* **T14-4 Reporter matrix.** 14.10 and 14.12 reported by `check` only (a stale workspace `build`s successfully by regenerating; a policy-violating workspace `build`s successfully); 14.21 reported by `check`, by `review` subcommands naming the session, and by `review list` — not by `build`, and on a workspace failing `build`'s validations by `check` alone, beside the gate's findings (T10.1-5); 14.23 reported by `inventory` and `rename`/`move` previews only — `check` reports the state as 14.10's unit form, and `build` and the refreshing reads never do (the rebuild replaces the record; the reads leave it unconsulted, T13.3-2); 14.14 delivered as an exit-2 error by every command that loads configuration — never `version` (T12.6-2); 14.13 and 14.22 reported by both `build` and `check` and by the gated reads (T13.3-3), yet accompanying no `occurrences`/`view`/`at` answer — each is the finding of no domain file, the journal and a write-path component never being domain files (11.2, T11.2-6); every other condition reported by both `build` and `check`, and as a domain file's finding accompanying the answers of each of `occurrences`/`view`/`at` whose domain can hold its staged file: all three for a spec-source staging, `occurrences` alone for a code-source one — the only staging for 14.7, 14.11, and 14.18, which locate in code sources alone — `view`'s and `at`'s domains holding spec sources only (11.2, 11.3–11.5, T11.2-5). * **T14-5 Grammar selection.** A file matched by a code group and named `.tsx`, containing TSX-only syntax (not parseable as plain TypeScript — T14-3's construct) inside a named unit that also holds a dependency marker and a `text(...)` call: `build` succeeds — `.tsx` parses as TSX (14.20) — and the marker's `references` edge and the call's `embeds` edge are recorded and attributed to that unit per 4.6. The negative direction, the same TSX-only construct in a `.ts` file failing 14.20, is T14-3's; a further arm stages it in a code-group file of another name (`.mts`), failing 14.20 identically — any name but `.tsx` selects plain TypeScript (14.20), discriminating against products keying specifically on `.ts`. * **T14-6 Stable codes.** For each of the 23 conditions, staged via its primary test's fixture and read from its stated reporter (T14-4): the finding carries the exact token 14 lists (`missing-id` … `unreadable-record`) as its `code` in the JSON report form, where 12.7 pins it — the value is the token string alone, the ordinal numeral no part of it — so a product omitting or misspelling a code fails even where exit class and located information are right. A plain usage error and a review-operation refusal carry no stable code — `code` `null` (14, T12.7-1/3). * **T14-7 Refusal reasons.** Staged refusals asserting each stable code with its concerned file, range, or identity (14): `refused-invalid-id` (concerning the invalid identity; intrinsic form only — a structurally misplaced but intrinsically valid new ID reports `refused-structural-parent` alone, never both); `refused-identity-unchanged`, reported alone by an identity-unchanged rename — no collision reason beside it (6.4: the after-removal check collides with nothing); `refused-id-collision`, locating every colliding bearer; `refused-structural-parent` (the violated identity); `refused-cycle`, locating the would-be cycle's full path; `refused-destination-exists` (the occupied path; the section form's non-spec-source occupant included); `refused-missing-target-parent` (the target-parent identity); `refused-invalid-destination` (the destination path; the destination-side directory-component cases of 6.5 report this code, never 14.22 — a plain file staged as a destination directory component and, in T6.5-4's derived-path arm, as a directory component of the destination's `outDir` emit destination). `refused-unresolvable-reference` admits no fixture (T6.4-3/T6.5-6) and is asserted only as the always-passing side of successful operations. Every applicable reason reports together, one finding per reason: a section move staged to both collide (`` present in the target file) and create a dependency cycle reports both findings, never only the first. The invalid-workspace refusal reports the workspace's numbered findings alone — on a workspace failing validation, a rename staged to also collide reports the validation findings only, exit 1, no refusal reason evaluated or reported beside them (6.4, 14). From bdab22a0f6c954cd3fbe42ce9e8bd9a590abea8d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:32:24 +0000 Subject: [PATCH 039/226] sdg(phase-6): retire 6.5's unspellable destination refusals to exit-2 arms; co-report the .mdx-occupant pair (iter 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: C1 (T6.5-4's "containing #" and "not valid UTF-8" refusal arms asserted exit 1 where SPEC 12.0/6.5 demand exit 2 — recorded as unstageable dead letters, staging invocations moved to T6.5-5/T12.0-13's exit-2 arms; T6.6-3's sweep cured via its T6.5-4 definition), I1 (the plain-.mdx destination-exists arm now asserts the co-reported refused-invalid-destination finding per SPEC 14's every-applicable-reason rule), O1 (empty- move-operand boundary pinned as refused-invalid-id, exit 1, against 11.3's exit-2 --to contrast), O2 (T13.3-3's opening scoped to the six gated reads), O3 (T13.5-1 seam-neutrality byte-parity arm for 13.5's "changes no other behavior"). Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index 5efd45a..40b7561 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -266,8 +266,8 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T6.5-1 File form.** IDs unchanged; identities change file part only; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; journal appended; finishing regeneration as T6.4-7; the applied-mapping report as T6.4-1 (6.5: both forms report as rename does). * **T6.5-2 Section form text edits.** Byte-exact fixtures: moved text spans opening tag's first character through closing tag's last; origin deletion drops lines left empty/whitespace-only (rule of 3); insertion immediately before the target parent's closing tag (or end of file for top-level `new-id`), followed by U+000A and preceded by one when not at line start; target file created when absent; no other byte changes beyond these edits, the identity and reference rewrites (their bytes: T6.5-7), and the finishing regeneration (6.5). Self-closing arms (1.1, 6.5), byte-exact: moving a self-closing section moves exactly the self-closing tag's own characters; a self-closing target parent is first rewritten to paired form — its `/` and any whitespace immediately before or after the `/` deleted, the closing tag matching the opening tag's name appended immediately after the tag's terminating `>` — and the insertion rule then applies before that closing tag: a `` parent becomes `` + U+000A + the moved text + U+000A + ``. * **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. -* **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link (target absent), one arm each (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose target path is occupied by anything other than a discovered spec source — neither an insertion target nor an absent path to create, refused `refused-destination-exists` (6.5, T14-7) — one arm each: a directory; a symbolic link resolving to a discovered spec source (the link-following discriminator: discovery never yields a symlink, 7 — a product resolving the target path through the filesystem finds a spec source there and inserts through the link); and an existing `.mdx` file outside every configured spec group (present, right extension, still no discovered spec source); section form whose `` is invalid per 1.4 (the mirrored "new ID is valid" check, 6.5) — a forbidden name (`then`) and a whitespace-bearing segment, one arm each; section form whose `` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; containing `#`; not valid UTF-8 (Linux leg); lacking `.mdx`; and the derived-path arm of `refused-invalid-destination` (6.5: a workspace-relative directory component of a derived path the destination would generate — 13.1, 13.2, 7.3 — occupied by a non-directory): with emission enabled under `markdown.outDir`, a file-form move whose destination `new/b.mdx` is otherwise valid, its own directory components unobstructed (`new/` absent — a nonexistent component is never a refusal cause, 13.4, T13.4-8), but whose emit destination `/new/b.md` has its component `/new` occupied by a plain file lying under no current source's write path (so the workspace passes `build`'s validations) — refused `refused-invalid-destination`, never 14.22 (14, T14-7), discriminating a product that vets only the destination path's own components; the module and companion paths share the destination's directory (13.1), so the `outDir` emit destination is the separable derived-path fixture. Plus the valid-workspace precondition as T6.4-6. -* **T6.5-5 Usage errors (exit 2).** Nonexistent origin file or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y` and `a.mdx#x b.mdx` match neither form — exit 2 — and an operand containing `#` is always a `#` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). +* **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link (target absent), one arm each (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose target path is occupied by anything other than a discovered spec source — neither an insertion target nor an absent path to create, refused `refused-destination-exists` (6.5, T14-7) — one arm each: a directory; a symbolic link resolving to a discovered spec source (the link-following discriminator: discovery never yields a symlink, 7 — a product resolving the target path through the filesystem finds a spec source there and inserts through the link); and an existing `.mdx` file outside every configured spec group (present, right extension, still no discovered spec source) — an arm asserting two findings, one per reason (14, T14-7): `refused-invalid-destination` (a path in no spec group) is applicable alongside `refused-destination-exists` in every staging of a plain-`.mdx` occupant, since an in-group, non-derived-excluded `.mdx` plain file is always discovered (7, 13.4) — the insertion target, no occupant refusal — and an unparseable discovered occupant is the invalid-workspace precondition's case; the directory and symlink arms, occupant-blocked at otherwise-valid in-group paths, stage singly; section form whose `` is not in intrinsic ID form (the mirrored "new ID is valid" check, 6.5; 14: one or more segments joined by `.`, each satisfying 1.4) — a forbidden name (`then`), a whitespace-bearing segment, and the empty `` (destination operand `b.mdx#`: one `#`, so the 12.0 split is well-formed and the id part has zero segments), one arm each, each refused `refused-invalid-id` — the empty-id arm discriminating a product that generalizes 11.3's `--to` spelling rule (where the same spelling exits 2 as malformed, T11.3-3) to move operands; section form whose `` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; lacking `.mdx`; and the derived-path arm of `refused-invalid-destination` (6.5: a workspace-relative directory component of a derived path the destination would generate — 13.1, 13.2, 7.3 — occupied by a non-directory): with emission enabled under `markdown.outDir`, a file-form move whose destination `new/b.mdx` is otherwise valid, its own directory components unobstructed (`new/` absent — a nonexistent component is never a refusal cause, 13.4, T13.4-8), but whose emit destination `/new/b.md` has its component `/new` occupied by a plain file lying under no current source's write path (so the workspace passes `build`'s validations) — refused `refused-invalid-destination`, never 14.22 (14, T14-7), discriminating a product that vets only the destination path's own components; the module and companion paths share the destination's directory (13.1), so the `outDir` emit destination is the separable derived-path fixture. The 6.5 destination clauses "containing `#`" and "not valid UTF-8" admit no staging (dead letters, recorded here as T6.5-6 records its unstageable clauses): a destination path exists only as an operand spelling, and every spelling that would present either is a usage error before any refusal is evaluated — a non-UTF-8 argument value exits 2 (12.0, T12.0-5), so a valid operand never denotes a non-UTF-8 path; a `#`-containing file-form destination classifies as a `#` pair, a mixed-synopsis exit 2 (T6.5-5); and a `#` in the section form's target-file part makes a two-`#` operand, the malformed value T12.0-13 pins as exit 2 on `move`. Plus the valid-workspace precondition as T6.4-6. +* **T6.5-5 Usage errors (exit 2).** Nonexistent origin file or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y`, `a.mdx#x b.mdx`, and `a.mdx b#c.mdx` — the last a `#`-containing file-form destination, classified as a pair — match neither form, exit 2, and an operand containing `#` is always a `#` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). A non-UTF-8 destination operand (raw bytes in the OS argument vector, Linux leg) is a usage-error argument value, exit 2 (12.0, T12.0-5). These exit-2 invocations are the stagings T6.5-4's dead-letter note sets aside. * **T6.5-6 Identity terms.** The new-identity checks read in identity terms (6.5): a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid; the exact self-move — `#` equal to `#` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. So is the mirrored "structural parent rules remain satisfied" check, in both forms: the file form changes no ID and no within-file nesting; in the section form the target parent is located as the target file's section bearing `` minus its final segment — absent, or lying within the moved subtree, it is `refused-missing-target-parent` (T6.5-4) — prefix replacement preserves the subtree's relative nesting, a single-segment `` inserts at top level (exactly one segment, 1.3), and the origin's removal disturbs no remaining ID; on a workspace passing the valid-workspace precondition, 1.3 therefore holds by construction after every non-refused move, the check is exercised as the always-passing side of every successful move (T6.5-1/2/3), and `refused-structural-parent` is staged through rename alone (T6.4-3, T14-7). * **T6.5-7 Operation-side rewrite bytes.** The real move's import-edit extents and reference-conversion spellings, byte-asserted against independently composed expected files — staged so no import is added, the one rewrite direction free of implementation latitude (6.5: identifier choice and insertion offset attach to added imports alone). Fixture: the origin file imports the target file under two bindings (valid, 2.1), one declaration alone on its line, the other following a retained, still-referenced third-module import on a shared line; every reference through the two bindings — a `d` chain and a `text(...)` embedding — lies inside the moved subtree, which also holds a single-quoted local string reference to a moved descendant; no reference to a moved node lies outside the subtree. After the section move into the target file: both target-file imports are left unreferenced and removed with 6.5's exact extent — the own-line declaration's line dropped with its terminator (3), the shared-line declaration's own characters alone deleted, the retained import kept byte-for-byte on its kept line; the moved references convert to local form in 6.4's pinned spelling for converted references, double-quoted string literals; the local reference is re-identified by prefix replacement with its single-quote spelling preserved (6.4: minimal in-place edits); and the rewritten origin and target files are each asserted byte-equal to expected bytes composed from the rules of 6.5 and 3. This is the operation-side assertion T6.5-2 sets aside (its no-other-byte-changes check excludes the identity and reference rewrites) and T6.6-4 makes only of the preview's report — failing a product that emits a spec-perfect preview while the real edit leaves an emptied import line behind, normalizes whitespace around a removed declaration, or spells a converted reference single-quoted. @@ -514,7 +514,7 @@ Assertions here — and wherever these forms appear across the suite — are for * **T13.3-1 Serving reads.** After `build`, the read commands (`check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, `at`) answer without error; graph data lives under `.xspec/`. * **T13.3-2 Refresh.** Delete the graph data — operationally, here and in T13.4-3: every path under `.xspec/` except the durable `.xspec/journal` and `.xspec/reviews/` (13.4); graph-data content is opaque (H-4), so tests only ever remove or compare it whole — (or edit a source) and run each of `ids`, `show`, `coverage`, `impact`, `review status`, `query`, `occurrences`, `view`, `at`: the answer reflects current sources (never stale data), graph data is rewritten as `build` would write it, but no TypeScript or Markdown is generated or removed and recorded derived-file paths are unchanged (a stale generated module stays stale — `check` still reports 14.10 afterwards). Record discipline (13.3): with the record corrupted shape-blind instead (T6.6-6's staging), each refreshing read answers finding-free — exit 0 on the otherwise clean workspace — reporting nothing for the record and leaving the corrupt state neither read, repaired, nor replaced: `inventory` afterwards still reports `recorded` unavailable (T11.6-4), until a successful `build` or a finishing regeneration (6.4) replaces the state. -* **T13.3-3 Failed refresh.** With invalid sources, each read command reports the validation errors, exits 1, answers nothing, and modifies nothing (derived files and graph data byte-identical). The mutating `review` subcommands observe the same rule — 13.3 binds `review` whole, and `create`, `resolve`, and `split` all consult the current graph: on a workspace holding a session, a configured coverage profile, and a resolvable commit from when its sources were valid, a source is then edited to fail validation, and `review create` under each of `--base` (the resolvable ref — baseline resolution precedes source validation, 12.0, so the refresh failure is the operative error), `--strategy audit`, and `--coverage`, plus `resolve` and `split` naming the pre-existing session and an unblocked item, each reports the validation errors, exits 1, and modifies nothing: no session file is created, and the existing session file, journal, derived files, and graph data are byte-identical (no status recorded, no decomposition). The gate is over every finding a `build` would report, source validity or not (13.3: source validation errors, journal errors, and refused writes alike): on an otherwise-valid workspace with a garbage journal line staged, and separately with an obstructed write path staged (T11.2-6's two fixtures), each of `ids`, `show`, `coverage`, `impact`, `review status`, and `query` reports that finding — the journal error (14.13) naming the line, the refused write (14.22) its offending component — exits 1, answers nothing, and modifies nothing (journal, sessions, derived files, and graph data byte-identical), discriminating a product that gates on source validity alone and answers `query` from a broken journal with exit 0 (refresh consumes the journal for canonical identities, 5.4). The never-gated contrast: on the same failing workspaces `occurrences`, `view`, and `at` answer per file under 11.2 (T11.2-1, T11.2-6) and `inventory` answers whatever the sources' validity (T11.6-4) — none of them modifying anything. +* **T13.3-3 Failed refresh.** With invalid sources, each gated read command (`ids`, `show`, `coverage`, `impact`, `review`, `query`) reports the validation errors, exits 1, answers nothing, and modifies nothing (derived files and graph data byte-identical). The mutating `review` subcommands observe the same rule — 13.3 binds `review` whole, and `create`, `resolve`, and `split` all consult the current graph: on a workspace holding a session, a configured coverage profile, and a resolvable commit from when its sources were valid, a source is then edited to fail validation, and `review create` under each of `--base` (the resolvable ref — baseline resolution precedes source validation, 12.0, so the refresh failure is the operative error), `--strategy audit`, and `--coverage`, plus `resolve` and `split` naming the pre-existing session and an unblocked item, each reports the validation errors, exits 1, and modifies nothing: no session file is created, and the existing session file, journal, derived files, and graph data are byte-identical (no status recorded, no decomposition). The gate is over every finding a `build` would report, source validity or not (13.3: source validation errors, journal errors, and refused writes alike): on an otherwise-valid workspace with a garbage journal line staged, and separately with an obstructed write path staged (T11.2-6's two fixtures), each of `ids`, `show`, `coverage`, `impact`, `review status`, and `query` reports that finding — the journal error (14.13) naming the line, the refused write (14.22) its offending component — exits 1, answers nothing, and modifies nothing (journal, sessions, derived files, and graph data byte-identical), discriminating a product that gates on source validity alone and answers `query` from a broken journal with exit 0 (refresh consumes the journal for canonical identities, 5.4). The never-gated contrast: on the same failing workspaces `occurrences`, `view`, and `at` answer per file under 11.2 (T11.2-1, T11.2-6) and `inventory` answers whatever the sources' validity (T11.6-4) — none of them modifying anything. * **T13.3-4 Determinism.** Graph data files are byte-deterministic across rebuilds of an identical workspace (content otherwise unasserted, H-4). ### 13.4 Derived and durable files @@ -532,7 +532,7 @@ Assertions here — and wherever these forms appear across the suite — are for All mutual-exclusion tests use the `--test-hold ` seam for determinism. -* **T13.5-1 Hold seam basics.** A mutating command (`rename`, `move`, `review create/resolve/split`) with `--test-hold`: creates an empty file at the path after acquiring exclusivity and before modifying anything (workspace byte-identical while held), proceeds only once the file is deleted, then completes normally. If anything exists at the hold path — a file, directory, or symbolic link — the command fails exit 2 without modifying anything. A non-mutating command (`build` and `query` as representatives) given `--test-hold` fails exit 2 as an unknown flag: 13.5 grants the seam to mutating commands alone, and unknown flags are usage errors (12.0). +* **T13.5-1 Hold seam basics.** A mutating command (`rename`, `move`, `review create/resolve/split`) with `--test-hold`: creates an empty file at the path after acquiring exclusivity and before modifying anything (workspace byte-identical while held), proceeds only once the file is deleted, then completes normally. Seam neutrality (13.5: the seam changes no other behavior): the final workspace state of a held-then-released run — sources, journal, sessions, derived files, and graph data — is byte-identical to the same operation run without `--test-hold` on an identical twin workspace (the hold path outside the workspace; a product-to-itself comparison under H-4, well-defined across directories per H-6). If anything exists at the hold path — a file, directory, or symbolic link — the command fails exit 2 without modifying anything. A non-mutating command (`build` and `query` as representatives) given `--test-hold` fails exit 2 as an unknown flag: 13.5 grants the seam to mutating commands alone, and unknown flags are usage errors (12.0). * **T13.5-2 Mutual exclusion.** While command 1 is held, each other mutating command fails promptly with exit 2 and modifies nothing (journal, sessions, sources byte-identical); after command 1 completes, the second command succeeds. * **T13.5-3 Exclusivity ends with the process.** Kill a held mutating command; a subsequent mutating command succeeds (a terminated holder never blocks). * **T13.5-4 Readers during mutation.** While a mutating command is held, read commands still run and observe the prior state; non-mutating commands run concurrently with each other (parallel `build`/`query` storm on one workspace terminates, and any derived-file inconsistency is resolved by one final `build` — byte-equal to a clean build). From d5e47a62fdbdc82f74541809eefd887855380fa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:53:58 +0000 Subject: [PATCH 040/226] sdg(phase-6): assert @1 unknown; record the ignored-order dead pair and the sorted-keys/JSON pins (iter 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration 10 (fresh full review): no Critical or Important items; all three Optional items applied, none rejected. - O1: T13.4-1 and T10.1-1 now record their interpretive pins as such — 13.4's unqualified "sorted" pinned to byte order (the sole string order SPEC.md defines, 12.0), and the session file's JSON parseability pinned from the .json name, 10.1/14.21 parse language, and 13.4 sorted keys. Assertions unchanged; the reviewer's alternative softening not taken. - O2: applied by strengthening rather than noting — T11-6 gains an #@1 arm asserting exit 2: 4.6 suffixes only occurrences after the first, so no occurrence bears @1, and 12.0 (byte-wise identities, unknown-identity class, exit partition) forces the outcome under every supported reading; no SPEC clarifying line needed, so no SPEC problem logged. - O3: T8.2-1's coverage claim made exact — the root-node vs coverage="none" pair of 8.2's fixed order is unobservable on any node (roots carry no coverage attribute, 5.5), recorded per the document's unstageable-clause convention. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index 40b7561..a4bd4cc 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -313,7 +313,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T8-3 Boundaries.** A spec-group boundary (spec→spec edges) and a code-group boundary (marker/`text` edges from code) each grant coverage (`boundaryKind` both inferred and explicit). * **T8-4 Boundary∩target overlap.** One file belongs to both the target and the boundary spec group (T7.1-1). A required node of that file with no incoming dependency edge is itself a boundary node yet MUST be reported uncovered, in `direct` and in `transitive` mode: coverage needs a path of one or more edges from a boundary node to the target (8), and boundary membership alone is no such path. A sibling required node of the same file with a single incoming `depends` edge from another node of the file (itself a boundary node) is covered in both modes. * **T8-5 Root path exclusion.** Spec groups `base` (file A) and `derived` (file B); A holds a top-level `{text(B.b1)}` outside any section — a root-sourced `embeds` edge A-root → `b1` (2.3) — and a section `a1` with `d={B}` — a root-targeted `depends` edge `a1` → B-root (2.2); B holds a top-level `{text("b2")}` — B-root → `b2`. Profiles target `derived` with boundary `base`, one `direct` and one `transitive`. Assertions (8): `b1` is uncovered in both modes — a spec-group boundary contributes only its non-root nodes as boundary nodes, and a root-sourced edge never extends a covering path; `b2` is uncovered in `transitive` mode although `a1` → B-root → `b2` is a chain of dependency edges — a root is never an intermediate, and neither the root-targeted nor the root-sourced edge extends a covering path. Coverage-scoped exclusion (8): the same edges remain ordinary dependency edges — a `forbidden` rule from `base` to `derived` reports both the A-root → `b1` and the `a1` → B-root edge (7.5); editing `b1`'s text changes A-root's effectiveHash through the root-sourced dependency pair — `b1` is no child of A-root, so containment cannot explain it — and changes B-root's effectiveHash through containment, hence `a1`'s through the root-targeted pair: A-root and `a1` are both `upstream-changed` (5.5); `query edges` reports both edges (11; with T2.2-2). One workspace asserting: group restriction; `targetTags` restriction; `"leaves"` vs `"all"`; `coverage="none"` exclusion; root exclusion. -* **T8.2-1 Report.** All profiles run by default; `coverage ` runs one; counts of required/covered/uncovered/ignored; identity of every covered, uncovered, and ignored node; one shortest covering path per covered node with the 12.0 tie-break (fixture with two equal-length paths asserts the byte-least one); ignored nodes report all applicable reasons in the fixed order (a node that is simultaneously `coverage="none"`, non-leaf, and untagged; and a root with children in a profile with `targets: "leaves"` and `targetTags` — reasons `root node`, non-leaf, and lacking every tag, pinning the `root node` reason's position in the fixed order); `--check` exits 1 iff any required node is uncovered (0 otherwise); `--json` carries the same information (adapter-asserted equality of information). +* **T8.2-1 Report.** All profiles run by default; `coverage ` runs one; counts of required/covered/uncovered/ignored; identity of every covered, uncovered, and ignored node; one shortest covering path per covered node with the 12.0 tie-break (fixture with two equal-length paths asserts the byte-least one); ignored nodes report all applicable reasons in the fixed order (a node that is simultaneously `coverage="none"`, non-leaf, and untagged; and a root with children in a profile with `targets: "leaves"` and `targetTags` — reasons `root node`, non-leaf, and lacking every tag, pinning the `root node` reason's position against those two; the fixed order's remaining pair, `root node` relative to `coverage="none"`, is unobservable on any node — reasons are reported per node, and no node bears both, a root carrying no coverage attribute (5.5) — recorded here as T6.5-6 records its unstageable clauses); `--check` exits 1 iff any required node is uncovered (0 otherwise); `--json` carries the same information (adapter-asserted equality of information). ## 9. Impact Analysis @@ -332,7 +332,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ### 10.1 Sessions -* **T10.1-1 Storage.** On a freshly built workspace, `review create` writes exactly `.xspec/reviews/.json` and nothing else (on a stale workspace it additionally performs the 13.3 graph-data refresh); the file is plain, deterministic (two identical fixtures → identical bytes), and parseable as a single JSON document. +* **T10.1-1 Storage.** On a freshly built workspace, `review create` writes exactly `.xspec/reviews/.json` and nothing else (on a stale workspace it additionally performs the 13.3 graph-data refresh); the file is plain, deterministic (two identical fixtures → identical bytes), and parseable as a single JSON document — an interpretive pin, recorded as such (T13.4-1's byte order is its sibling): the `.json` name 10.1 mandates, its parse language (a session that cannot be parsed is corrupt, 14.21), and the sorted keys of 13.4 support no other reading. * **T10.1-2 Names.** Valid: letters, digits, `.`, `_`, `-`, not beginning with `.`. Invalid names (`/`, space, empty, leading `.`, non-ASCII) → usage error, exit 2, nothing created. Names are case-sensitive for all subcommands (`status Foo` does not find `foo` → exit 2 — a single-casing probe rerun on the Windows leg, E-6, where a case-insensitive filesystem exposes a product matching session names via filesystem lookup), but `create` refuses a name matching an existing session ignoring ASCII case (exit 1, refused operation per 10.7/12.0). * **T10.1-3 Non-session files.** A stray file `.xspec/reviews/notes.txt` and a subdirectory are ignored by `list`, `check`, and every subcommand. The valid-name qualifier of 10.1 discriminates: a garbage-content `.json` file whose stem is an invalid session name — `.foo.json` (leading `.`) and `a b.json` (whitespace) — and a wrong-case extension `NAME.JSON` (paths compare byte-wise, 12.0) are not sessions: `list` reports them neither as sessions nor as corrupt and exits 0, `check` reports no 14.21, and naming them finds no session (`status` on `.foo` or `a b` → exit 2 invalid name; on `NAME` → exit 2 unknown session — the `NAME.JSON` probe stages a single casing and reruns on the Windows leg, E-6). * **T10.1-4 Corruption.** Each corrupt state → every `review` subcommand naming the session reports corruption, exits 1, modifies nothing; `list` reports the session corrupt in place of its fields and exits 1; `check` reports 14.21: unparseable JSON; missing 10.2 field; unknown status; duplicate item `id`s; `blockedBy` naming an absent item; a `blockedBy` cycle; two items with same kind and scope node; malformed recorded creation parameters or decompositions; a session path that is a directory or symlink (13.4). Staging is blackbox: SPEC.md leaves the session file's concrete shape opaque, so every shape-dependent corrupt fixture starts from a session file the product itself wrote and is corrupted through the H-3 adapter layer (shape-aware, value-blind: duplicating an item entry, rewriting a status to an unknown value, redirecting `blockedBy` into a cycle or at an absent id, deleting a field, garbling recorded parameters); shape-independent states (unparseable bytes, truncation, a directory or symlink at the path) are staged directly. The harness never writes a session file from an assumed layout. @@ -399,7 +399,7 @@ SPEC.md 11's five commands are JSON-only: for `query`, `occurrences`, `view`, `a * **T11-3 subtree/ancestors.** `subtree` returns the node plus descendants in document order (root query returns the whole file); `ancestors` returns proper ancestors nearest-first ending at the file root, excluding the queried node (empty for a root); rows carry the row fields of T11-2 — identity, source range, tags, coverage attribute (11: one row contract for `nodes`, `subtree`, and `ancestors`) — asserted on `subtree` and `ancestors` rows including a tagged `coverage="none"` node and a root (attribute absent), so a product omitting a row field from either subcommand fails. * **T11-4 edges.** `--from`/`--to` accept requirement nodes and code locations; `--kinds` filters over all four kinds and defaults to no filter (contains edges included); comma-separated list form; unknown kind value → exit 2. * **T11-5 reachable.** Reports existence of a dependency path under the given kinds (default: all three dependency kinds, never `contains`) and one shortest witness path with the 12.0 tie-break (two-equal-paths fixture); equal `--from` and `--to` (a node bearing both incoming and outgoing dependency edges) report that no path exists — a zero-length path is not a path (11); `--kinds contains` → exit 2 (invalid flag value: `reachable` accepts only the three dependency kinds, 11 — `edges` accepts all four, T11-4). -* **T11-6 Identity resolution.** Bare `path` resolves to a root node for a spec-group file and to a code location for a code-group file; `path#unit` and `path#unit@N` address code locations; a path in no configured group → exit 2 (unknown, 12.0). Wrong-kind operands (12.0): `query node` and `show` (12.4) given a code-group `path` or `path#unit` — a code source named where a requirement-node identity is required — each exit 2. Unknown code units (12.0: an unknown node identity named in arguments, the check judged parse-local over the named file's named units, 4.6): on a discovered code source, `query edges --from #` → exit 2 — likewise `edges --to` and `reachable --from`/`--to` given the same identity — and an out-of-range disambiguator, `#@2` where the chain occurs once in the file (4.6), is equally unknown, exit 2: never a bare edgeless graph node with an empty answer, exit 0 (the failing-workspace arm: T12.0-10). +* **T11-6 Identity resolution.** Bare `path` resolves to a root node for a spec-group file and to a code location for a code-group file; `path#unit` and `path#unit@N` address code locations; a path in no configured group → exit 2 (unknown, 12.0). Wrong-kind operands (12.0): `query node` and `show` (12.4) given a code-group `path` or `path#unit` — a code source named where a requirement-node identity is required — each exit 2. Unknown code units (12.0: an unknown node identity named in arguments, the check judged parse-local over the named file's named units, 4.6): on a discovered code source, `query edges --from #` → exit 2 — likewise `edges --to` and `reachable --from`/`--to` given the same identity — and an out-of-range disambiguator, `#@2` where the chain occurs once in the file (4.6), is equally unknown, exit 2 — as is `#@1` at every occurrence count: 4.6 suffixes only occurrences after the first, so no occurrence bears `@1` — the first occurrence's identity is the bare `path#unit`, and identities compare byte-wise (12.0) — staged where the chain occurs once and where it occurs twice, the two-occurrence arm discriminating a product that resolves `@1` to the first occurrence: never a bare edgeless graph node with an empty answer, exit 0, and never a resolved answer (the failing-workspace arm: T12.0-10). * **T11-7 Ordering.** Every result list is deterministic: repeated runs byte-identical; content-identical workspaces in different directories produce identical output (H-6). ### 11.2 Availability on imperfect files @@ -519,7 +519,7 @@ Assertions here — and wherever these forms appear across the suite — are for ### 13.4 Derived and durable files -* **T13.4-1 Plain committable files.** Every file xspec writes is a plain file; writing the workspace into git and back (commit, clean checkout) round-trips builds and reads. Sorted keys (13.4): every JSON object in a product-written session file — read after `create` and again after a `resolve` rewrites it (10.4) — has its keys in byte-sorted order (13.4, the byte order per 12.0), asserted shape- and value-blind over whatever objects and keys are present (H-3: no shape or values pinned). The session file is the one written class where the clause is discriminable: journal entry content (6.1) and graph data (13.3) are opaque, and generated TypeScript and Markdown carry no keyed serialization; the sibling stable-ordering clause is covered by the determinism protocol (H-6, T10.1-1, T12.0-7, T13.3-4). +* **T13.4-1 Plain committable files.** Every file xspec writes is a plain file; writing the workspace into git and back (commit, clean checkout) round-trips builds and reads. Sorted keys (13.4): every JSON object in a product-written session file — read after `create` and again after a `resolve` rewrites it (10.4) — has its keys in byte-sorted order — 13.4 leaves "sorted" unqualified; the assertion pins it, recorded as an interpretive pin, to byte order as the sole string order SPEC.md defines (12.0), so a product sorting keys under any other deterministic collation fails by intent — asserted shape- and value-blind over whatever objects and keys are present (H-3: no shape or values pinned). The session file is the one written class where the clause is discriminable: journal entry content (6.1) and graph data (13.3) are opaque, and generated TypeScript and Markdown carry no keyed serialization; the sibling stable-ordering clause is covered by the determinism protocol (H-6, T10.1-1, T12.0-7, T13.3-4). * **T13.4-2 Derived reproducibility.** Delete, truncate, and garbage-overwrite each class of derived file (module, companion, Markdown, graph data): `build` restores all byte-exactly. * **T13.4-3 Orphan knowledge boundary.** Build so a derived file exists and is recorded; delete all graph data (T13.3-2's operational definition), taking the recorded derived-file paths with it; change the configuration so that file is no longer generated; `build`: the orphaned file is outside xspec's knowledge and is not removed; it may be deleted manually (asserted: subsequent builds leave the stray file alone). * **T13.4-4 Derived paths belong to xspec.** A user-created file at a derived path is replaced by `build`; a symbolic link at a derived file's own path is replaced as the occupant — nothing is written through it (link target byte-identical after build, link gone, plain file present; not an error). From 1eaeaf213c2f38cb1a5341e1cd5529a788803dde Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 20:02:22 +0000 Subject: [PATCH 041/226] sdg(phase-7): add CONF-AVAIL datum-form violators; refresh CONF-VALID/CONF-MD scopes and exclusions for the 0001 surfaces Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/CERTIFICATIONS.md | 43 +++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/specs/CERTIFICATIONS.md b/specs/CERTIFICATIONS.md index ebc2e76..28e1cb1 100644 --- a/specs/CERTIFICATIONS.md +++ b/specs/CERTIFICATIONS.md @@ -2,7 +2,7 @@ This document specifies the fixture products that certify selected tests of `specs/TEST-SPEC.md` under the certification protocol of TEST-SPEC.md §17 (C-1, C-2). A **conformer** conforms to `specs/SPEC.md` within its stated scope, with the simplest behavior that does so. A **violator** is its conformer with exactly one specified behavioral deviation. A test is **certified** when it passes against the conformer and fails against each violator that targets it. Fixtures are implemented as part of the test harness and are driven through the identical blackbox surfaces as the product (C-2: an executable/workspace binding and nothing else); this document describes them only in terms of SPEC.md's interfaces, contracts, seams, and observability features and prescribes no implementation details. -Selection is deliberately incomplete (PROCESS.md). A fixture exists here only where a vacuous pass is an elevated risk: negative and absence-of-effect tests whose staging or byte-compare wiring could silently miss the behavior under test, temporal behavior, tests routed through the `--test-hold` seam (SPEC.md 13.5), and the reachability of property tests' generated inputs. No Bug Report exists, so no fixture is justified empirically. Every test not named in an in-scope set below is deliberately uncertified (see Exclusions). There are no spec modules, so there are no module certification files. +Selection is deliberately incomplete (PROCESS.md). A fixture exists here only where a vacuous pass is an elevated risk: negative and absence-of-effect tests whose staging, byte-compare, or form-decode wiring could silently miss the behavior under test, temporal behavior, tests routed through the `--test-hold` seam (SPEC.md 13.5), and the reachability of property tests' generated inputs. No Bug Report exists, so no fixture is justified empirically. Every test not named in an in-scope set below is deliberately uncertified (see Exclusions). There are no spec modules, so there are no module certification files. Each conformer entry states its **scope** — the SPEC.md behaviors and command surface it implements and the workspace shapes it accepts — and its **in-scope tests**: the named subset (C-1) the certification runner executes against the conformer and each of its violators. Every in-scope test passes against the conformer. Each violator entry states its scope (its conformer's), its single deviation, the tests it certifies, and its expected failures: exactly the certified tests fail against it, and every other in-scope test passes. Where TEST-SPEC.md leaves an in-scope test's fixture content open and an expected-failure set — or the conformer's ability to pass within scope — depends on the choice, the entry states that choice as a **staging constraint** — a condition certification imposes on the harness's fixture for the named test, binding alongside C-1. @@ -48,7 +48,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command * **Deviation:** `build` and the read commands modify the journal: each such invocation that is not refused as a usage or configuration error (exit 2) appends one fixed line to `.xspec/journal`, creating the file when absent. Mutating commands, and the entries `rename`/`move` append, are unchanged. * **Certifies:** T6.1-1, T13.4-5. * **Expected failures:** exactly T6.1-1 (a journal file exists after `build` in a fresh workspace; the byte-compares around `build`, `check`, `coverage`, and `query` observe modification — in this git-less scope the `impact` invocation is refused exit 2 and appends nothing, and `review`'s compare observes modification only where the read subcommand chosen exits 0, as `review list` does) and T13.4-5 (its journal byte-compares under `build` and read commands fail). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched; the 13.5 tests assert hold, exclusion, and derived-file behavior, not journal bytes, and T13.5-2's byte-compare covers the refused mutating command, which appends nothing. -* **Note:** the representative value of this violator for the suite's other never-modifies assertions (T12.0-11, T12.1-4, T13.3-3, T6.4-3, T6.5-4) holds insofar as those assertions share the compare-around-command machinery certified here; the Exclusions lean on exactly that condition. +* **Note:** the representative value of this violator for the suite's other never-modifies assertions (T12.0-11, T12.1-4, T13.3-3, T6.4-3, T6.5-4, T6.6-2, and the answer-side compares of T11.2-1/T11.2-6/T11.6-4) holds insofar as those assertions share the compare-around-command machinery certified here; the Exclusions lean on exactly that condition. ### VIOL-CORE-PERSISTREADS @@ -59,7 +59,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ## CONF-VALID — segment and tag validity -**Scope.** Workspaces with one configured spec group of one or more `.mdx` sources whose sections carry `id` and `tags` props (multi-file: T1.3-5's cross-file duplicate-ID arm builds); no imports, embeddings, `d` props, code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `build` with the error reporting of 14 for conditions 14.1–14.4 (file, location, condition identity, 14.2's statement of the expected form, exit codes per 12.0) and `query node`/`query nodes` reporting identity, tags, and metadataHash. Contracts under certification: 1.3, 1.4 (with the exact character classes of SPEC.md 1.4), 2.6 tag splitting, and the masking rule of 14.2. +**Scope.** Workspaces with one configured spec group of one or more `.mdx` sources whose sections carry `id` and `tags` props (multi-file: T1.3-5's cross-file duplicate-ID arm builds); no imports, embeddings, `d` props, code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `build` with the error reporting of 14 for conditions 14.1–14.4 — and 14.17 as T1.3-6's invalid-form arms stage it — (file, location, condition identity with its stable code where the report form carries one — 14, 12.7 — 14.2's statement of the expected form, exit codes per 12.0) and `query node`/`query nodes` reporting identity, tags, and metadataHash. Contracts under certification: 1.3, 1.4 (with the exact character classes of SPEC.md 1.4), 2.6 tag splitting, and the masking rules of 14.1 and 14.17 over 14.2. **In-scope tests:** T1.3-1, T1.3-2, T1.3-3, T1.3-4, T1.3-5, T1.3-6, T1.4-1, T1.4-2, T1.4-4, T2.6-1, T2.6-2, P-1. @@ -81,7 +81,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ## CONF-MD — Markdown compilation -**Scope.** Spec-group workspaces of `.mdx` sources with imports (2.1, valid forms as staged), same-file and cross-file `text(...)` embeddings (2.3), MDX comments, mixed line terminators, and sections carrying the full prop set of 2.7 — `id`, `d` (local or external form, resolving as staged; 2.2), `coverage`, and `tags` (2.5, 2.6) — as T3-1's all-props removals stage them; `markdown` absent, `{ emit: false }`, and `{ emit: true }` with default emission next to each source (13.2); no code groups, no `coverage` or `policy` configuration keys, no git. Command surface: `build` with byte-exact Markdown output per 3, and `query node` reporting own and subtree text (1.6, defined through the rules of 3). Contracts under certification: 3 in full — removal, replacement, the line-drop rule, line terminators — and the emission scope of 7.3. +**Scope.** Spec-group workspaces of `.mdx` sources with imports (2.1, valid forms as staged), same-file and cross-file `text(...)` embeddings (2.3), MDX comments, mixed line terminators, fenced code blocks and inline code spans carrying construct-like bytes (T3-1's grammar boundary), and sections carrying the full prop set of 2.7 — `id`, `d` (local or external form, resolving as staged; 2.2), `coverage`, and `tags` (2.5, 2.6) — as T3-1's all-props removals stage them; `markdown` absent, `{ emit: false }`, and `{ emit: true }` with default emission next to each source (13.2); no code groups, no `coverage` or `policy` configuration keys, no git. Command surface: `build` with byte-exact Markdown output per 3; `query node` reporting own and subtree text (1.6, defined through the rules of 3); and, for T3-1's grammar-boundary arm, `check` exiting 0 and `query nodes`/`query edges` reporting no node and no edge for the construct-like bytes inside fences and code spans (constructs exist only where the MDX parse yields them). Contracts under certification: 3 in full — removal, replacement, the line-drop rule, line terminators, the parse-not-pattern grammar boundary — and the emission scope of 7.3. **In-scope tests:** T3-1, T3-2, T3-3, T3-4, T3-5, T3-6, P-2, P-3. @@ -130,14 +130,41 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command * **Certifies:** T7-6. * **Expected failures:** exactly T7-6 (its exclusion arms: a staged `.xspec.`-named `.mdx` file matched by a glob enters the discovered set; a file under `.xspec/` is discovered where a pattern spells the dot segment literally; with emission enabled, a glob-matched file at a source's destination is discovered or, lacking `.mdx`, reported as 14.19 — each observably failing the arm's no-error non-discovery assertion; the import and zero-source arms are untouched, and the test fails on the exclusion arms alone). T7-4 and T7-5 pass under a staging constraint: their fixtures stage no `.xspec.`-bearing names, write no pattern naming `.xspec/`, and leave `markdown` absent — and their wildcard patterns cannot reach the conformer's own graph data past the unchanged dot-segment rule. +## CONF-AVAIL — availability answers and JSON datum forms + +**Scope.** Workspaces of configured spec groups of `.mdx` sources at valid-UTF-8, `#`-free workspace-relative paths — imports (2.1), `d` props, and `{text(...)}` embeddings as the in-scope fixtures stage them; no code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `view`, with and without `--text` (operand and `--file` forms as staged), and `occurrences` with `--file` and `--to`, each answering in the form-exact 12.7 document forms with the availability rules of 11.2 — parse-local structure and positional trees (a section inside an invalid non-section element parenting per 11.4), spelled-identity definedness, interpreted tags and coverage, resolution through defined identities, occurrence records per 5.7/11.3 with `source` withheld as one datum where undefined, whole-value expansion poisoning with own and subtree text defined through the rules of 3 (1.6; emission out of scope), removal classification by syntactic form, and the raw attribute and import data of 11.4 — findings accompanying per 11.2/14 with stable codes and located ranges for the staged conditions (14.1, 14.3, 14.4, 14.5, 14.6, 14.9, 14.15, 14.16, 14.17), and the exit discipline of 11.2 (any finding or explicitly-unavailable datum → exit 1 with the full answer emitted; complete and finding-free → exit 0). A staging constraint: T11.4-1's fixtures spell every identity uniquely and validly and stage no undefined datum — its invalid-element arm (14.16) keeps every spelled identity defined — so its answers carry the unavailability marker nowhere. + +**In-scope tests:** T11.2-2, T11.2-4, T11.3-4, T11.4-1, T11.4-3, T11.4-4. + +**Justification.** The availability answers are the suite's densest negative-observation surface — no-winner identity undefinedness, never-a-picked-bearer and never-a-dropped-record occurrence sources, whole-value poisoning with partial expansion forbidden, and T11.3-4's definitive emptiness — and every one of those observations rides a three-state datum decode (plain value, the stated `null`, `{"unavailable": true}`; 12.7) that H-3 requires be asserted form-exact with no adapter in the path. Nothing self-tests that decode's distinctions: S-5 covers adapters, which these surfaces bypass, and T12.7-1's S-5-guarded structural walk checks the marker's own shape but cannot see a marker replaced by `null` or a `null` member silently dropped — exactly the collapse a defaulting JSON decoder makes, and such a harness defect passes conforming and deviating products alike, forever: criterion (a)'s vacuous-pass class with no other red-green check. The two violators certify that the harness's decode actually separates the three states; T11.4-1 and T11.3-4 stage marker-free and `null`-free answers respectively and anchor the violators' passing sides. + +### VIOL-AVAIL-NULLMARKER + +* **Scope:** CONF-AVAIL. +* **Deviation:** The unavailability marker is never emitted: every datum the rules of 11.2 leave undefined is carried as `null` in place of `{"unavailable": true}` (12.7). Which data are undefined, all defined values, findings, exit codes, and every other document member are unchanged. +* **Certifies:** T11.2-2, T11.2-4, T11.4-3, T11.4-4. +* **Expected failures:** exactly the certified four, each asserting the marker literally on a staged undefined datum (form-exact, H-3/12.7): T11.2-2 (the identity-datum matrix — every explicitly-unavailable arm reads `null`), T11.2-4 (the occurrence records' `source` and the poisoned own/subtree text values under `view --text`), T11.4-3 (the per-node identity, tags, and coverage unavailability arms), T11.4-4 (the unresolved import targets — `./typo.xspec`, the bare specifier). T11.4-1 passes under its staging constraint: its fixtures stage no undefined datum, so no document it decodes carries the deviation (a root's `tags`/`coverage` are the stated `null` on either side, untouched). T11.3-4 passes: a valid workspace defines every datum, and its empty enumerations are unchanged. + +### VIOL-AVAIL-OMIT + +* **Scope:** CONF-AVAIL. +* **Deviation:** `null`-valued members are omitted: every member whose value an answer would carry as the stated `null` (12.7) is absent from the emitted document — a viewed root's `tags` and `coverage` (T11.4-3, 12.7) and a located finding's `path` (12.7) among them. Members with plain, marker, or list values, which findings exist, and exit codes are unchanged. +* **Certifies:** T11.2-2, T11.2-4, T11.4-1, T11.4-3, T11.4-4. +* **Expected failures:** exactly the certified five — every in-scope test that decodes a `view` answer: each viewed file's root node carries the stated-`null` `tags` and `coverage`, omitted under this deviation, and 12.7's member-presence contract (`null` is never omission) is asserted literally wherever the forms appear (H-3, T12.7-2), so each such decode fails on the missing members — T11.4-3's root arm asserts the distinction directly, and T11.2-4's `occurrences` answers additionally fail through their located findings' omitted `path` members. T11.3-4 passes: its two answers are empty enumerations — `[]` is not `null` (12.7) — carrying no finding and no `null`-valued member to omit. + ## Exclusions Considered against the selection criteria and deliberately left uncertified; each may be revisited under criterion (b) on an empirically demonstrated miss. -* **P-4, P-5, P-6** (hash laws, rename/move purity, baseline replay): a conformer passing their anchor tests requires substantially the whole graph, identity, and baseline engine — a near-complete second product — while the anchors themselves are positive, byte-asserted fixtures whose failure modes are loud, not vacuous. +* **P-4, P-5, P-6, P-13** (hash laws, rename/move purity and section-move categories, baseline replay, coverage): a conformer passing their anchor tests requires substantially the whole graph, identity, baseline, or coverage engine — a near-complete second product — while the anchors themselves are positive, byte-asserted fixtures whose failure modes are loud, not vacuous, and the oracles among them (P-5, P-6, P-13) are vetted by S-6's fixed vector suites drawn from SPEC.md's worked material. * **P-7**: its capture half requires policy machinery out of any lean scope; the glob half's staging hazard is certified through CONF-DISC on T7-4. * **P-8, P-9, P-10**: P-8 sweeps every command, exceeding any narrow conformer scope; P-9 asserts consistency invariants anchored by the deterministic 10.x fixtures; P-10's single-mutator schedules and kill accounting admit no deviation with an unambiguous expected-failure set — its reader half shares T13.5-5's polling machinery, certified via VIOL-CORE-PARTIALWRITE, and its seam choreography is certified via the CONF-CORE lock violators. +* **P-11, P-12**: P-11's imperfect-input classes are broad basins under P-8's mutators — nearly any byte mutation of a source lands in some finding family, unlike the boundary code points P-1 and P-2 must weight their generators toward — each class anchored pointwise by the deterministic 11.2 fixtures; its datum-form discipline is certified deterministically through the CONF-AVAIL violators, its termination, exit, and complete-document clauses are loud (S-3 captures exits and hangs; a partial document fails its own parse), and a conformer admitting it would need occurrence analysis over fuzzed TypeScript — P-8's scope argument. P-12 enumerates every offset of every file — reachability is total by construction — and its comparator is computed from the product's own `view` answers, anchored by T11.5-1's precomputed fixture, so there is no independent oracle to mis-trust. * **T13.5-6, T13.5-7** (workspace isolation; interrupted mutation): squarely in criterion (a)'s temporal class, but P-10's rationale extends to both — neither admits a deviation with an unambiguous expected-failure set. Cross-workspace interference surfaces only when schedules overlap, and post-release kill damage lands nondeterministically; T13.5-7's operative assertion is disjunctive for exactly that reason (`check` passes or reports findings), so no single deviation fails it deterministically, and its held-point choreography is certified via the CONF-CORE lock violators. T13.5-6's isolation is additionally the harness's own H-1 obligation, exercised on every parallel run (E-3). -* **Section 4 consumer-side and type-level tests**: their vacuous-pass hazard lives in the TypeScript tooling driver, which S-4 self-tests against a known non-xspec fixture; a conformer would need the full generated-module contract (skeleton, branding, documentation, navigation). -* **T10.1-4 session-corruption arms**: the per-arm precision hazard is real, but the recorded-creation-parameters arm requires a baseline or coverage session, dragging git or coverage machinery into an otherwise lean scope. -* **The remaining negative matrices and refusals (2.1 import negatives, 2.4, 2.7, 4.x imports and markers, 5.3 cycles, 6.1 journal integrity — T6.1-3, 6.3 baseline failures, 6.4/6.5 refusals — T6.5-6's refused self-move with its journal compare included, 7.x configuration validation, 10.1 session-name and non-session negatives, 12.0 usage errors, the 13.4 symlink write refusals and their byte-compares — T13.4-6, the 14.19/14.20 path and encoding negatives — T1.5-2 and T1.6-5, whose byte-level staging is exactly what S-2's builder round-trip self-tests, and the masking and reporter-matrix contracts of 14 — T14-3, T14-4)** and the remaining absence-of-effect sweeps (T12.0-11, T12.1-4, T12.2-3, T13.3-3, and T13.4-4's nothing-written-through-the-link compare): they share the double-invalidity, error-identity and masking, and compare-around structures certified representatively through CONF-VALID and VIOL-CORE-CHATTYREADS — representative insofar as those tests share the certified machinery (the VIOL-CORE-CHATTYREADS note's condition); certifying each would be completeness, which this document must not pursue. +* **Section 4 consumer-side and type-level tests**: their vacuous-pass hazard lives in the TypeScript tooling driver, which S-4 self-tests against a known non-xspec fixture; a conformer would need the full generated-module contract (skeleton, branding, documentation, navigation). The TypeScript-side occurrence and range enumerations (T1.7-2, T5.7-1 through T5.7-4, T11.3-1) sit behind the same wall — a conformer serving them carries the full 4.5/4.6 unit analysis — and their two-sided enumeration equalities fail loud, the record form they decode certified through the CONF-AVAIL violators. +* **T10.1-4 session-corruption arms**: the per-arm precision hazard is real, but the recorded-creation-parameters arm requires a baseline or coverage session, dragging git or coverage machinery into an otherwise lean scope. T10.1-5's and T12.0-10's gate-precedence pairs carry their own discriminating contrasts in-test (the `check`-vs-subcommand and valid-twin comparisons). +* **Previews and the unreadable-record cluster (T6.6-2 through T6.6-6, T6.5-7; 14.23 at T12.2-2, T13.3-2, T11.6-4)**: T6.6-2's modifies-nothing compare shares the compare-around machinery certified via VIOL-CORE-CHATTYREADS (its note's condition), and T6.6-3's runs-while-held arm shares the drive-during-hold choreography certified via the CONF-CORE lock violators (T13.5-2's refused second command and T13.5-4's concurrent reads are the same staging); T6.6-4 and T6.5-7 are positive edits byte-asserted against precomputed offsets and independently composed expected files (loud); and the shape-blind 14.23 stagings are self-controlled — each state's reachability is positively asserted in-test or by its sibling on the same staging (the condition-23 finding's presence, `inventory`'s recorded-unavailable report, `check`'s unit-form finding), so a staging accident fails loud rather than passing silently. +* **`inventory` and `version` (T11.6-1 through T11.6-4, T12.6-1/2)**: the anchoring, resolved-configuration, derived-map, occupancy, and listing arms are positive and byte-asserted; T11.6-4's no-parse/no-write negatives ride the certified compare-around machinery, and every broken state it must ignore is positively reported from the same staging by its home reporter (T13.3-3, T10.1-4, T12.2-2); T12.6-2 carries its own discriminating pair — `build` exits 2 on the very fixture `version` must answer from. +* **The 12.7 form sweeps and the code contracts (T12.7-1 through T12.7-3, T14-6, T14-7, T14-8)**: one form contract is asserted literally wherever the documents appear (H-3), so the decode rigor the sweeps depend on is certified representatively through the CONF-AVAIL violators, where the marker and the stated `null` are densest; staging a fixture per surface and per condition would be completeness. The stable-code, refusal-reason, and location-cardinality assertions are positive identity checks that fail loud when a staged condition does not fire — the finding is then absent altogether, and the assertion with it. +* **The single-casing probes and the Windows leg (the casing arms of T7-4, T10.1-2, T10.1-3, T12.0-6; E-6)**: their discrimination is the Windows leg's to carry by design — on Linux the masking is by construction — and the non-discovery and exit-2 wiring they ride is certified via VIOL-DISC-DIALECT and the error-identity representatives; a case-folding fixture would re-prove that wiring on another rule. +* **The remaining negative matrices and refusals (2.1 import negatives, 2.4, 2.7, 4.x imports and markers, 5.3 cycles, 6.1 journal integrity — T6.1-3, 6.3 baseline failures, 6.4/6.5 refusals — T6.5-6's refused self-move with its journal compare included, 7.x configuration validation, 10.1 session-name and non-session negatives, 12.0 usage errors, the argument, spelling, and domain-and-exit matrices of the machine-interface surfaces — T11.2-5, T11.3-2/3, T11.4-2, T11.5-2, T12.0-13, and T11-6's unknown-unit and `@N` arms, their answer-side decode rigor the CONF-AVAIL-certified machinery and their exit assertions S-3's — T11.4-5's consultation-domain negatives, the 13.4 symlink write refusals and their byte-compares — T13.4-6, the 14.19/14.20 path and encoding negatives — T1.5-2, T1.6-5, and T11.2-3, whose byte-level staging is exactly what S-2's builder round-trip self-tests, and the masking and reporter-matrix contracts of 14 — T14-3, T14-4)** and the remaining absence-of-effect sweeps (T12.0-11, T12.1-4, T12.2-3, T13.3-3, T13.4-4's nothing-written-through-the-link compare, and the answer-side no-write compares of T11.2-1/T11.2-6): they share the double-invalidity, error-identity and masking, and compare-around structures certified representatively through CONF-VALID, VIOL-CORE-CHATTYREADS, and the CONF-AVAIL violators — representative insofar as those tests share the certified machinery (the VIOL-CORE-CHATTYREADS note's condition); certifying each would be completeness, which this document must not pursue. From d4b4143a9e08019fe00e2350dc329c6ddfaa5a56 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 20:29:35 +0000 Subject: [PATCH 042/226] sdg(phase-7): pin CHATTYREADS line inertness; certify T11.3-4's restricted arm; name CONF-DISC's 14.19 path (iter 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: I1 (VIOL-CORE-CHATTYREADS deviation states the appended line's inertness — no 5.4 mapping, no 14.13, derived bytes independent of line count — and the passing side now covers T10.4-5's ungated reads and T13.5-4's derived-file comparison), I2 (new VIOL-AVAIL-NOFILE certifies T11.3-4's restricted arm; Justification recast to claim the staging hazard it now checks and to scope the decode claim to the datum-form violators, with the four Exclusions references tightened to match), I3 (CONF-DISC command surface names the 14.19 reporting path, dormant on the conformer), O1+O4 (CONF-AVAIL restated in the shared "Contracts under certification" pattern with graph-data/refresh out of scope and "as staged" flag wording), O2 (VIOL-DISC-DIALECT's bracket-free patterns labeled a staging constraint), O3 (VIOL-CORE-EARLYWRITE scoped to --test-hold invocations), O5 (Exclusions entry for the review-operation refusal negatives). Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/CERTIFICATIONS.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/specs/CERTIFICATIONS.md b/specs/CERTIFICATIONS.md index 28e1cb1..8b60c44 100644 --- a/specs/CERTIFICATIONS.md +++ b/specs/CERTIFICATIONS.md @@ -24,7 +24,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ### VIOL-CORE-EARLYWRITE * **Scope:** CONF-CORE. -* **Deviation:** A mutating command performs its workspace modifications before creating the hold file: it acquires exclusivity, completes the operation's writes (journal append included), then creates the hold file, waits for its deletion, and exits normally. +* **Deviation:** A mutating command given `--test-hold` performs its workspace modifications before creating the hold file: it acquires exclusivity, completes the operation's writes (journal append included), then creates the hold file, waits for its deletion, and exits normally. An invocation without `--test-hold` creates no hold file (13.5) and degenerates to conforming behavior, where the deviation is unobservable. * **Certifies:** T13.5-1, T13.5-4. * **Expected failures:** exactly T13.5-1 (the workspace is not byte-identical while held; modification precedes the hold) and T13.5-4 (read commands run while a command is held observe the operation's result, not the prior state). All other in-scope tests pass: exclusivity is still acquired first, so T13.5-2's excluded command still fails without modifying anything — a staging constraint: T13.5-2's modifies-nothing compare brackets the excluded command alone, its snapshot taken while command 1 is already held, so this deviation's pre-hold writes stay outside the compare; a kill at the held point leaves the completed, consistent operation, and T13.5-3's subsequent mutating command — a staging constraint: one that succeeds whether or not the killed operation's writes landed, so not a retry of the same operation — still succeeds; writes remain atomic (T13.5-5); journal content, append-only form, and determinism are unchanged (T6.1-1, T6.1-2); durable-file and session-read discipline are unchanged (T13.4-5, T10.4-5). @@ -45,9 +45,9 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ### VIOL-CORE-CHATTYREADS * **Scope:** CONF-CORE. -* **Deviation:** `build` and the read commands modify the journal: each such invocation that is not refused as a usage or configuration error (exit 2) appends one fixed line to `.xspec/journal`, creating the file when absent. Mutating commands, and the entries `rename`/`move` append, are unchanged. +* **Deviation:** `build` and the read commands modify the journal: each such invocation that is not refused as a usage or configuration error (exit 2) appends one fixed line to `.xspec/journal`, creating the file when absent. The appended line is inert to the fixture's own journal handling: read back as neither an identity mapping (5.4) nor a malformed entry (no 14.13, so no later command is gated by it, 13.3), with graph data and every other derived byte independent of how many such lines the journal holds (13.4). Mutating commands, and the entries `rename`/`move` append, are unchanged. * **Certifies:** T6.1-1, T13.4-5. -* **Expected failures:** exactly T6.1-1 (a journal file exists after `build` in a fresh workspace; the byte-compares around `build`, `check`, `coverage`, and `query` observe modification — in this git-less scope the `impact` invocation is refused exit 2 and appends nothing, and `review`'s compare observes modification only where the read subcommand chosen exits 0, as `review list` does) and T13.4-5 (its journal byte-compares under `build` and read commands fail). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched; the 13.5 tests assert hold, exclusion, and derived-file behavior, not journal bytes, and T13.5-2's byte-compare covers the refused mutating command, which appends nothing. +* **Expected failures:** exactly T6.1-1 (a journal file exists after `build` in a fresh workspace; the byte-compares around `build`, `check`, `coverage`, and `query` observe modification — in this git-less scope the `impact` invocation is refused exit 2 and appends nothing, and `review`'s compare observes modification only where the read subcommand chosen exits 0, as `review list` does) and T13.4-5 (its journal byte-compares under `build` and read commands fail). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched, and its review reads answer ungated — the inert lines meet no 14.13, so 13.3's gate never turns them into exit-1 non-answers; the 13.5 tests assert hold, exclusion, and derived-file behavior, not journal bytes — T13.5-4's final-`build` byte-equality to a clean build resolves derived-file inconsistency, a comparison of derived files (13.4) the inertness condition keeps independent of the two workspaces' differing appended-line counts — and T13.5-2's byte-compare covers the refused mutating command, which appends nothing. * **Note:** the representative value of this violator for the suite's other never-modifies assertions (T12.0-11, T12.1-4, T13.3-3, T6.4-3, T6.5-4, T6.6-2, and the answer-side compares of T11.2-1/T11.2-6/T11.6-4) holds insofar as those assertions share the compare-around-command machinery certified here; the Exclusions lean on exactly that condition. ### VIOL-CORE-PERSISTREADS @@ -103,7 +103,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ## CONF-DISC — configuration-driven discovery -**Scope.** Workspaces of trivial single-section `.mdx` sources whose file and directory names carry glob-significant bytes, plus, as T7-6 stages them, files at derived-classified paths and an import target unmatched by every group; spec groups with the glob grammar of 7 (a no-match group, and the empty `specs` and `code` maps, are valid with zero sources); imports of 2.1's single-default-binding form, resolving against the importing file's directory to a discovered source, an undiscovered target failing with 14.15; `markdown` with `emit: true` as T7-6's destination arm stages it, destinations classified by configuration alone (7.3); symbolic links present in the tree; no code groups (`code` appears only as the empty map), `coverage`, `policy`, or git; content of derived and emitted files beyond path is out of scope. A staging constraint: T7-6's exclusion arms are staged over spec groups — its `code` arm is the empty map — so the one exclusion rule of 13.4 is certified on its spec-group side. Command surface: `build` and `ids` (12.3) as the observation of the discovered set, the configuration-error behavior of 14.14/12.0 for patterns resolving outside the workspace root, and the source-error reporting of 14.15. Contracts under certification: glob semantics of 7 — `*`, `?`, `**`, byte-wise case-sensitive matching, dot-segment rule, every other character a literal — discovery's refusal to follow symbolic links, and the source exclusion of 13.4 (`.xspec.` names, `.xspec/` paths, and enabled emit destinations in no group). +**Scope.** Workspaces of trivial single-section `.mdx` sources whose file and directory names carry glob-significant bytes, plus, as T7-6 stages them, files at derived-classified paths and an import target unmatched by every group; spec groups with the glob grammar of 7 (a no-match group, and the empty `specs` and `code` maps, are valid with zero sources); imports of 2.1's single-default-binding form, resolving against the importing file's directory to a discovered source, an undiscovered target failing with 14.15; `markdown` with `emit: true` as T7-6's destination arm stages it, destinations classified by configuration alone (7.3); symbolic links present in the tree; no code groups (`code` appears only as the empty map), `coverage`, `policy`, or git; content of derived and emitted files beyond path is out of scope. A staging constraint: T7-6's exclusion arms are staged over spec groups — its `code` arm is the empty map — so the one exclusion rule of 13.4 is certified on its spec-group side. Command surface: `build` and `ids` (12.3) as the observation of the discovered set, the configuration-error behavior of 14.14/12.0 for patterns resolving outside the workspace root, and the source-error reporting of 14.15 and 14.19 — the 14.19 path dormant in conforming behavior, since the 13.4 exclusion keeps the derived-classified matches, the only staged matches lacking `.mdx`, out of the discovered set. Contracts under certification: glob semantics of 7 — `*`, `?`, `**`, byte-wise case-sensitive matching, dot-segment rule, every other character a literal — discovery's refusal to follow symbolic links, and the source exclusion of 13.4 (`.xspec.` names, `.xspec/` paths, and enabled emit destinations in no group). **In-scope tests:** T7-4, T7-5, T7-6. @@ -114,7 +114,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command * **Scope:** CONF-DISC. * **Deviation:** Glob patterns are interpreted in a common dialect in which `[` `]` bracket expressions and `{` `}` brace alternations are active metacharacters, instead of the literals 7 requires — a single deviation: one rule of 7 (every character outside `*`, `?`, and `**` is a literal) broken for one dialect's metacharacter subset. `*`, `?`, `**`, case sensitivity, and the dot-segment rule are unchanged. * **Certifies:** T7-4. -* **Expected failures:** exactly T7-4 (its literal-metacharacter arms: `a[1].mdx` matches `a1.mdx` and fails to match the file named `a[1].mdx`; `b{a,c}.mdx` matches `ba.mdx`/`bc.mdx` and not the literal name). T7-5 and T7-6 pass: their patterns carry no bracket or brace characters, so their matching — and T7-6's exclusion — is unchanged. +* **Expected failures:** exactly T7-4 (its literal-metacharacter arms: `a[1].mdx` matches `a1.mdx` and fails to match the file named `a[1].mdx`; `b{a,c}.mdx` matches `ba.mdx`/`bc.mdx` and not the literal name). T7-5 and T7-6 pass under a staging constraint: their fixtures' patterns carry no bracket or brace characters, so their matching — and T7-6's exclusion — is unchanged. ### VIOL-DISC-SYMLINK @@ -132,11 +132,11 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ## CONF-AVAIL — availability answers and JSON datum forms -**Scope.** Workspaces of configured spec groups of `.mdx` sources at valid-UTF-8, `#`-free workspace-relative paths — imports (2.1), `d` props, and `{text(...)}` embeddings as the in-scope fixtures stage them; no code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `view`, with and without `--text` (operand and `--file` forms as staged), and `occurrences` with `--file` and `--to`, each answering in the form-exact 12.7 document forms with the availability rules of 11.2 — parse-local structure and positional trees (a section inside an invalid non-section element parenting per 11.4), spelled-identity definedness, interpreted tags and coverage, resolution through defined identities, occurrence records per 5.7/11.3 with `source` withheld as one datum where undefined, whole-value expansion poisoning with own and subtree text defined through the rules of 3 (1.6; emission out of scope), removal classification by syntactic form, and the raw attribute and import data of 11.4 — findings accompanying per 11.2/14 with stable codes and located ranges for the staged conditions (14.1, 14.3, 14.4, 14.5, 14.6, 14.9, 14.15, 14.16, 14.17), and the exit discipline of 11.2 (any finding or explicitly-unavailable datum → exit 1 with the full answer emitted; complete and finding-free → exit 0). A staging constraint: T11.4-1's fixtures spell every identity uniquely and validly and stage no undefined datum — its invalid-element arm (14.16) keeps every spelled identity defined — so its answers carry the unavailability marker nowhere. +**Scope.** Workspaces of configured spec groups of `.mdx` sources at valid-UTF-8, `#`-free workspace-relative paths — imports (2.1), `d` props, and `{text(...)}` embeddings as the in-scope fixtures stage them; no code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `view`, with and without `--text` (operand and `--file` forms as staged), and `occurrences` (`--file` and `--to` as staged), each answering in the form-exact 12.7 document forms. Contracts under certification: the availability rules of 11.2 — parse-local structure and positional trees (a section inside an invalid non-section element parenting per 11.4), spelled-identity definedness, interpreted tags and coverage, resolution through defined identities, whole-value expansion poisoning with own and subtree text defined through the rules of 3 (1.6; emission out of scope), and removal classification by syntactic form — occurrence records per 5.7/11.3 with `source` withheld as one datum where undefined, the `--file` domain restriction and `--to` selection of 11.3, the raw attribute and import data of 11.4, findings accompanying per 11.2/14 with stable codes and located ranges for the staged conditions (14.1, 14.3, 14.4, 14.5, 14.6, 14.9, 14.15, 14.16, 14.17), and the exit discipline of 11.2 (any finding or explicitly-unavailable datum → exit 1 with the full answer emitted; complete and finding-free → exit 0). Graph-data content and refresh behavior beyond the answers are out of scope: no in-scope test observes either. A staging constraint: T11.4-1's fixtures spell every identity uniquely and validly and stage no undefined datum — its invalid-element arm (14.16) keeps every spelled identity defined — so its answers carry the unavailability marker nowhere. **In-scope tests:** T11.2-2, T11.2-4, T11.3-4, T11.4-1, T11.4-3, T11.4-4. -**Justification.** The availability answers are the suite's densest negative-observation surface — no-winner identity undefinedness, never-a-picked-bearer and never-a-dropped-record occurrence sources, whole-value poisoning with partial expansion forbidden, and T11.3-4's definitive emptiness — and every one of those observations rides a three-state datum decode (plain value, the stated `null`, `{"unavailable": true}`; 12.7) that H-3 requires be asserted form-exact with no adapter in the path. Nothing self-tests that decode's distinctions: S-5 covers adapters, which these surfaces bypass, and T12.7-1's S-5-guarded structural walk checks the marker's own shape but cannot see a marker replaced by `null` or a `null` member silently dropped — exactly the collapse a defaulting JSON decoder makes, and such a harness defect passes conforming and deviating products alike, forever: criterion (a)'s vacuous-pass class with no other red-green check. The two violators certify that the harness's decode actually separates the three states; T11.4-1 and T11.3-4 stage marker-free and `null`-free answers respectively and anchor the violators' passing sides. +**Justification.** The availability answers are the suite's densest negative-observation surface — no-winner identity undefinedness, never-a-picked-bearer and never-a-dropped-record occurrence sources, whole-value poisoning with partial expansion forbidden — and every one of those observations rides a three-state datum decode (plain value, the stated `null`, `{"unavailable": true}`; 12.7) that H-3 requires be asserted form-exact with no adapter in the path. Nothing self-tests that decode's distinctions: S-5 covers adapters, which these surfaces bypass, and T12.7-1's S-5-guarded structural walk checks the marker's own shape but cannot see a marker replaced by `null` or a `null` member silently dropped — exactly the collapse a defaulting JSON decoder makes, and such a harness defect passes conforming and deviating products alike, forever: criterion (a)'s vacuous-pass class with no other red-green check. The datum-form violators certify that the harness's decode actually separates the three states; T11.4-1 and T11.3-4 stage marker-free and `null`-free answers respectively and anchor those violators' passing sides. T11.3-4's restricted arm is a separate hazard of the same criterion-(a) class: a negative observation with no in-test positive control — nothing observable separates restricted-away-from-the-occurrence from an occurrence never successfully staged, a mis-staged reference's finding lying outside the restricted domain with the file that holds it (11.2, 11.3) — the hazard class CONF-DISC certifies for T7-6, certified here through VIOL-AVAIL-NOFILE. ### VIOL-AVAIL-NULLMARKER @@ -152,6 +152,13 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command * **Certifies:** T11.2-2, T11.2-4, T11.4-1, T11.4-3, T11.4-4. * **Expected failures:** exactly the certified five — every in-scope test that decodes a `view` answer: each viewed file's root node carries the stated-`null` `tags` and `coverage`, omitted under this deviation, and 12.7's member-presence contract (`null` is never omission) is asserted literally wherever the forms appear (H-3, T12.7-2), so each such decode fails on the missing members — T11.4-3's root arm asserts the distinction directly, and T11.2-4's `occurrences` answers additionally fail through their located findings' omitted `path` members. T11.3-4 passes: its two answers are empty enumerations — `[]` is not `null` (12.7) — carrying no finding and no `null`-valued member to omit. +### VIOL-AVAIL-NOFILE + +* **Scope:** CONF-AVAIL. +* **Deviation:** `occurrences` does not apply the `--file` restriction: the flag and its argument checks behave as specified (11.3), but the consulted domain is the entire discovered set, exactly as with the flag absent — the enumeration and the findings accompanying it (11.2) follow that widened domain. A single deviation: 11.3's one set-restriction rule dropped; `--to` selection, `view`, and every other behavior are unchanged. +* **Certifies:** T11.3-4. +* **Expected failures:** exactly T11.3-4 (its restricted arm: the resolving occurrence of X held by the file `--file` excludes is enumerated, so the answer is not the asserted definitive emptiness; the unrestricted arm, with no `--file` to ignore, is unchanged). All other in-scope tests pass — a staging constraint: T11.2-4, the only other in-scope test driving `occurrences`, stages no `--file` on those invocations, so its records and accompanying findings are the unrestricted domain's on either side; the remaining in-scope tests drive `view` alone, which the deviation leaves untouched. + ## Exclusions Considered against the selection criteria and deliberately left uncertified; each may be revisited under criterion (b) on an empirically demonstrated miss. @@ -159,12 +166,13 @@ Considered against the selection criteria and deliberately left uncertified; eac * **P-4, P-5, P-6, P-13** (hash laws, rename/move purity and section-move categories, baseline replay, coverage): a conformer passing their anchor tests requires substantially the whole graph, identity, baseline, or coverage engine — a near-complete second product — while the anchors themselves are positive, byte-asserted fixtures whose failure modes are loud, not vacuous, and the oracles among them (P-5, P-6, P-13) are vetted by S-6's fixed vector suites drawn from SPEC.md's worked material. * **P-7**: its capture half requires policy machinery out of any lean scope; the glob half's staging hazard is certified through CONF-DISC on T7-4. * **P-8, P-9, P-10**: P-8 sweeps every command, exceeding any narrow conformer scope; P-9 asserts consistency invariants anchored by the deterministic 10.x fixtures; P-10's single-mutator schedules and kill accounting admit no deviation with an unambiguous expected-failure set — its reader half shares T13.5-5's polling machinery, certified via VIOL-CORE-PARTIALWRITE, and its seam choreography is certified via the CONF-CORE lock violators. -* **P-11, P-12**: P-11's imperfect-input classes are broad basins under P-8's mutators — nearly any byte mutation of a source lands in some finding family, unlike the boundary code points P-1 and P-2 must weight their generators toward — each class anchored pointwise by the deterministic 11.2 fixtures; its datum-form discipline is certified deterministically through the CONF-AVAIL violators, its termination, exit, and complete-document clauses are loud (S-3 captures exits and hangs; a partial document fails its own parse), and a conformer admitting it would need occurrence analysis over fuzzed TypeScript — P-8's scope argument. P-12 enumerates every offset of every file — reachability is total by construction — and its comparator is computed from the product's own `view` answers, anchored by T11.5-1's precomputed fixture, so there is no independent oracle to mis-trust. +* **P-11, P-12**: P-11's imperfect-input classes are broad basins under P-8's mutators — nearly any byte mutation of a source lands in some finding family, unlike the boundary code points P-1 and P-2 must weight their generators toward — each class anchored pointwise by the deterministic 11.2 fixtures; its datum-form discipline is certified deterministically through the CONF-AVAIL datum-form violators, its termination, exit, and complete-document clauses are loud (S-3 captures exits and hangs; a partial document fails its own parse), and a conformer admitting it would need occurrence analysis over fuzzed TypeScript — P-8's scope argument. P-12 enumerates every offset of every file — reachability is total by construction — and its comparator is computed from the product's own `view` answers, anchored by T11.5-1's precomputed fixture, so there is no independent oracle to mis-trust. * **T13.5-6, T13.5-7** (workspace isolation; interrupted mutation): squarely in criterion (a)'s temporal class, but P-10's rationale extends to both — neither admits a deviation with an unambiguous expected-failure set. Cross-workspace interference surfaces only when schedules overlap, and post-release kill damage lands nondeterministically; T13.5-7's operative assertion is disjunctive for exactly that reason (`check` passes or reports findings), so no single deviation fails it deterministically, and its held-point choreography is certified via the CONF-CORE lock violators. T13.5-6's isolation is additionally the harness's own H-1 obligation, exercised on every parallel run (E-3). -* **Section 4 consumer-side and type-level tests**: their vacuous-pass hazard lives in the TypeScript tooling driver, which S-4 self-tests against a known non-xspec fixture; a conformer would need the full generated-module contract (skeleton, branding, documentation, navigation). The TypeScript-side occurrence and range enumerations (T1.7-2, T5.7-1 through T5.7-4, T11.3-1) sit behind the same wall — a conformer serving them carries the full 4.5/4.6 unit analysis — and their two-sided enumeration equalities fail loud, the record form they decode certified through the CONF-AVAIL violators. +* **Section 4 consumer-side and type-level tests**: their vacuous-pass hazard lives in the TypeScript tooling driver, which S-4 self-tests against a known non-xspec fixture; a conformer would need the full generated-module contract (skeleton, branding, documentation, navigation). The TypeScript-side occurrence and range enumerations (T1.7-2, T5.7-1 through T5.7-4, T11.3-1) sit behind the same wall — a conformer serving them carries the full 4.5/4.6 unit analysis — and their two-sided enumeration equalities fail loud, the record form they decode certified through the CONF-AVAIL datum-form violators. * **T10.1-4 session-corruption arms**: the per-arm precision hazard is real, but the recorded-creation-parameters arm requires a baseline or coverage session, dragging git or coverage machinery into an otherwise lean scope. T10.1-5's and T12.0-10's gate-precedence pairs carry their own discriminating contrasts in-test (the `check`-vs-subcommand and valid-twin comparisons). +* **The review-operation refusal negatives (T10.3-2's re-block refusal; T10.7-9/10's refused `split` and `resolve`)**: refusals of 10.7 carry no stable code (14), so a wrong-reason refusal is in-test indistinguishable for any product and the error-identity representatives reach none of them; what discriminates instead is asserted beside each refusal — the same test flips one condition and the same subcommand succeeds (T10.3-2's dependent resolves once its blocker is re-resolved; T10.7-9 asserts the successful split beside its other-kind and childless-root refusals; T10.7-10 asserts `resolve` succeeding on any unblocked item beside its refused blocked one) — so a wrong-reason refusal fails its paired positive arm rather than passing silently, and a violator would carry the blocking, decomposition, and re-derivation machinery those tests assert around the refusals for one exit-code assertion already braced in-test. * **Previews and the unreadable-record cluster (T6.6-2 through T6.6-6, T6.5-7; 14.23 at T12.2-2, T13.3-2, T11.6-4)**: T6.6-2's modifies-nothing compare shares the compare-around machinery certified via VIOL-CORE-CHATTYREADS (its note's condition), and T6.6-3's runs-while-held arm shares the drive-during-hold choreography certified via the CONF-CORE lock violators (T13.5-2's refused second command and T13.5-4's concurrent reads are the same staging); T6.6-4 and T6.5-7 are positive edits byte-asserted against precomputed offsets and independently composed expected files (loud); and the shape-blind 14.23 stagings are self-controlled — each state's reachability is positively asserted in-test or by its sibling on the same staging (the condition-23 finding's presence, `inventory`'s recorded-unavailable report, `check`'s unit-form finding), so a staging accident fails loud rather than passing silently. * **`inventory` and `version` (T11.6-1 through T11.6-4, T12.6-1/2)**: the anchoring, resolved-configuration, derived-map, occupancy, and listing arms are positive and byte-asserted; T11.6-4's no-parse/no-write negatives ride the certified compare-around machinery, and every broken state it must ignore is positively reported from the same staging by its home reporter (T13.3-3, T10.1-4, T12.2-2); T12.6-2 carries its own discriminating pair — `build` exits 2 on the very fixture `version` must answer from. -* **The 12.7 form sweeps and the code contracts (T12.7-1 through T12.7-3, T14-6, T14-7, T14-8)**: one form contract is asserted literally wherever the documents appear (H-3), so the decode rigor the sweeps depend on is certified representatively through the CONF-AVAIL violators, where the marker and the stated `null` are densest; staging a fixture per surface and per condition would be completeness. The stable-code, refusal-reason, and location-cardinality assertions are positive identity checks that fail loud when a staged condition does not fire — the finding is then absent altogether, and the assertion with it. +* **The 12.7 form sweeps and the code contracts (T12.7-1 through T12.7-3, T14-6, T14-7, T14-8)**: one form contract is asserted literally wherever the documents appear (H-3), so the decode rigor the sweeps depend on is certified representatively through the CONF-AVAIL datum-form violators, where the marker and the stated `null` are densest; staging a fixture per surface and per condition would be completeness. The stable-code, refusal-reason, and location-cardinality assertions are positive identity checks that fail loud when a staged condition does not fire — the finding is then absent altogether, and the assertion with it. * **The single-casing probes and the Windows leg (the casing arms of T7-4, T10.1-2, T10.1-3, T12.0-6; E-6)**: their discrimination is the Windows leg's to carry by design — on Linux the masking is by construction — and the non-discovery and exit-2 wiring they ride is certified via VIOL-DISC-DIALECT and the error-identity representatives; a case-folding fixture would re-prove that wiring on another rule. -* **The remaining negative matrices and refusals (2.1 import negatives, 2.4, 2.7, 4.x imports and markers, 5.3 cycles, 6.1 journal integrity — T6.1-3, 6.3 baseline failures, 6.4/6.5 refusals — T6.5-6's refused self-move with its journal compare included, 7.x configuration validation, 10.1 session-name and non-session negatives, 12.0 usage errors, the argument, spelling, and domain-and-exit matrices of the machine-interface surfaces — T11.2-5, T11.3-2/3, T11.4-2, T11.5-2, T12.0-13, and T11-6's unknown-unit and `@N` arms, their answer-side decode rigor the CONF-AVAIL-certified machinery and their exit assertions S-3's — T11.4-5's consultation-domain negatives, the 13.4 symlink write refusals and their byte-compares — T13.4-6, the 14.19/14.20 path and encoding negatives — T1.5-2, T1.6-5, and T11.2-3, whose byte-level staging is exactly what S-2's builder round-trip self-tests, and the masking and reporter-matrix contracts of 14 — T14-3, T14-4)** and the remaining absence-of-effect sweeps (T12.0-11, T12.1-4, T12.2-3, T13.3-3, T13.4-4's nothing-written-through-the-link compare, and the answer-side no-write compares of T11.2-1/T11.2-6): they share the double-invalidity, error-identity and masking, and compare-around structures certified representatively through CONF-VALID, VIOL-CORE-CHATTYREADS, and the CONF-AVAIL violators — representative insofar as those tests share the certified machinery (the VIOL-CORE-CHATTYREADS note's condition); certifying each would be completeness, which this document must not pursue. +* **The remaining negative matrices and refusals (2.1 import negatives, 2.4, 2.7, 4.x imports and markers, 5.3 cycles, 6.1 journal integrity — T6.1-3, 6.3 baseline failures, 6.4/6.5 refusals — T6.5-6's refused self-move with its journal compare included, 7.x configuration validation, 10.1 session-name and non-session negatives, 12.0 usage errors, the argument, spelling, and domain-and-exit matrices of the machine-interface surfaces — T11.2-5, T11.3-2/3, T11.4-2, T11.5-2, T12.0-13, and T11-6's unknown-unit and `@N` arms, their answer-side decode rigor the CONF-AVAIL-certified machinery and their exit assertions S-3's — T11.4-5's consultation-domain negatives, the 13.4 symlink write refusals and their byte-compares — T13.4-6, the 14.19/14.20 path and encoding negatives — T1.5-2, T1.6-5, and T11.2-3, whose byte-level staging is exactly what S-2's builder round-trip self-tests, and the masking and reporter-matrix contracts of 14 — T14-3, T14-4)** and the remaining absence-of-effect sweeps (T12.0-11, T12.1-4, T12.2-3, T13.3-3, T13.4-4's nothing-written-through-the-link compare, and the answer-side no-write compares of T11.2-1/T11.2-6): they share the double-invalidity, error-identity and masking, and compare-around structures certified representatively through CONF-VALID, VIOL-CORE-CHATTYREADS, and the CONF-AVAIL datum-form violators — representative insofar as those tests share the certified machinery (the VIOL-CORE-CHATTYREADS note's condition); certifying each would be completeness, which this document must not pursue. From 327f67e05f2500c7e07a54a2db632df1d573affe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 20:59:20 +0000 Subject: [PATCH 043/226] sdg(phase-7): pin read sweeps and pre-generation discovery observations; name bare 11.2 forms and --tag in surfaces (iter 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied all review items; none rejected. - I1: VIOL-DISC-DERIVED — T7-4/T7-5 staging constraint extended: discovery observations run before any generated file exists, closing the leak from the conformer's own next-to-source .xspec. output under T7-4's bare * pattern. - I2: CONF-CORE — read surface enumerated exactly, the 11.2 surfaces declared outside it, and T13.4-5's/T13.5-4's read sweeps pinned to it by constraint. - I3: CONF-VALID — query nodes --tag (T2.6-1's staged selection) named in the command surface. - I4: CONF-AVAIL — bare view (T11.4-1) and bare occurrences (T11.2-4, T11.3-4's unrestricted arm) named in the command surface. - O1: VIOL-DISC-DERIVED destination arm stated as deterministic 14.19 (destinations end .md, 13.2). - O2: VIOL-CORE-PARTIALWRITE deviation pins the write on every build of T13.5-5's loop (byte-identical content rewritten, never skipped). - O3: T13.5-2 bracketing constraint hoisted to CONF-CORE's scope; EARLYWRITE and CHATTYREADS now reference it. - O4: T11.4-1 constraint aligned with 11.2's chain conditions (1.4 form, 1.3 structure under 11.4's positional enclosure, unduplicated, valid paths). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/CERTIFICATIONS.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/specs/CERTIFICATIONS.md b/specs/CERTIFICATIONS.md index 8b60c44..10dff14 100644 --- a/specs/CERTIFICATIONS.md +++ b/specs/CERTIFICATIONS.md @@ -8,7 +8,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ## CONF-CORE — operational core: exclusion seam, journal, durable files, review reads -**Scope.** Workspaces with one configured spec group of `.mdx` sources without imports, embeddings, `d` props, or tags; no `code`, `markdown`, `coverage`, or `policy` keys; no git. Command surface: `build`; the read commands of 13.3 behaving per 12.0 over such workspaces (`check` with no findings on valid state, `ids`, `show`, `query`, `coverage` reporting zero profiles, the `review` read subcommands; `impact --base` without git is the exit-2 unreadable-baseline case of 6.3/12.0); `rename` and file-form `move` with journal append (6.1, 6.2); `review` with the `audit` strategy (10.6) through `create`, `resolve`, `split`, and the read subcommands, including read-time invalidation over the recorded state of 10.4 — a staging constraint: every mutating command the in-scope 13.5 tests drive is drawn from this surface — `rename`, file-form `move` (never the section form), and the mutating `review` subcommands with `create` under `--strategy audit` (never `--base` or `--coverage`). Contracts under certification: 6.1 journal form and write discipline, 10.4 read discipline, 13.4 durable-file protection, and 13.5 in full, `--test-hold` seam included. Content of derived files beyond path, byte-determinism, write discipline, and atomic visibility is out of scope. +**Scope.** Workspaces with one configured spec group of `.mdx` sources without imports, embeddings, `d` props, or tags; no `code`, `markdown`, `coverage`, or `policy` keys; no git. Command surface: `build`; of 13.3's read commands, exactly `check` (no findings on valid state), `ids`, `show`, `query`, `coverage` (reporting zero profiles), the `review` read subcommands, and `impact --base` (without git, the exit-2 unreadable-baseline case of 6.3/12.0), each behaving per 12.0 over such workspaces — the 11.2 surfaces (`occurrences`, `view`, `at`) are outside this surface; `rename` and file-form `move` with journal append (6.1, 6.2); `review` with the `audit` strategy (10.6) through `create`, `resolve`, `split`, and the read subcommands, including read-time invalidation over the recorded state of 10.4. Staging constraints: every mutating command the in-scope 13.5 tests drive is drawn from this surface — `rename`, file-form `move` (never the section form), and the mutating `review` subcommands with `create` under `--strategy audit` (never `--base` or `--coverage`); every read command T13.4-5's byte-compares and T13.5-4's concurrent reads drive is drawn from the read surface enumerated above; and T13.5-2's modifies-nothing compare brackets the excluded command alone, its snapshot taken while command 1 is already held — the bracketing both VIOL-CORE-EARLYWRITE's and VIOL-CORE-CHATTYREADS's passing sides lean on. Contracts under certification: 6.1 journal form and write discipline, 10.4 read discipline, 13.4 durable-file protection, and 13.5 in full, `--test-hold` seam included. Content of derived files beyond path, byte-determinism, write discipline, and atomic visibility is out of scope. **In-scope tests:** T6.1-1, T6.1-2, T10.4-5, T13.4-5, T13.5-1, T13.5-2, T13.5-3, T13.5-4, T13.5-5. @@ -26,7 +26,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command * **Scope:** CONF-CORE. * **Deviation:** A mutating command given `--test-hold` performs its workspace modifications before creating the hold file: it acquires exclusivity, completes the operation's writes (journal append included), then creates the hold file, waits for its deletion, and exits normally. An invocation without `--test-hold` creates no hold file (13.5) and degenerates to conforming behavior, where the deviation is unobservable. * **Certifies:** T13.5-1, T13.5-4. -* **Expected failures:** exactly T13.5-1 (the workspace is not byte-identical while held; modification precedes the hold) and T13.5-4 (read commands run while a command is held observe the operation's result, not the prior state). All other in-scope tests pass: exclusivity is still acquired first, so T13.5-2's excluded command still fails without modifying anything — a staging constraint: T13.5-2's modifies-nothing compare brackets the excluded command alone, its snapshot taken while command 1 is already held, so this deviation's pre-hold writes stay outside the compare; a kill at the held point leaves the completed, consistent operation, and T13.5-3's subsequent mutating command — a staging constraint: one that succeeds whether or not the killed operation's writes landed, so not a retry of the same operation — still succeeds; writes remain atomic (T13.5-5); journal content, append-only form, and determinism are unchanged (T6.1-1, T6.1-2); durable-file and session-read discipline are unchanged (T13.4-5, T10.4-5). +* **Expected failures:** exactly T13.5-1 (the workspace is not byte-identical while held; modification precedes the hold) and T13.5-4 (read commands run while a command is held observe the operation's result, not the prior state). All other in-scope tests pass: exclusivity is still acquired first, so T13.5-2's excluded command still fails without modifying anything — under the scope's bracketing constraint (the compare's snapshot taken while command 1 is already held), this deviation's pre-hold writes stay outside the compare; a kill at the held point leaves the completed, consistent operation, and T13.5-3's subsequent mutating command — a staging constraint: one that succeeds whether or not the killed operation's writes landed, so not a retry of the same operation — still succeeds; writes remain atomic (T13.5-5); journal content, append-only form, and determinism are unchanged (T6.1-1, T6.1-2); durable-file and session-read discipline are unchanged (T13.4-5, T10.4-5). ### VIOL-CORE-STALELOCK @@ -38,7 +38,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ### VIOL-CORE-PARTIALWRITE * **Scope:** CONF-CORE. -* **Deviation:** Derived-file writes are not atomic in their observable effect: while a derived file is being written, its path holds a strict prefix of the new content for a sustained interval — long relative to a concurrent reader's polling cadence — before the complete content appears. Durable files are unaffected. +* **Deviation:** Derived-file writes are not atomic in their observable effect: while a derived file is being written, its path holds a strict prefix of the new content for a sustained interval — long relative to a concurrent reader's polling cadence — before the complete content appears; every `build` performs its derived-file writes through this interval — content already byte-identical on disk is rewritten, never skipped — so each build of T13.5-5's polling loop exposes the partial state, on an unchanged workspace too. Durable files are unaffected. * **Certifies:** T13.5-5. * **Expected failures:** exactly T13.5-5 (the polling reader observes a partial file). All other in-scope tests pass: T13.5-4's storm arm asserts only termination and a final `build`'s byte-equality to a clean build, its held-phase reads precede any write, and every other test observes derived files only after commands complete; journal, session, and exclusion behavior are unchanged. @@ -47,7 +47,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command * **Scope:** CONF-CORE. * **Deviation:** `build` and the read commands modify the journal: each such invocation that is not refused as a usage or configuration error (exit 2) appends one fixed line to `.xspec/journal`, creating the file when absent. The appended line is inert to the fixture's own journal handling: read back as neither an identity mapping (5.4) nor a malformed entry (no 14.13, so no later command is gated by it, 13.3), with graph data and every other derived byte independent of how many such lines the journal holds (13.4). Mutating commands, and the entries `rename`/`move` append, are unchanged. * **Certifies:** T6.1-1, T13.4-5. -* **Expected failures:** exactly T6.1-1 (a journal file exists after `build` in a fresh workspace; the byte-compares around `build`, `check`, `coverage`, and `query` observe modification — in this git-less scope the `impact` invocation is refused exit 2 and appends nothing, and `review`'s compare observes modification only where the read subcommand chosen exits 0, as `review list` does) and T13.4-5 (its journal byte-compares under `build` and read commands fail). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched, and its review reads answer ungated — the inert lines meet no 14.13, so 13.3's gate never turns them into exit-1 non-answers; the 13.5 tests assert hold, exclusion, and derived-file behavior, not journal bytes — T13.5-4's final-`build` byte-equality to a clean build resolves derived-file inconsistency, a comparison of derived files (13.4) the inertness condition keeps independent of the two workspaces' differing appended-line counts — and T13.5-2's byte-compare covers the refused mutating command, which appends nothing. +* **Expected failures:** exactly T6.1-1 (a journal file exists after `build` in a fresh workspace; the byte-compares around `build`, `check`, `coverage`, and `query` observe modification — in this git-less scope the `impact` invocation is refused exit 2 and appends nothing, and `review`'s compare observes modification only where the read subcommand chosen exits 0, as `review list` does) and T13.4-5 (its journal byte-compares under `build` and read commands fail). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched, and its review reads answer ungated — the inert lines meet no 14.13, so 13.3's gate never turns them into exit-1 non-answers; the 13.5 tests assert hold, exclusion, and derived-file behavior, not journal bytes — T13.5-4's final-`build` byte-equality to a clean build resolves derived-file inconsistency, a comparison of derived files (13.4) the inertness condition keeps independent of the two workspaces' differing appended-line counts — and T13.5-2's byte-compare covers the refused mutating command alone (the scope's bracketing constraint), which appends nothing. * **Note:** the representative value of this violator for the suite's other never-modifies assertions (T12.0-11, T12.1-4, T13.3-3, T6.4-3, T6.5-4, T6.6-2, and the answer-side compares of T11.2-1/T11.2-6/T11.6-4) holds insofar as those assertions share the compare-around-command machinery certified here; the Exclusions lean on exactly that condition. ### VIOL-CORE-PERSISTREADS @@ -59,7 +59,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ## CONF-VALID — segment and tag validity -**Scope.** Workspaces with one configured spec group of one or more `.mdx` sources whose sections carry `id` and `tags` props (multi-file: T1.3-5's cross-file duplicate-ID arm builds); no imports, embeddings, `d` props, code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `build` with the error reporting of 14 for conditions 14.1–14.4 — and 14.17 as T1.3-6's invalid-form arms stage it — (file, location, condition identity with its stable code where the report form carries one — 14, 12.7 — 14.2's statement of the expected form, exit codes per 12.0) and `query node`/`query nodes` reporting identity, tags, and metadataHash. Contracts under certification: 1.3, 1.4 (with the exact character classes of SPEC.md 1.4), 2.6 tag splitting, and the masking rules of 14.1 and 14.17 over 14.2. +**Scope.** Workspaces with one configured spec group of one or more `.mdx` sources whose sections carry `id` and `tags` props (multi-file: T1.3-5's cross-file duplicate-ID arm builds); no imports, embeddings, `d` props, code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `build` with the error reporting of 14 for conditions 14.1–14.4 — and 14.17 as T1.3-6's invalid-form arms stage it — (file, location, condition identity with its stable code where the report form carries one — 14, 12.7 — 14.2's statement of the expected form, exit codes per 12.0) and `query node`/`query nodes` reporting identity, tags, and metadataHash — `nodes` with the `--tag` tag-filtered selection (11.1) T2.6-1 asserts through. Contracts under certification: 1.3, 1.4 (with the exact character classes of SPEC.md 1.4), 2.6 tag splitting, and the masking rules of 14.1 and 14.17 over 14.2. **In-scope tests:** T1.3-1, T1.3-2, T1.3-3, T1.3-4, T1.3-5, T1.3-6, T1.4-1, T1.4-2, T1.4-4, T2.6-1, T2.6-2, P-1. @@ -128,11 +128,11 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command * **Scope:** CONF-DISC. * **Deviation:** Discovery does not apply the source exclusion of 13.4: a path whose file name contains `.xspec.`, a file under `.xspec/`, or a file at an enabled Markdown emit destination, when matched by a spec-group glob, is treated as an ordinary match — a single deviation: one rule of 13.4 (derived files are never sources) dropped. Glob semantics, the dot-segment rule, link behavior, 14.19 for non-`.mdx` matches, and the import and empty-map rules are unchanged. * **Certifies:** T7-6. -* **Expected failures:** exactly T7-6 (its exclusion arms: a staged `.xspec.`-named `.mdx` file matched by a glob enters the discovered set; a file under `.xspec/` is discovered where a pattern spells the dot segment literally; with emission enabled, a glob-matched file at a source's destination is discovered or, lacking `.mdx`, reported as 14.19 — each observably failing the arm's no-error non-discovery assertion; the import and zero-source arms are untouched, and the test fails on the exclusion arms alone). T7-4 and T7-5 pass under a staging constraint: their fixtures stage no `.xspec.`-bearing names, write no pattern naming `.xspec/`, and leave `markdown` absent — and their wildcard patterns cannot reach the conformer's own graph data past the unchanged dot-segment rule. +* **Expected failures:** exactly T7-6 (its exclusion arms: a staged `.xspec.`-named `.mdx` file matched by a glob enters the discovered set; a file under `.xspec/` is discovered where a pattern spells the dot segment literally; with emission enabled, a glob-matched file at a source's destination — always a non-`.mdx` name, destinations ending `.md` (13.2) — is reported as 14.19 — each observably failing the arm's no-error non-discovery assertion; the import and zero-source arms are untouched, and the test fails on the exclusion arms alone). T7-4 and T7-5 pass under a staging constraint: their fixtures stage no `.xspec.`-bearing names, write no pattern naming `.xspec/`, leave `markdown` absent, and observe discovery only while no generated file exists — each arm's observation is a first `build`, or an `ids` no `build` precedes (refresh writes graph data alone, 13.3) — so no pattern whose reach exceeds `.mdx` names (T7-4's bare `*`) ever confronts the conformer's own next-to-source output (13.1), and their wildcard patterns cannot reach its graph data past the unchanged dot-segment rule. ## CONF-AVAIL — availability answers and JSON datum forms -**Scope.** Workspaces of configured spec groups of `.mdx` sources at valid-UTF-8, `#`-free workspace-relative paths — imports (2.1), `d` props, and `{text(...)}` embeddings as the in-scope fixtures stage them; no code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `view`, with and without `--text` (operand and `--file` forms as staged), and `occurrences` (`--file` and `--to` as staged), each answering in the form-exact 12.7 document forms. Contracts under certification: the availability rules of 11.2 — parse-local structure and positional trees (a section inside an invalid non-section element parenting per 11.4), spelled-identity definedness, interpreted tags and coverage, resolution through defined identities, whole-value expansion poisoning with own and subtree text defined through the rules of 3 (1.6; emission out of scope), and removal classification by syntactic form — occurrence records per 5.7/11.3 with `source` withheld as one datum where undefined, the `--file` domain restriction and `--to` selection of 11.3, the raw attribute and import data of 11.4, findings accompanying per 11.2/14 with stable codes and located ranges for the staged conditions (14.1, 14.3, 14.4, 14.5, 14.6, 14.9, 14.15, 14.16, 14.17), and the exit discipline of 11.2 (any finding or explicitly-unavailable datum → exit 1 with the full answer emitted; complete and finding-free → exit 0). Graph-data content and refresh behavior beyond the answers are out of scope: no in-scope test observes either. A staging constraint: T11.4-1's fixtures spell every identity uniquely and validly and stage no undefined datum — its invalid-element arm (14.16) keeps every spelled identity defined — so its answers carry the unavailability marker nowhere. +**Scope.** Workspaces of configured spec groups of `.mdx` sources at valid-UTF-8, `#`-free workspace-relative paths — imports (2.1), `d` props, and `{text(...)}` embeddings as the in-scope fixtures stage them; no code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `view`, with and without `--text` — the bare whole-domain form (neither operands nor `--file`: every discovered spec source viewed, 11.4, as T11.4-1 drives it) and the operand and `--file` forms as staged — and `occurrences` — the bare unrestricted form (no `--file`: the entire discovered set, 11.3, as T11.2-4 and T11.3-4's unrestricted arm drive it) and `--file` and `--to` as staged — each answering in the form-exact 12.7 document forms. Contracts under certification: the availability rules of 11.2 — parse-local structure and positional trees (a section inside an invalid non-section element parenting per 11.4), spelled-identity definedness, interpreted tags and coverage, resolution through defined identities, whole-value expansion poisoning with own and subtree text defined through the rules of 3 (1.6; emission out of scope), and removal classification by syntactic form — occurrence records per 5.7/11.3 with `source` withheld as one datum where undefined, the `--file` domain restriction and `--to` selection of 11.3, the raw attribute and import data of 11.4, findings accompanying per 11.2/14 with stable codes and located ranges for the staged conditions (14.1, 14.3, 14.4, 14.5, 14.6, 14.9, 14.15, 14.16, 14.17), and the exit discipline of 11.2 (any finding or explicitly-unavailable datum → exit 1 with the full answer emitted; complete and finding-free → exit 0). Graph-data content and refresh behavior beyond the answers are out of scope: no in-scope test observes either. A staging constraint: T11.4-1's fixtures stage no undefined datum — every node identity defined under 11.2's chain conditions: over valid paths, each section and each enclosing section (the positional enclosure of 11.4) spells an identity, every spelled identity well-formed (1.4), structurally conformant (1.3), and spelled by no other section of its file — its invalid-element arm (14.16) keeping every spelled identity defined — so its answers carry the unavailability marker nowhere. **In-scope tests:** T11.2-2, T11.2-4, T11.3-4, T11.4-1, T11.4-3, T11.4-4. From 6daf3a04409eb56b2f8f01c574755742ee21baaa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 18:26:12 +0000 Subject: [PATCH 044/226] sdg(phase-7): pin CONF-AVAIL command routing as staging constraint; exact CHATTYREADS and CONF-VALID rationales; account T3-6 negative half (iter 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: I1 (CONF-AVAIL scope now binds every in-scope test's commands to the enumerated view/occurrences surface — no in-scope staging drives at, and T11.2-4's record observations ride occurrences and view — mirroring CONF-CORE, so the conformer can pass C-1 under any conforming harness staging), O1 (VIOL-CORE-CHATTYREADS passing side now credits T13.5-1's seam-neutrality journal compare to identical command sequences on both twins instead of denying the compare), O2 (CONF-MD justification accounts T3-6's uncertified negative half via its positively anchored destination computation), O3 (CONF-VALID justification restated to the exact corruption-catching mechanism: 14.20 or clean build fails against the conformer; class-shifted corruption leaves the violator's expected failure unmaterialized). Rejected: none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/CERTIFICATIONS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specs/CERTIFICATIONS.md b/specs/CERTIFICATIONS.md index 10dff14..ea34956 100644 --- a/specs/CERTIFICATIONS.md +++ b/specs/CERTIFICATIONS.md @@ -47,7 +47,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command * **Scope:** CONF-CORE. * **Deviation:** `build` and the read commands modify the journal: each such invocation that is not refused as a usage or configuration error (exit 2) appends one fixed line to `.xspec/journal`, creating the file when absent. The appended line is inert to the fixture's own journal handling: read back as neither an identity mapping (5.4) nor a malformed entry (no 14.13, so no later command is gated by it, 13.3), with graph data and every other derived byte independent of how many such lines the journal holds (13.4). Mutating commands, and the entries `rename`/`move` append, are unchanged. * **Certifies:** T6.1-1, T13.4-5. -* **Expected failures:** exactly T6.1-1 (a journal file exists after `build` in a fresh workspace; the byte-compares around `build`, `check`, `coverage`, and `query` observe modification — in this git-less scope the `impact` invocation is refused exit 2 and appends nothing, and `review`'s compare observes modification only where the read subcommand chosen exits 0, as `review list` does) and T13.4-5 (its journal byte-compares under `build` and read commands fail). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched, and its review reads answer ungated — the inert lines meet no 14.13, so 13.3's gate never turns them into exit-1 non-answers; the 13.5 tests assert hold, exclusion, and derived-file behavior, not journal bytes — T13.5-4's final-`build` byte-equality to a clean build resolves derived-file inconsistency, a comparison of derived files (13.4) the inertness condition keeps independent of the two workspaces' differing appended-line counts — and T13.5-2's byte-compare covers the refused mutating command alone (the scope's bracketing constraint), which appends nothing. +* **Expected failures:** exactly T6.1-1 (a journal file exists after `build` in a fresh workspace; the byte-compares around `build`, `check`, `coverage`, and `query` observe modification — in this git-less scope the `impact` invocation is refused exit 2 and appends nothing, and `review`'s compare observes modification only where the read subcommand chosen exits 0, as `review list` does) and T13.4-5 (its journal byte-compares under `build` and read commands fail). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched, and its review reads answer ungated — the inert lines meet no 14.13, so 13.3's gate never turns them into exit-1 non-answers; the 13.5 tests' compares are unmoved by the appends: T13.5-1's seam-neutrality compare includes the journal, but its held run and no-hold twin drive the same command sequence — the seam flag rides the mutating command, which this deviation leaves unchanged — so the appends are byte-identical on both sides; T13.5-4's final-`build` byte-equality to a clean build resolves derived-file inconsistency, a comparison of derived files (13.4) the inertness condition keeps independent of the two workspaces' differing appended-line counts; and T13.5-2's byte-compare covers the refused mutating command alone (the scope's bracketing constraint), which appends nothing. * **Note:** the representative value of this violator for the suite's other never-modifies assertions (T12.0-11, T12.1-4, T13.3-3, T6.4-3, T6.5-4, T6.6-2, and the answer-side compares of T11.2-1/T11.2-6/T11.6-4) holds insofar as those assertions share the compare-around-command machinery certified here; the Exclusions lean on exactly that condition. ### VIOL-CORE-PERSISTREADS @@ -63,7 +63,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command **In-scope tests:** T1.3-1, T1.3-2, T1.3-3, T1.3-4, T1.3-5, T1.3-6, T1.4-1, T1.4-2, T1.4-4, T2.6-1, T2.6-2, P-1. -**Justification.** The 1.4 matrix is the suite's most staging-fragile negative surface: its fixtures place control characters, exotic whitespace, and boundary code points inside source bytes, where a staging accident yields a different error (14.20) and the assertion of failure passes vacuously against a product that never validates 1.4. P-1's value rests entirely on its generator reaching those classes — the criterion-(a) reachability case. +**Justification.** The 1.4 matrix is the suite's most staging-fragile negative surface: its fixtures place control characters, exotic whitespace, and boundary code points inside source bytes that tooling silently normalizes, and its negative arms assert one condition (14.4) for every 1.4 class — a corrupted arm whose byte lands in a different rejected class still sees 14.4 and passes vacuously against a product that never validates the class under test. Certification makes each corruption loud: staging that yields 14.20 or a clean build fails the arm against the conformer, and staging that no longer reaches the deviation's class leaves the violator's expected failure unmaterialized. P-1's value rests entirely on its generator reaching those classes — the criterion-(a) reachability case. ### VIOL-VALID-CTRL @@ -85,7 +85,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command **In-scope tests:** T3-1, T3-2, T3-3, T3-4, T3-5, T3-6, P-2, P-3. -**Justification.** The line-drop rule is the subtlest pure contract in SPEC.md, and its discriminating fixtures depend on exact exotic bytes (boundary code points, lone-CR terminators) that tooling silently normalizes — a corrupted fixture passes vacuously in both directions. P-2's oracle is trusted by the property suite (S-6 checks its vectors; certification checks that generated documents actually reach the discriminating classes and that the property fails when the product deviates). +**Justification.** The line-drop rule is the subtlest pure contract in SPEC.md, and its discriminating fixtures depend on exact exotic bytes (boundary code points, lone-CR terminators) that tooling silently normalizes — a corrupted fixture passes vacuously in both directions. P-2's oracle is trusted by the property suite (S-6 checks its vectors; certification checks that generated documents actually reach the discriminating classes and that the property fails when the product deviates). T3-6's negative half — no `.md` emitted with `markdown` absent or `emit: false` — is an in-scope absence-of-effect observation deliberately certified by no violator: its destination computation is positively anchored by the sibling §3 tests' byte-asserted emissions at the same next-to-source destinations under `emit: true`, and by its own `emit: true` half (13.2). ### VIOL-MD-CLASS @@ -132,7 +132,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ## CONF-AVAIL — availability answers and JSON datum forms -**Scope.** Workspaces of configured spec groups of `.mdx` sources at valid-UTF-8, `#`-free workspace-relative paths — imports (2.1), `d` props, and `{text(...)}` embeddings as the in-scope fixtures stage them; no code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `view`, with and without `--text` — the bare whole-domain form (neither operands nor `--file`: every discovered spec source viewed, 11.4, as T11.4-1 drives it) and the operand and `--file` forms as staged — and `occurrences` — the bare unrestricted form (no `--file`: the entire discovered set, 11.3, as T11.2-4 and T11.3-4's unrestricted arm drive it) and `--file` and `--to` as staged — each answering in the form-exact 12.7 document forms. Contracts under certification: the availability rules of 11.2 — parse-local structure and positional trees (a section inside an invalid non-section element parenting per 11.4), spelled-identity definedness, interpreted tags and coverage, resolution through defined identities, whole-value expansion poisoning with own and subtree text defined through the rules of 3 (1.6; emission out of scope), and removal classification by syntactic form — occurrence records per 5.7/11.3 with `source` withheld as one datum where undefined, the `--file` domain restriction and `--to` selection of 11.3, the raw attribute and import data of 11.4, findings accompanying per 11.2/14 with stable codes and located ranges for the staged conditions (14.1, 14.3, 14.4, 14.5, 14.6, 14.9, 14.15, 14.16, 14.17), and the exit discipline of 11.2 (any finding or explicitly-unavailable datum → exit 1 with the full answer emitted; complete and finding-free → exit 0). Graph-data content and refresh behavior beyond the answers are out of scope: no in-scope test observes either. A staging constraint: T11.4-1's fixtures stage no undefined datum — every node identity defined under 11.2's chain conditions: over valid paths, each section and each enclosing section (the positional enclosure of 11.4) spells an identity, every spelled identity well-formed (1.4), structurally conformant (1.3), and spelled by no other section of its file — its invalid-element arm (14.16) keeping every spelled identity defined — so its answers carry the unavailability marker nowhere. +**Scope.** Workspaces of configured spec groups of `.mdx` sources at valid-UTF-8, `#`-free workspace-relative paths — imports (2.1), `d` props, and `{text(...)}` embeddings as the in-scope fixtures stage them; no code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `view`, with and without `--text` — the bare whole-domain form (neither operands nor `--file`: every discovered spec source viewed, 11.4, as T11.4-1 drives it) and the operand and `--file` forms as staged — and `occurrences` — the bare unrestricted form (no `--file`: the entire discovered set, 11.3, as T11.2-4 and T11.3-4's unrestricted arm drive it) and `--file` and `--to` as staged — each answering in the form-exact 12.7 document forms. A staging constraint: every command the in-scope tests drive is drawn from this enumerated surface — in particular, no in-scope staging drives `at` (the 11.2 preamble's third surface, not served by this conformer), and T11.2-4's occurrence-record observations ride `occurrences` and `view`. Contracts under certification: the availability rules of 11.2 — parse-local structure and positional trees (a section inside an invalid non-section element parenting per 11.4), spelled-identity definedness, interpreted tags and coverage, resolution through defined identities, whole-value expansion poisoning with own and subtree text defined through the rules of 3 (1.6; emission out of scope), and removal classification by syntactic form — occurrence records per 5.7/11.3 with `source` withheld as one datum where undefined, the `--file` domain restriction and `--to` selection of 11.3, the raw attribute and import data of 11.4, findings accompanying per 11.2/14 with stable codes and located ranges for the staged conditions (14.1, 14.3, 14.4, 14.5, 14.6, 14.9, 14.15, 14.16, 14.17), and the exit discipline of 11.2 (any finding or explicitly-unavailable datum → exit 1 with the full answer emitted; complete and finding-free → exit 0). Graph-data content and refresh behavior beyond the answers are out of scope: no in-scope test observes either. A staging constraint: T11.4-1's fixtures stage no undefined datum — every node identity defined under 11.2's chain conditions: over valid paths, each section and each enclosing section (the positional enclosure of 11.4) spells an identity, every spelled identity well-formed (1.4), structurally conformant (1.3), and spelled by no other section of its file — its invalid-element arm (14.16) keeping every spelled identity defined — so its answers carry the unavailability marker nowhere. **In-scope tests:** T11.2-2, T11.2-4, T11.3-4, T11.4-1, T11.4-3, T11.4-4. From 5d11f16a1bab20c1af37bd8337b40ddad713f2da Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 20:04:57 +0000 Subject: [PATCH 045/226] sdg(phase-7): CERTIFICATIONS.md converged; flip patch 0001 to Stage: Tests Specified (iter 4) Iteration-4 review returned no Critical and no Important items; refinement halts with the document unchanged. Optional items not applied: - O1 rejected: T6.1-2's in-scope membership is already functionally justified (anchors EARLYWRITE's and CHATTYREADS's passing sides); per-test justification prose is a completeness motive, not a selection criterion. - O2 rejected: T13.5-4's final compare is scoped by TEST-SPEC's own wording ("derived-file inconsistency is resolved... byte-equal to a clean build"), so the derived-files reading is assertion semantics from the test's text, not an open fixture-content choice needing a staging constraint. - O3 rejected: reviewer's own finding is that nothing in the expected-failure set turns on the refresh-write question; residual clarity polish only. specs/tmp/REVIEW.md deleted; no CERTIFICATIONS-PROBLEMS.md existed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index 141ee5d..211b995 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -1,7 +1,7 @@ # 0001 — Foundational machine surfaces for an external spec UI - **Type:** Improvement Proposal (IP) -- **Stage:** Applied +- **Stage:** Tests Specified - **Branch:** `claude/xspec-ui-apis-4df8fa` (harness-designated for this session; stands in for `patch/external-ui-apis`) ## Motivation From 8294929d2190a3d1ae24257122a5d62fc56ee8be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 20:15:32 +0000 Subject: [PATCH 046/226] =?UTF-8?q?sdg(phase-8):=20run=20CI=20on=20pushes?= =?UTF-8?q?=20to=20the=20patch=20branch=20=E2=80=94=20its=20conflicted=20P?= =?UTF-8?q?R=20gets=20no=20pull=5Frequest=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolding audit for IP 0001 (Tests Specified): the existing layout, vitest projects, npm scripts, network-denial wrapper, and E-6 exchange wiring already cover every TEST-SPEC/CERTIFICATIONS addition; new tests and fixtures are Phase 9/10 content under existing globs. The one gap: PR #7 is unmergeable against main (specs/PHILOSOPHY.md conflict), and GitHub creates no pull_request-event workflow runs for a conflicted PR (zero runs across 45 commits). Add the working branch to ci.yml's push trigger so the TEST-SPEC-required tests run on every push to the branch head — the channel sdg/initial-build already used — with checks attaching to the PR's head commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c5e5d..07e8601 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,16 @@ name: CI on: pull_request: + # GitHub creates no pull_request-event runs while a PR is unmergeable. + # PR #7 (patch 0001, branch claude/xspec-ui-apis-4df8fa, standing in for + # patch/external-ui-apis) is conflicted with main (specs/PHILOSOPHY.md), so + # its CI signal comes from push-event runs on the branch head instead — + # same workflow, same tree; checks attach to the head commit and surface on + # the PR. Drop that branch from this list once its PR is mergeable again or + # the patch completes. (Same channel sdg/initial-build used, kept for + # history.) push: - branches: [main, sdg/initial-build] + branches: [main, sdg/initial-build, claude/xspec-ui-apis-4df8fa] workflow_dispatch: concurrency: From 1c8747ed1aa6ae70be510db353f806c868d04ccb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 20:58:44 +0000 Subject: [PATCH 047/226] sdg(phase-9): plan 93 harness compliance tasks into FIX_PLAN.md Convert the three compliance reviews (TEST-SPEC 1-9, TEST-SPEC 10-18 + cross-cutting, CERTIFICATIONS) and the red verify run at 8294929 into a flat, ordered task list: foundations (literal 12.7 findings decode, exit-2 stream protocol, H-7 universe re-pin), contradicting-assertion fixes, stable-code sweeps, missing arms, missing tests (5.7, 6.5-6.7, 10-14, 11.2-11.6, 12.6-12.7), property layer + S-6 oracles, the CONF-AVAIL certification family, and the E-6 legs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 697 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 697 insertions(+) create mode 100644 specs/tmp/FIX_PLAN.md diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md new file mode 100644 index 0000000..0fce13b --- /dev/null +++ b/specs/tmp/FIX_PLAN.md @@ -0,0 +1,697 @@ +# FIX_PLAN — Phase 9 (test harness vs TEST-SPEC.md + CERTIFICATIONS.md) + +Planned 2026-08-10 from three compliance reviews (R1: TEST-SPEC §§1–9; R2: +TEST-SPEC §§10–18 + cross-cutting; R3: CERTIFICATIONS.md) and the red VERIFY +run at sha 8294929 (`npm run test:self`: 5 failed / 222 passed — +certification-document ×3, s1-traceability ×2). Citations `[R1 #n]`, +`[R2 #n]`, `[R3 gap n]`, `[VERIFY]` name the finding a task satisfies; +TEST-SPEC.md / CERTIFICATIONS.md / SPEC.md remain the sole authority for the +full requirement text — task summaries locate the gap, they do not replace +the spec. + +## How to work this plan + +- Phase 9 scope guard: **never modify product code** (`src/`). Everything + here is harness work under `test/` (fixtures included) plus this file. +- Take tasks top to bottom unless a task's "after:" note says otherwise. + Mark a completed task `[x]` in the same commit that completes it. Never + delete tasks; append new ones at the end if new findings arrive. +- Keep each spawn small and complete: one task (or one task plus its + explicitly paired partner) per spawn is the intended grain. +- Verify with `npm run build && npm run test:self` (the phase gate) and, for + suite subsets, `npx vitest run --config test/vitest.config.ts --project + suite test/suite/.test.ts` (see AGENTS.md). Product-facing tests are + expected to FAIL against the stub product (red-as-diagnosed, H-8) — a new + or changed product-facing test is verified by (a) failing as diagnosed + against the stub, (b) certifying green where a CERTIFICATIONS.md fixture + has it in scope, (c) self-tests (S-1..S-7, C-1) staying/going green per the + task's "verify" note. +- Expected mid-loop redness: `test:self` stays partially red until the whole + plan lands (S-1 unmapped-key list shrinks as new tests register; the + certification-document gate stays red until FP-091). Each task's verify + note says which failures it must clear and which may remain. + +## Conventions for every new-test task (stages E, G, H) + +A "new test T" task always means, in one change: + +1. Implement the test body per its TEST-SPEC entry (title, arms, budget) in + the section's registry module `test/suite/registry/section-.ts` + (create the module if the section is new, export a + `readonly ProductTestEntry[]`, and spread it into + `test/suite/registry/index.ts`). +2. Declare it in the matching `test/suite/section-.test.ts` via + `declareProductTests` (create the thin wrapper file for new sections). +3. Add its `H7_TRACEABILITY` entry in + `test/suite/registry/traceability.ts` (home passage per H-7, plus "14" + when it asserts a numbered condition, plus any cross-section passages its + TEST-SPEC entry states). +4. Decode outputs through the H-3 adapter layer — except findings arrays, + findings-only reports, and other 12.7 form-exact surfaces, which must be + decoded literally (FP-001's layer). +5. Confirm red-as-diagnosed against the stub product, and note any + CERTIFICATIONS.md scope (the six CONF-AVAIL in-scope tests are called out + in their tasks). + +--- + +## Stage A — foundations (do these first; many tasks depend on them) + +- [ ] FP-001 — Rebuild the findings decode layer as a literal, form-exact + SPEC 12.7 decode; add the three-state datum decode; add the S-5 guards. + [R1 #28, R2 #18, R2 #41; SPEC 12.7, TEST-SPEC §0 H-3 (amended), §17 S-5] + - `test/helpers/adapters/reports.ts` (`decodeFinding`, ASSUMED-SHAPE + comment at top): the current assumed finding shape + `{"condition":"14.N","message","file"?,"location"?,...}` is wrong. SPEC + 12.7 fixes findings as `{"code","message","locations","path", + "identities"}` with stable token codes, and amended H-3 makes findings + arrays and findings-only reports form-exact surfaces where no adapter + may re-map member names or shapes. Rebuild the findings assertion layer + as a literal 12.7 decode: exact member names, `null`-vs-omission rules, + `[]`-vs-`null` rules, the pinned findings-order comparator, duplicate + collapse. + - `test/helpers/adapters/model.ts` (`Finding` interface, ~line 183): + findings must carry the stable token `code`; keep a `14.N` condition + identity only as a value derived through a harness-pinned SPEC §14 + token→condition table so existing condition-identity assertions can be + expressed against tokens. + - Add a three-state datum decode helper (`plain` value / `null` / + `{"unavailable": true}`) under `test/helpers/` — nothing decodes that + triple today; T12.7-1 (FP-075) and the §11 tests need it. + - `test/self/s5-output-adapters.test.ts`: add guards for the literal 12.7 + findings decode and for the structural-unavailability walk T12.7-1 + relies on. + - Update every compile-affected call site in the same change (suite + registry modules import the decoder widely). + - Verify: `npm run test:self` — S-5 green; certification must not regress + on finding shape: if a conformer fixture emits old-shape findings on an + in-scope path, apply the minimal 12.7 form change to that fixture here + (CONF-VALID's full behavioral rework stays FP-009). + +- [ ] FP-002 — Fix the exit-2 output-stream protocol everywhere it is + asserted: with JSON output in effect, exit-2 stdout is exactly one 12.7 + error document (`{"error": …}`); stdout is byte-empty on exit 2 only when + JSON is NOT in effect; stderr is byte-identical across output forms. + [R1 #26, R2 #17; SPEC 12.0/12.7, TEST-SPEC §0 H-5, T12.0-2] + - `test/helpers/assertions.ts` `assertJsonOutputConvention` (~line 175) + currently enforces "exit 2 → stdout byte-empty under `--json`" — invert + to the error-document contract. + - Call sites to sweep: `test/suite/registry/section-16-p8.ts` (P-8), + `section-12.0-i.ts` (T12.0-2 — also ADD its missing stderr-invariance + arm: stderr byte-identical across output forms), `section-6.3.ts` ~line + 233 (T6.3-4), `section-6.4.ts` ~line 348 (T6.4-4), `section-6.5.ts` + ~line 409 (T6.5-5), `test/suite/registry/support.ts` + `expectConfigurationError` ~line 95 (asserts `assertStdoutEmpty` on exit + 2 — must instead assert the single 12.7 error document; users T7-1, + T7-2, T7-3, T7-4, T7.2-1, T7.3-1, T7.4-1, T7.5-1), `section-7.4-7.5.ts` + ~line 590 (T7.4-1 unknown-profile arm), `section-7-basics.ts` ~line 733 + (T7-3 unknown `--from` arm). + - Verify: affected suite files compile and fail only as red-as-diagnosed + product assertions; `npm run test:self` no worse. + +- [ ] FP-003 — Re-pin the H-7/S-1 traceability universe and apply the + renumber/remap fixes that need no new tests. + [VERIFY s1-traceability, R2 #39, R1 #12; TEST-SPEC §0 H-7, §17 S-1] + - `test/self/s1-traceability.test.ts`: `EXPECTED_KEY_COUNT` 71 → 81 (line + 44) and the "preamble + 60 subsections" comment (line 39) → 70 + subsections (SPEC.md now adds subsections 5.7, 6.7, 11.1–11.6, 12.6, + 12.7). `EXPECTED_SECTION_COUNT` stays 15. + - `test/suite/registry/section-6.6.ts` (`defineProductTest` at ~line 363): + the manual-restructuring test exists under the retired ID `T6.6-1`; + re-register it as `T6.7-1` (SPEC/TEST-SPEC renumber: manual + restructuring moved 6.6 → 6.7; new §6.6 is the preview command). Update + its title's SPEC citations accordingly and the traceability entry + `"T6.6-1": ["6.6"]` (traceability.ts ~line 194) → `"T6.7-1": ["6.7"]`. + - `test/suite/registry/traceability.ts`: remap T11-1..T11-7 from key + `"11"` to `"11.1"` while keeping SPEC §11's body key `"11"` covered + (assign §11-body coverage to the tests that assert it — §11 now has + subsections, so fix the header comment claiming 11 has none, and review + `SPEC_BODY_TEXT_KEY_SECTIONS` against amended H-7). + - Note in the map comment that T12.0-10 stops being alias-only once + FP-039 lands (its own arms make it an implemented test). + - Verify: S-1's universe-count assertion goes green; its unmapped-key + failure narrows to exactly {5.7, 11.2, 11.3, 11.4, 11.5, 11.6, 12.6, + 12.7} (6.7 and 11.1 become mapped here) and stays red until stages E/G + land — state left red on purpose. + +## Stage B — existing assertions that contradict the current spec + +- [ ] FP-004 — Invert T10.7-12's code-impact-scope range assertion. + [R2 #19; SPEC 10.7, 1.7] + `test/suite/registry/section-10.7-ii.ts` ~line 638 asserts a present + `code-impact` scope "must carry no source range". Current SPEC 10.7/1.7: + a PRESENT code-location scope carries its source range; only a DELETED + location's entry carries none. Invert the assertion and stage both sides + (present location → range asserted byte-precisely; deleted location → + no range). Verify: red-as-diagnosed against stub; no self-test change. + +- [ ] FP-005 — Re-stage the two `move` destination spellings that changed + exit class from refusal (exit 1) to usage error (exit 2). + [R1 #25, non-UTF-8 half of R1 #22; TEST-SPEC T6.5-4 dead-letter note, + T6.5-5] + `test/suite/registry/section-6.5.ts`: the `#`-containing destination + (`specs/Ha#sh.mdx`, ~line 1476) and the non-UTF-8 destination (~line + 1499) are currently asserted as exit-1 refusals under T6.5-4; TEST-SPEC + now classifies both as exit-2 usage errors under T6.5-5. Move the arms to + T6.5-5 and assert exit 2 with the FP-002 protocol (single 12.7 error + document under `--json`), workspace unmodified. Verify: red-as-diagnosed; + T6.5-4 retains no exit-class contradiction. + +## Stage C — §§1–9 cross-cutting assertion sweeps (after FP-001) + +- [ ] FP-006 — Sweep §§1–9 condition assertions to assert stable code + tokens and the literal 12.7 finding form. [R1 #27, #28; TEST-SPEC §0 + ("where §14 assigns the condition or refusal reason a stable code, assert + that exact code string"), SPEC 12.7, 14] + No §§1–9 test asserts the SPEC 14 token strings today — the model carries + only `condition: "14.N"`. Using FP-001's layer, make every §§1–9 test that + asserts a numbered condition assert the exact token code string and + decode the finding's 12.7 members literally (`code`, `message`, + `locations`, `path`, `identities`). Sweep the registry modules for + sections 1–5, 7, 8, 9 (section 6 refusals are FP-007). Verify: affected + files red-as-diagnosed only; S-5/S-1 unaffected. + +- [ ] FP-007 — Make §6 refusal assertions assert stable refusal codes with + their concerned file/range/identity. [R1 #27; TEST-SPEC §§6.4–6.5, SPEC + 12.7, 14] + `expectRefusalModifiesNothing` (defined in + `test/suite/registry/section-6.4.ts` ~line 303 and used across + section-6.4.ts / section-6.5.ts) asserts only exit 1 plus + workspace-unchanged. Extend it (or its call sites) so each refusal arm + asserts the exact stable refusal code and the concerned file/range/ + identity of the refusal finding, most acutely T6.4-3, T6.5-4, T6.5-6. + Verify: red-as-diagnosed only. + +## Stage D — §§1–9 missing arms, with paired certification-fixture reworks + +- [ ] FP-008 — T1.3-6: add the invalid-`id`-form arms. [R1 #13; TEST-SPEC + §1.3, SPEC 14.17] + `test/suite/registry/section-1.3.ts` has no 14.17 arm. Add: a + repeated-`id` bearer and a braced-`id` bearer (`id={"x"}`), each reporting + 14.17 and never 14.1, masking 14.2 for its immediate children, while + grandchildren's structural checks still report. Pair with FP-009 (the + CONF-VALID conformer must pass this arm once both land — certification + for the family may be red between the two commits; prefer one spawn). + +- [ ] FP-009 — Rework CONF-VALID to its refreshed CERTIFICATIONS.md scope. + [R3 gap 4; CERTIFICATIONS.md CONF-VALID, SPEC 12.7, 14.17] + `test/fixtures/conf-valid/product.mjs` (+ `bin-ctrl.mjs`/`bin-wide.mjs` + deviations unchanged): (a) add the condition-17 path — today it emits + only 14.1–14.4/14.20; (b) repeated `id` must report 14.17 and never 14.1 + (today it silently takes the last value), masking 14.2 for immediate + children; (c) braced `id={"x"}` must report 14.17 (today 14.20); (d) emit + findings in the 12.7 form with stable token codes (today + `{"condition","file","location","message"}` with no token). After: FP-001, + FP-008. Verify: `npm run test:self` — CONF-VALID conformer passes all + in-scope tests, its violators still fail at least one certified test. + +- [ ] FP-010 — T3-1: add the grammar-boundary arm. [R1 #15; TEST-SPEC §3] + `test/suite/registry/section-3.ts` — the T3-1 fixture's fence (~line 92) + contains only plain text. Add fences and an inline code span containing + ``, `
`, `import X from "./X.xspec"`, `{text("a")}`, and + assert: no node, no edge, no finding, bytes preserved byte-for-byte. + Pair with FP-011 (same red-window note as FP-008/FP-009). + +- [ ] FP-011 — Rework CONF-MD to its refreshed CERTIFICATIONS.md scope. + [R3 gap 3; CERTIFICATIONS.md CONF-MD] + `test/fixtures/conf-md/product.mjs` (+ `bin-class.mjs`/`bin-cr.mjs`): + (a) support `check` with exit 0 on T3-1's grammar-boundary staging (today: + exit 2 "unknown command"); (b) support `query nodes`/`query edges` with + no-node/no-edge reports (today exit 2); (c) give `parseMdx` fence and + inline-code-span lexer state so `` inside a fence is literal + content — no node, no edge, no finding (today a spurious 14.20), bytes + preserved. After: FP-010. Verify: CONF-MD conformer green on in-scope + tests incl. T3-1's new arm; violators still certify. + +- [ ] FP-012 — T1.7-1: add the bare-identity edge-endpoint arms. [R1 #14; + TEST-SPEC §1.7] + `test/suite/registry/section-1.6-1.7.ts` asserts only `query node`/`show` + ranges; no code location, no `reachable` anywhere in the file. Add arms + asserting endpoints-as-identities-alone on (a) `edges` rows, (b) a + `reachable` witness path, and (c) `query node`'s incoming/outgoing edge + lists — each traversing a code location. + +- [ ] FP-013 — T4.3-2: add the zero-argument and two-argument `text(...)` + arms. [R1 #16; TEST-SPEC §4.3, SPEC 14.8] + `test/suite/registry/section-4.3-4.4.ts` (~line 294) has only the + string/computed-index/optional-chaining arms. Add `text()` and + `text("a","b")` calls in a TypeScript file, each → 14.8. + +- [ ] FP-014 — T4.5-2: add the upstream (cross-file) impact arm. [R1 #17; + TEST-SPEC §4.5] + `test/suite/registry/section-4.5.ts` stages only the same-document + subtreeHash edit (direct impact). Add: the marker's document bears a + root-sourced `{text(...)}` edge into another file; an edit THERE changing + only that root's effectiveHash leaves the location transitively impacted + while no node of the marker's own document is `changed`. + +- [ ] FP-015 — T6.4-1: assert the rename command's own report — the applied + mapping. [R1 #18; TEST-SPEC §6.4, SPEC 12.0, H-3] + `test/suite/registry/section-6.4.ts` asserts journal append and rewrites + only; rename stdout is never decoded. Add: decode rename's stdout (JSON + per 12.0, H-3 adapter) and assert the report is the applied mapping — + every journaled identity pair. + +- [ ] FP-016 — T6.4-4: add the wrong-kind and parse-local old-ID-existence + arms. [R1 #19; TEST-SPEC §6.4] + `test/suite/registry/section-6.4.ts`, none present today: (a) discovered + code source passed as `` → exit 2; (b) duplicate-spelling bearers → + exit 1 via the duplicate-ID finding; (c) bearer beneath an ancestor + spelling no identity → exit 1; (d) sole would-be bearer spelling no + identity (repeated `id` attribute) → exit 2 beside that file's findings. + +- [ ] FP-017 — T6.5-1: assert the applied-mapping report for the file-form + move (both forms report as rename does). [R1 #20; TEST-SPEC §6.5] + `test/suite/registry/section-6.5.ts`: decode the move command's stdout in + both file form and section form and assert the applied mapping, exactly + as FP-015 does for rename. + +- [ ] FP-018 — T6.5-4: add the missing destination-refusal arms. [R1 #21; + TEST-SPEC §6.5] + `test/suite/registry/section-6.5.ts` (plain-file arm at ~line 1435 is the + only occupancy arm): add (a) file-form destination occupied by a symbolic + link and by a broken symbolic link; (b) section-form target path occupied + by a directory, by a symlink resolving to a discovered spec source, and + by an out-of-group `.mdx` file — the latter asserting BOTH + `refused-invalid-destination` and `refused-destination-exists`; (c) empty + `` (`b.mdx#`) refused `refused-invalid-id`; (d) the derived-path + arm of `refused-invalid-destination`: emission under `markdown.outDir` + with emit-destination component `/new` occupied by a plain file. + Assert stable refusal codes per FP-007. + +- [ ] FP-019 — T6.5-5: add the missing usage-error arms. [R1 #22; TEST-SPEC + §6.5] + `test/suite/registry/section-6.5.ts` (~line 1580 area has only + nonexistent origin file/ID, ordering, masking): add (a) wrong-kind + (code-source) origin in each form; (b) the three mixed-synopsis + invocations `a.mdx b.mdx#y`, `a.mdx#x b.mdx`, `a.mdx b#c.mdx` → exit 2; + (c) parse-local ID existence arms mirroring T6.4-4 (FP-016). (The + non-UTF-8 destination operand arm arrives via FP-005's restage.) Exit-2 + protocol per FP-002. + +- [ ] FP-020 — T7-2: add the string-literal group-name keys arm. [R1 #23; + TEST-SPEC §7] + Neither `test/suite/registry/section-7-basics.ts` nor + `section-7.4-7.5.ts` stages quoted keys. Add: group names as string + literals (`"my-group"`/`"test-code"`) load, discover, and resolve in a + coverage profile and in a policy selector. + +- [ ] FP-021 — T7.5-5: add the literal-`$` forms arm. [R1 #24; TEST-SPEC + §7.5, SPEC 14.14] + `test/suite/registry/section-7.4-7.5.ts`: stage `$0`, trailing `$`, and + `$` before a non-digit, each in `from` and in `to`; assert they load + without 14.14 and match only the literal bytes. + +## Stage E — §§1–9 missing tests (new-test convention applies) + +- [ ] FP-022 — Implement T1.7-2: code-location ranges via occurrence + records. [R1 #1; TEST-SPEC §1.7] + First harness use of `xspec occurrences` (it is never invoked anywhere + today). Assert ranges against precomputed byte offsets for: whole-file, + function/class, multi-declaration variable, dotted namespace, default + exports, and `@2` disambiguation. Registry module + `section-1.6-1.7.ts`; map `"1.7"`. + +- [ ] FP-023 — Implement T5.7-1: occurrence units and duplicates. [R1 #2; + TEST-SPEC §5.7] + One record per `d` array entry / embedding / call / marker; collapsed + edges vs distinct occurrence records. New registry module for §5.7 (e.g. + `test/suite/registry/section-5.7.ts` + suite wrapper); map `"5.7"`. + +- [ ] FP-024 — Implement T5.7-2: byte-precise occurrence spans per kind. + [R1 #3; TEST-SPEC §5.7] + Array-entry expression only; the whole braced `{text(...)}` container; + callee-through-paren; bare marker chain without `;`. + +- [ ] FP-025 — Implement T5.7-3: occurrence record data and total + deterministic order. [R1 #4; TEST-SPEC §5.7, H-6] + Record members: file, range, edge kind, source node as + identity-plus-range, target identity. Total order: path bytes, range + start, range end. + +- [ ] FP-026 — Implement T5.7-4: no-occurrence constructs and the exit-1 + answer carrying the domain's findings. [R1 #5; TEST-SPEC §5.7] + Imports, type-only uses, shadowed chains, dynamic/unresolving spellings + produce no records; the exit-1 answer still carries the domain's + findings. + +- [ ] FP-027 — Implement T6.5-7: operation-side rewrite bytes for the real + move. [R1 #6; TEST-SPEC §6.5] + Import-removal extents (own-line import dropped with terminator; + shared-line declaration's own characters only), double-quoted conversion + spellings, preserved single-quote local reference — whole files + byte-asserted against composed expected bytes. Registry + `section-6.5.ts`; map `"6.5"`. + +- [ ] FP-028 — Implement T6.6-2: preview is inert and predictive. [R1 #7; + TEST-SPEC §6.6 (new preview section)] + Preview modifies nothing; a subsequent real run's applied mapping equals + the preview's `mapping`; byte-determinism; form-exact 12.7 preview + document under `--json`. New-§6.6 tests live beside the renamed T6.7-1 + (FP-003) — keep registry module naming coherent with + `test/suite/registry/index.ts` imports; map `"6.6"`. + +- [ ] FP-029 — Implement T6.6-3: refusal/usage-error equivalence under + `--preview`. [R1 #8; TEST-SPEC §6.6] + Same stable codes as the real operation; exit 1 with `mapping`/`files`/ + `delta` null; exit 2 identically; preview runs to completion under + `--test-hold` held by another command; `--test-hold` + `--preview` is a + usage error. + +- [ ] FP-030 — Implement T6.6-4: preview report content — the ten 12.7 edit + classes. [R1 #9; TEST-SPEC §6.6, SPEC 12.7] + All ten edit classes with byte-precise pre-operation ranges, + class-plus-range only, tie-break comparator. + +- [ ] FP-031 — Implement T6.6-5: derived-file delta both directions, + record-based. [R1 #10; TEST-SPEC §6.6] + Not presence-based: with graph data deleted, `generated` approaches the + full set and the preview still writes nothing. + +- [ ] FP-032 — Implement T6.6-6: preview under a corrupt graph record. + [R1 #11; TEST-SPEC §6.6, SPEC 14.23] + Full preview with `delta` explicitly unavailable, a condition-23 finding + (`unreadable-record`, concerned path the graph-data area), exit 1; the + real operation proceeds; a refused preview reports refusal findings + alone. Share the corrupt-record staging with FP-041 (T12.2-2's + unreadable-record arm reuses it). + +## Stage F — §§10–14 missing arms + +- [ ] FP-033 — T10.1-4: stage the "malformed recorded decompositions" + corrupt-state arm. [R2 #20; TEST-SPEC §10.1] + `test/suite/registry/section-10.1.ts`. + +- [ ] FP-034 — T10.4-2: add the non-scope presence recordings. [R2 #21; + TEST-SPEC §10.4] + `test/suite/registry/section-10.4.ts`: context arm + (`metadata-consistency`, removed target `T` re-authored) and origin arm + (`dependency-consistency`, origin `D` deleted with hashes/context set + unchanged). + +- [ ] FP-035 — T10.7-7: assert payload source ranges for EVERY present + node. [R2 #22; TEST-SPEC §10.7] + Currently asserted "for present requirement nodes" only + (section-10.7-i/ii — locate the T10.7-7 body): cover every present + node's, requirement node and present code location alike; none for + absent nodes. + +- [ ] FP-036 — T11-6: add the wrong-kind / unknown-unit / disambiguator + arms. [R2 #23; TEST-SPEC §11.1] + `test/suite/registry/section-11.ts`: (a) `query node`/`show` on a + code-group `path`/`path#unit` → exit 2; (b) `query edges --from/--to` and + `reachable --from/--to` with `#unspelled-unit` → exit 2; (c) out-of-range + `@2`; (d) `@1` unknown at every occurrence count, staged at one and at + two occurrences. + +- [ ] FP-037 — T12.0-1/-3/-4: extend the shared command sweep with the new + surfaces. [R2 #24; TEST-SPEC §12.0] + `test/suite/registry/section-12.0-i.ts` `SWEEP_STEPS` (~line 235): add + `occurrences`, `view`, `at`, `inventory`, `version`. T12.0-1 must also + assert the JSON-only surfaces emit the same single document with the + `--json` flag as without. + +- [ ] FP-038 — T12.0-9: add the new exit-partition representatives. + [R2 #25; TEST-SPEC §12.0] + Exit 0: `version`, clean `occurrences`/`view`/`at`, `inventory`, + successful previews. Exit 1: refused previews and answers carrying + findings/explicitly-unavailable data (emitted in full). Exit 2: + wrong-kind operands and the `occurrences --to` malformed-only exception. + +- [ ] FP-039 — T12.0-10: implement the precedence arms (test stops being + alias-only). [R2 #26; TEST-SPEC §12.0] + Add: gated-read usage-error precedence on a failing workspace with the + valid-twin comparison; the `show #id` masking arm; the + past-the-gate corrupt-session `resolve` arm; the within-class-2 + no-configuration arms; the configuration-error-precedes arm. Update its + H-7 entry (see FP-003's note). + +- [ ] FP-040 — T12.0-12: extend the git-less sweep. [R2 #27; TEST-SPEC + §12.0] + Add `occurrences`, `view`, `at`, `inventory`, `version`, and the + `--preview` invocations of `rename`/`move`. + +- [ ] FP-041 — T12.2-2: add occupant-kind staleness and graph-data + unit-form arms. [R2 #28; TEST-SPEC §12.2] + `test/suite/registry/section-12.1-12.2.ts`: occupant kinds (symlink to a + byte-identical target; directory); graph-data unit forms — missing + (isolated: exactly one condition-10 unit-form finding, no per-file + finding), mismatch (isolated via refresh-then-revert), unreadable-record + (FP-032's staging → unit form alone; `build` replaces; `check` clean; + `inventory` recovers). + +- [ ] FP-042 — T12.2-3: pin never-refreshes per state. [R2 #29; TEST-SPEC + §12.2] + Missing-arm state (graph data stays absent), isolated mismatch state, + combined per-file+unit state. + +- [ ] FP-043 — T12.5-1: extend the dispatch sweep. [R2 #30; TEST-SPEC + §12.5] + `test/suite/registry/section-12.3-12.5.ts`: add `occurrences`, `view`, + `at`, `inventory`. + +- [ ] FP-044 — T13.3-1/T13.3-2: extend read sweeps; add the + record-discipline arm. [R2 #31; TEST-SPEC §13.3] + `test/suite/registry/section-13.3.ts`: sweeps gain `occurrences`, `view`, + `at`. T13.3-2 gains: shape-blind record corruption → refreshing reads + answer finding-free exit 0, state neither read nor replaced, `inventory` + reports `recorded` unavailable until `build`. + +- [ ] FP-045 — T13.3-3: add the whole-gate arms and the never-gated + contrast. [R2 #32; TEST-SPEC §13.3] + Whole-gate: garbage journal line (14.13) and obstructed write path + (14.22) — each gated read reports it, exits 1, answers nothing, modifies + nothing. Never-gated contrast: `occurrences`/`view`/`at` answering per + SPEC 11.2 and `inventory` answering, on the same workspaces. + +- [ ] FP-046 — T13.4-6: add plain-file occupant and finding-cardinality + arms. [R2 #33; TEST-SPEC §13.4] + `test/suite/registry/section-13.4.ts`: occupants — a `build` write-path + directory component; a first-emission `outDir` component. Cardinality — + one component refusing two writes → one finding; two components → two + findings, via `check`. + +- [ ] FP-047 — T13.5-1: add the seam-neutrality arm. [R2 #34; TEST-SPEC + §13.5] + `test/suite/registry/section-13.5.ts`: held-then-released final workspace + state byte-identical to the same operation without `--test-hold` on an + identical twin. + +- [ ] FP-048 — T14-4: extend the reporter matrix. [R2 #35; TEST-SPEC §14] + `test/suite/registry/section-14.ts`: 14.21 by `check` alone beside gate + findings; the 14.23 row (`inventory` + previews only, `check` as 14.10 + unit form, `build`/refreshing reads never); 14.14 never `version`; + 14.13/14.22 by gated reads yet accompanying no `occurrences`/`view`/`at` + answer; every other condition accompanying `occurrences`/`view`/`at` + answers per domain (all three for spec-source stagings; `occurrences` + alone for 14.7/14.11/14.18). + +## Stage G — §§10–14 missing tests (new-test convention applies) + +CONF-AVAIL staging constraints (CERTIFICATIONS.md, binds the 11.x bodies +below): in-scope stagings drive only the enumerated surface — never `at`; +T11.2-4's record observations ride `occurrences`/`view`; T11.4-1 stages no +undefined datum. The six in-scope tests are marked (CONF-AVAIL) — they must +certify against FP-091's fixtures once those land. + +- [ ] FP-049 — Implement T10.1-5: failing-workspace gate precedence over + session corruption, with the `check`-reports-14.21-beside-gate-findings + discriminating pair. [R2 #1; TEST-SPEC §10.1] Registry + `section-10.1.ts`; map `"10.1"` (+ `"14"`). + +- [ ] FP-050 — Implement T11.2-1: parse-local structure and per-file + masking with no writes. [R2 #2; TEST-SPEC §11.2] New registry module(s) + for §11.2 (+ suite wrapper, index import); map `"11.2"`. +- [ ] FP-051 — Implement T11.2-2 (CONF-AVAIL): spelled-identity / + interpreted-data definedness matrix. [R2 #2, R3 gap 1, VERIFY; TEST-SPEC + §11.2] +- [ ] FP-052 — Implement T11.2-3: invalid paths (Linux leg). [R2 #2; + TEST-SPEC §11.2] +- [ ] FP-053 — Implement T11.2-4 (CONF-AVAIL): resolution and expanded-text + poisoning; record observations ride `occurrences`/`view`. [R2 #2, R3 gap + 1, VERIFY; TEST-SPEC §11.2] +- [ ] FP-054 — Implement T11.2-5: domain/findings/exit discipline. [R2 #2; + TEST-SPEC §11.2] +- [ ] FP-055 — Implement T11.2-6: never-stale + gate-findings-never-attach. + [R2 #2; TEST-SPEC §11.2] + +- [ ] FP-056 — Implement T11.3-1: `occurrences` enumeration in the + form-exact 12.7 record form. [R2 #3; TEST-SPEC §11.3] New §11.3 registry + module; map `"11.3"`. Uses FP-001's literal decode. +- [ ] FP-057 — Implement T11.3-2: `--file` set restriction. [R2 #3; + TEST-SPEC §11.3] +- [ ] FP-058 — Implement T11.3-3: `--to` syntactic acceptance / malformed + spellings. [R2 #3; TEST-SPEC §11.3] +- [ ] FP-059 — Implement T11.3-4 (CONF-AVAIL): definitive emptiness. + [R2 #3, R3 gap 1 (VIOL-AVAIL-NOFILE certifies exactly this), VERIFY; + TEST-SPEC §11.3] + +- [ ] FP-060 — Implement T11.4-1 (CONF-AVAIL): whole-domain views and + positional tree with tag-range decomposition byte-asserted; stages no + undefined datum. [R2 #4, R3 gap 1, VERIFY; TEST-SPEC §11.4] New §11.4 + registry module; map `"11.4"`. +- [ ] FP-061 — Implement T11.4-2: operands-vs-restriction. [R2 #4; + TEST-SPEC §11.4] +- [ ] FP-062 — Implement T11.4-3 (CONF-AVAIL): raw attributes and per-node + data with stated-`null` root `tags`/`coverage`. [R2 #4, R3 gap 1, VERIFY; + TEST-SPEC §11.4] +- [ ] FP-063 — Implement T11.4-4 (CONF-AVAIL): imports datum. [R2 #4, R3 + gap 1, VERIFY; TEST-SPEC §11.4] +- [ ] FP-064 — Implement T11.4-5: `--text` expansion domain. [R2 #4; + TEST-SPEC §11.4] +- [ ] FP-065 — Implement T11.4-6: byte classification reproducing compiled + Markdown via the P-2 oracle (`test/helpers/oracles/markdown.ts`). + [R2 #4; TEST-SPEC §11.4] + +- [ ] FP-066 — Implement T11.5-1: total `at` resolution incl. EOF offset + and derivability from view data. [R2 #5; TEST-SPEC §11.5] New §11.5 + registry module; map `"11.5"`. +- [ ] FP-067 — Implement T11.5-2: offset spelling matrix. [R2 #5; TEST-SPEC + §11.5] +- [ ] FP-068 — Implement T11.5-3: occurrence containment ends and imperfect + files. [R2 #5; TEST-SPEC §11.5] + +- [ ] FP-069 — Implement T11.6-1: `inventory` anchoring byte-exact, incl. + the E-6 drive-mismatch arm (Linux side; the Windows-subset arm is + FP-093). [R2 #6; TEST-SPEC §11.6, E-6] New §11.6 registry module; map + `"11.6"`. +- [ ] FP-070 — Implement T11.6-2: resolved configuration/sources/derived + map. [R2 #6; TEST-SPEC §11.6] +- [ ] FP-071 — Implement T11.6-3: record, area, durables, orders. [R2 #6; + TEST-SPEC §11.6] +- [ ] FP-072 — Implement T11.6-4: no-parse/no-write/one-finding + (condition-23, `recorded` unavailable). [R2 #6; TEST-SPEC §11.6, SPEC + 14.23] + +- [ ] FP-073 — Implement T12.0-13: multi-`#` operand malformedness vs `#` + in ``/`--file` values (`specs/a#b.mdx` staging). [R2 #7; TEST-SPEC + §12.0] Registry `section-12.0-i.ts` or `-ii.ts`; map `"12.0"`. + +- [ ] FP-074 — Implement T12.6-1 and T12.6-2: the `version` command. + [R2 #8; TEST-SPEC §12.6] Form-exact `{"product","interface"}` with + `interface` exactly `"1"`; workspace/configuration independence with the + `build`-exits-2 discriminating pair. New §12.6 registry module (may share + a module/wrapper with §12.7 tests); map `"12.6"`. + +- [ ] FP-075 — Implement T12.7-1: 12.7 value forms — range, byte-form + paths, the `{"unavailable": true}` uniqueness walk, finding form. Uses + FP-001's three-state datum decode; S-5 guards that walk (FP-001). + [R2 #9, R2 #41; TEST-SPEC §12.7] New §12.7 registry module; map `"12.7"`. +- [ ] FP-076 — Implement T12.7-2: findings-array ordering + document forms. + [R2 #9; TEST-SPEC §12.7] +- [ ] FP-077 — Implement T12.7-3: the exit-2 error document, incl. the + `configuration-error` stable code and the anchoring-form concerned path. + Pairs with FP-002's protocol. [R2 #9; TEST-SPEC §12.7] + +- [ ] FP-078 — Implement T13.4-8: writes create missing directories + (file-form move, section-form move target, first emission under nested + `outDir`). [R2 #10; TEST-SPEC §13.4] Registry `section-13.4.ts`; map + `"13.4"`. + +- [ ] FP-079 — Implement T14-6: stable codes — all 23 condition tokens read + from each condition's stated reporter; `code` `null` for plain usage + errors and review refusals. [R2 #11; TEST-SPEC §14] Registry + `section-14.ts`; map `"14"`. +- [ ] FP-080 — Implement T14-7: refusal reasons — each stable refusal code + with concerned file/range/identity; all-applicable-reasons-together; the + invalid-workspace refusal reporting numbered findings alone. [R2 #12; + TEST-SPEC §14] +- [ ] FP-081 — Implement T14-8: location cardinality — one finding locating + every participant (triple-duplicate ID, import collision, cycle full + path, embedding container span); within-finding location order. [R2 #13; + TEST-SPEC §14] + +## Stage H — property layer (§16) and oracles (S-6) + +- [ ] FP-082 — P-2 generator: include backticks/`~` so fenced code blocks + and inline code spans spelling construct-like bytes are generated, with + the oracle treating them as content. [R2 #36; TEST-SPEC §16 P-2] + Generator/oracle: `test/helpers/oracles/markdown.ts` + + `test/suite/registry/section-16-p2-p3.ts`. Keep the S-6 markdown-oracle + vetted suite green (`test/self/s6-markdown-oracle.test.ts`) — extend its + vectors for the new grammar-boundary treatment. + +- [ ] FP-083 — Implement the P-5 section-move category oracle + its S-6 + vetted fixed-vector suite. [R2 #42; TEST-SPEC §16 P-5, §17 S-6; SPEC 6.2, + 5.6] + New oracle under `test/helpers/oracles/`; vetted vectors: SPEC 6.2's + worked straddling-line case plus the T6.2-3/T6.2-4 cases, in a new + `test/self/s6-*-oracle.test.ts`. + +- [ ] FP-084 — Generalize P-5 to random section moves using the full + 6.2/5.6 oracle. [R2 #37; TEST-SPEC §16 P-5] After FP-083. + `test/suite/registry/section-16-p5-p6.ts` currently restricts the + section-move arm to "clean-boundary" moves. Require: random section moves + generally — straddling-line drops computed via the line-drop rules/P-2 + oracle, created-target-file root as added, coincident-parent purity, + `metadata-changed` on no node — anchored by T6.2-3/T6.2-4. + +- [ ] FP-085 — Implement the P-6 baseline graph-diff oracle + its S-6 + vetted suite; wire P-6 to it. [R2 #42; TEST-SPEC §16 P-6, §17 S-6; SPEC + 5.6] + Vetted vectors: SPEC 5.6's three worked examples + the T5.6-6 case. + +- [ ] FP-086 — P-7 generator: add the `$`-at-capture-boundary literal forms + (`$0`, `$` before a non-digit, trailing `$`). [R2 #38; TEST-SPEC §16 P-7] + `test/suite/registry/section-16-p7.ts` (+ glob-input generator); keep the + S-6 glob-oracle suite (`test/self/s6-glob-oracle.test.ts`) green — + `test/helpers/oracles/glob.ts` already models patterns; extend vectors if + the oracle needs the literal-`$` forms pinned. + +- [ ] FP-087 — Implement P-11: availability robustness fuzz over + `occurrences`/`view`/`at`. [R2 #14; TEST-SPEC §16 P-11] New §16 registry + module + wrapper; H-7 map per the passages its TEST-SPEC entry asserts. + Property machinery: `test/helpers/property.ts` (fixed default seed set — + see AGENTS.md). + +- [ ] FP-088 — Implement P-12: `at` ≡ view-derived resolution over every + offset; occurrence order/totality equivalence. [R2 #15; TEST-SPEC §16 + P-12] + +- [ ] FP-089 — Implement the P-13 coverage-reachability oracle (independent + SPEC 8.1/8 reachability) + its S-6 vetted suite (vectors per SPEC 15). + [R2 #42; TEST-SPEC §16 P-13, §17 S-6] + +- [ ] FP-090 — Implement P-13: coverage oracle property — random + workspaces/profiles vs the FP-089 oracle. [R2 #16; TEST-SPEC §16 P-13] + After FP-089. + +## Stage I — CONF-AVAIL certification family + +- [ ] FP-091 — Build the CONF-AVAIL fixture family, wire the manifest, and + flip the whole-document pins. [R3 gaps 1–2, R2 #40, VERIFY; CERTIFICATIONS.md + CONF-AVAIL; TEST-SPEC §17 C-1/C-2] + After the six in-scope tests are registered (FP-051, FP-053, FP-059, + FP-060, FP-062, FP-063). + - New `test/fixtures/conf-avail/`: plain Node ESM conformer (`bin.mjs` + + `product.mjs`, no build step/deps — see AGENTS.md and existing + fixtures) implementing the scope CERTIFICATIONS.md states, and the + three violators beside it (`bin-.mjs` naming per document): + VIOL-AVAIL-NULLMARKER (certifies T11.2-2, T11.2-4, T11.4-3, T11.4-4), + VIOL-AVAIL-OMIT (those plus T11.4-1), VIOL-AVAIL-NOFILE (exactly + T11.3-4). + - `test/self/certification-fixtures.ts`: append the fifth conformer entry + (list currently ends at CONF-DISC), in-scope IDs and violator + `certifies` verbatim from the document. + - `test/self/certification-document.test.ts`: pins 4 conformers/13 + violators → 5/16. + - Verify: `npm run test:self` fully green on certification-document and + certification (conformer passes every in-scope test; each violator + fails at least one certified test) — this task clears the three VERIFY + certification-document failures. + +## Stage J — E-6 cross-platform legs + +- [ ] FP-092 — Extend the E-6 representative fixture with the new command + steps. [R2 #43; TEST-SPEC E-6] + `test/helpers/e6.ts` (the `step(...)` sequence): add `occurrences`, + `view --text`, `at`, `inventory` (invoked from a NESTED working + directory), `version`, and `move --preview` — their path/range-dense JSON + documents byte-compared across legs via the existing exchange + (`test/suite/e6-exchange-writer.test.ts`, `test/windows/ + e6-byte-identity.test.ts`, `XSPEC_E6_EXCHANGE_DIR`; see AGENTS.md). + +- [ ] FP-093 — Add the Windows-subset drive-mismatch anchoring arm of + T11.6-1. [R2 #43; TEST-SPEC §11.6 (E-6 arm)] After FP-069. + `test/windows/` (beside `e6-subset.test.ts`): the drive-mismatch + anchoring arm runs on the Windows leg only. From 561c4b77b41f4a8e91c1276e7cbfe0a210fd1384 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 21:41:20 +0000 Subject: [PATCH 048/226] sdg(phase-9): decode findings literally per SPEC 12.7 with datum decode and S-5 guards (FP-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild the findings assertion layer as a literal, form-exact SPEC 12.7 decode (TEST-SPEC H-3 amended): the finding form {code, message, locations, path, identities} with exact members, null-vs-omission and []-vs-null rules, stable code tokens validated against the pinned SPEC 14 vocabularies, the marked byte-form path, the pinned findings-order comparator (numeric condition order, refusals in 14's order, code-less last; byte-wise location/path/identity/message tie-breaks with the prefix rule), and duplicate collapse — new test/helpers/adapters/forms.ts, never adjustable to a product's shape, with its own form-exact failure trailer. Model: Finding carries the token code; the 14.N condition identity is derived through the harness-pinned token table (model.ts) so existing condition-identity assertions are expressed against tokens. Add the three-state datum decode (plain / null / {"unavailable": true}) and the unavailability-marker structural walk T12.7-1 relies on; S-5 guards for all of it (58 S-5 tests green). Sweep every compile-affected call site: located conditions assert via locations, path-level conditions (14.10/13/19/21/23) via the concerned path, policy findings via the contractual 14.12 identities quadruple, cycles via participating-file locations (byte-precise full-path staging stays T14-8/FP-081). Certification fixtures emit the 12.7 form (minimal mechanical conversion; CONF-VALID behavioral rework stays FP-009). Gate: npm run test:self — 5 failed / 228 passed, exactly the pre-existing baseline (certification-document x3 until FP-091, s1-traceability x2 until FP-003); S-5 and certification green; affected suite files red-as-diagnosed HarnessAssertionError against the current product's pre-12.7 shape. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 2 +- test/fixtures/conf-core/product.mjs | 121 +++++- test/fixtures/conf-disc/product.mjs | 103 ++++- test/fixtures/conf-md/product.mjs | 93 ++++- test/fixtures/conf-valid/product.mjs | 91 ++++- test/helpers/adapters/forms.ts | 498 +++++++++++++++++++++++ test/helpers/adapters/index.ts | 16 +- test/helpers/adapters/model.ts | 115 +++++- test/helpers/adapters/reports.ts | 96 +---- test/self/s5-output-adapters.test.ts | 493 ++++++++++++++++++++-- test/suite/registry/section-1.3.ts | 26 +- test/suite/registry/section-1.5.ts | 9 +- test/suite/registry/section-1.6-1.7.ts | 16 +- test/suite/registry/section-12.0-i.ts | 10 +- test/suite/registry/section-12.1-12.2.ts | 24 +- test/suite/registry/section-13.3.ts | 18 +- test/suite/registry/section-14.ts | 15 +- test/suite/registry/section-16-p1.ts | 2 +- test/suite/registry/section-16-p7.ts | 18 +- test/suite/registry/section-2.1.ts | 37 +- test/suite/registry/section-2.5-2.6.ts | 15 +- test/suite/registry/section-4.ts | 22 +- test/suite/registry/section-5.1-5.3.ts | 167 ++++---- test/suite/registry/section-6.1.ts | 18 +- test/suite/registry/section-6.6.ts | 4 +- test/suite/registry/section-7.1-7.3.ts | 32 +- test/suite/registry/section-7.4-7.5.ts | 31 +- test/suite/registry/section-8.ts | 25 +- test/suite/registry/support.ts | 62 +-- 29 files changed, 1753 insertions(+), 426 deletions(-) create mode 100644 test/helpers/adapters/forms.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 0fce13b..ba8852e 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -57,7 +57,7 @@ A "new test T" task always means, in one change: ## Stage A — foundations (do these first; many tasks depend on them) -- [ ] FP-001 — Rebuild the findings decode layer as a literal, form-exact +- [x] FP-001 — Rebuild the findings decode layer as a literal, form-exact SPEC 12.7 decode; add the three-state datum decode; add the S-5 guards. [R1 #28, R2 #18, R2 #41; SPEC 12.7, TEST-SPEC §0 H-3 (amended), §17 S-5] - `test/helpers/adapters/reports.ts` (`decodeFinding`, ASSUMED-SHAPE diff --git a/test/fixtures/conf-core/product.mjs b/test/fixtures/conf-core/product.mjs index 3641700..ec930c1 100644 --- a/test/fixtures/conf-core/product.mjs +++ b/test/fixtures/conf-core/product.mjs @@ -1781,19 +1781,118 @@ function emitJsonOnly(io, doc) { io.stdout(canonicalJson(doc) + "\n"); } +// SPEC 14's stable code tokens by condition ordinal ("14.N" → token). The +// JSON report carries the token string alone (SPEC 12.7, 14); the ordinal +// orders findings and is no part of the value. Only the conditions this +// conformer's scope reports appear. +const CODE_TOKENS = { + 14.1: "missing-id", + 14.2: "invalid-structural-id", + 14.3: "duplicate-id", + 14.4: "invalid-segment-or-tag", + "14.10": "stale-output", + 14.13: "journal-error", + 14.19: "invalid-source-path", + "14.20": "unparseable-source", + 14.21: "corrupt-session", +}; + +/** A condition's ordinal (the `N` of `14.N`), ordering findings (SPEC 12.7). */ +function conditionOrdinal(condition) { + return Number(condition.slice(3)); +} + +/** + * The pinned findings order (SPEC 12.7): by code (numbered conditions in + * numeric order), then locations element-wise (file path bytes, range start, + * range end; a proper prefix first), then concerned path (null before any + * path, byte-wise otherwise), then identities, then message — this scope's + * identities are always empty, so the remaining dimensions decide. + */ +function compareFindingDocs(a, b) { + const byOrdinal = + conditionOrdinal(a.internalCondition) - + conditionOrdinal(b.internalCondition); + if (byOrdinal !== 0) return byOrdinal; + const shared = Math.min(a.locations.length, b.locations.length); + for (let i = 0; i < shared; i += 1) { + const byFile = Buffer.compare( + Buffer.from(a.locations[i].file, "utf8"), + Buffer.from(b.locations[i].file, "utf8"), + ); + if (byFile !== 0) return byFile; + if (a.locations[i].range.start !== b.locations[i].range.start) { + return a.locations[i].range.start - b.locations[i].range.start; + } + if (a.locations[i].range.end !== b.locations[i].range.end) { + return a.locations[i].range.end - b.locations[i].range.end; + } + } + if (a.locations.length !== b.locations.length) { + return a.locations.length - b.locations.length; + } + if ((a.path === null) !== (b.path === null)) return a.path === null ? -1 : 1; + if (a.path !== null && b.path !== null) { + const byPath = Buffer.compare( + Buffer.from(a.path, "utf8"), + Buffer.from(b.path, "utf8"), + ); + if (byPath !== 0) return byPath; + } + return Buffer.compare( + Buffer.from(a.message, "utf8"), + Buffer.from(b.message, "utf8"), + ); +} + +/** + * The findings report in the 12.7 form: one `{"code", "message", + * "locations", "path", "identities"}` per finding. A finding carrying an + * in-source location (the located conditions of this scope) locates the + * offending construct with `path` null; a path-level finding (14.10, 14.13, + * 14.19, 14.21) carries the file or path it concerns with `locations` empty. + * Findings are emitted in the pinned order, identical findings collapsed to + * one (SPEC 12.7). + */ function findingsDoc(findings) { + const docs = findings.map((finding) => ({ + code: CODE_TOKENS[finding.condition], + message: finding.message, + locations: + finding.location === undefined + ? [] + : [ + { + file: finding.file, + range: { + start: finding.location.start, + end: finding.location.end, + }, + }, + ], + path: finding.location === undefined ? (finding.file ?? null) : null, + identities: [], + internalCondition: finding.condition, + })); + docs.sort(compareFindingDocs); + const collapsed = []; + for (const doc of docs) { + const previous = collapsed[collapsed.length - 1]; + if (previous !== undefined && compareFindingDocs(previous, doc) === 0) { + continue; + } + collapsed.push(doc); + } return { - findings: findings.map((finding) => { - const entry = { condition: finding.condition, message: finding.message }; - if (finding.file !== undefined) entry.file = finding.file; - if (finding.location !== undefined) { - entry.location = { - end: finding.location.end, - start: finding.location.start, - }; - } - return entry; - }), + findings: collapsed.map( + ({ code, message, locations, path, identities }) => ({ + code, + message, + locations, + path, + identities, + }), + ), }; } diff --git a/test/fixtures/conf-disc/product.mjs b/test/fixtures/conf-disc/product.mjs index 3a580b6..11eca92 100644 --- a/test/fixtures/conf-disc/product.mjs +++ b/test/fixtures/conf-disc/product.mjs @@ -1332,15 +1332,104 @@ async function loadWorkspace(cwd, configFlag) { // Commands (SPEC 12.0 conventions; the §CONF-DISC surface) // --------------------------------------------------------------------------- +// SPEC 14's stable code tokens by condition ordinal ("14.N" → token). The +// JSON report carries the token string alone (SPEC 12.7, 14); the ordinal +// orders findings and is no part of the value. Only the conditions this +// conformer's scope reports appear. +const CODE_TOKENS = { + 14.1: "missing-id", + 14.15: "invalid-import", + 14.19: "invalid-source-path", + "14.20": "unparseable-source", +}; + +/** A condition's ordinal (the `N` of `14.N`), ordering findings (SPEC 12.7). */ +function conditionOrdinal(condition) { + return Number(condition.slice(3)); +} + +/** + * The pinned findings order (SPEC 12.7): by code (numbered conditions in + * numeric order), then locations element-wise (file path bytes, range start, + * range end; a proper prefix first), then concerned path (null before any + * path, byte-wise otherwise), then identities, then message — this scope's + * identities are always empty, so the remaining dimensions decide. + */ +function compareFindingDocs(a, b) { + const byOrdinal = + conditionOrdinal(a.internalCondition) - + conditionOrdinal(b.internalCondition); + if (byOrdinal !== 0) return byOrdinal; + const shared = Math.min(a.locations.length, b.locations.length); + for (let i = 0; i < shared; i += 1) { + const byFile = Buffer.compare( + Buffer.from(a.locations[i].file, "utf8"), + Buffer.from(b.locations[i].file, "utf8"), + ); + if (byFile !== 0) return byFile; + if (a.locations[i].range.start !== b.locations[i].range.start) { + return a.locations[i].range.start - b.locations[i].range.start; + } + if (a.locations[i].range.end !== b.locations[i].range.end) { + return a.locations[i].range.end - b.locations[i].range.end; + } + } + if (a.locations.length !== b.locations.length) { + return a.locations.length - b.locations.length; + } + if ((a.path === null) !== (b.path === null)) return a.path === null ? -1 : 1; + if (a.path !== null && b.path !== null) { + const byPath = Buffer.compare( + Buffer.from(a.path, "utf8"), + Buffer.from(b.path, "utf8"), + ); + if (byPath !== 0) return byPath; + } + return Buffer.compare( + Buffer.from(a.message, "utf8"), + Buffer.from(b.message, "utf8"), + ); +} + +/** + * The findings report in the 12.7 form: one `{"code", "message", + * "locations", "path", "identities"}` per finding. A finding carrying an + * in-source location (14.1, 14.15, 14.20 here) locates the offending + * construct with `path` null; the path-level 14.19 carries the offending + * path it concerns with `locations` empty. Findings are emitted in the + * pinned order, identical findings collapsed to one (SPEC 12.7). + */ function findingsDoc(findings) { + const docs = findings.map((finding) => ({ + code: CODE_TOKENS[finding.condition], + message: finding.message, + locations: + finding.location === undefined + ? [] + : [{ file: finding.file, range: finding.location }], + path: finding.location === undefined ? (finding.file ?? null) : null, + identities: [], + internalCondition: finding.condition, + })); + docs.sort(compareFindingDocs); + const collapsed = []; + for (const doc of docs) { + const previous = collapsed[collapsed.length - 1]; + if (previous !== undefined && compareFindingDocs(previous, doc) === 0) { + continue; + } + collapsed.push(doc); + } return { - findings: findings.map((finding) => { - /** @type {Record} */ - const doc = { condition: finding.condition, message: finding.message }; - if (finding.file !== undefined) doc.file = finding.file; - if (finding.location !== undefined) doc.location = finding.location; - return doc; - }), + findings: collapsed.map( + ({ code, message, locations, path, identities }) => ({ + code, + message, + locations, + path, + identities, + }), + ), }; } diff --git a/test/fixtures/conf-md/product.mjs b/test/fixtures/conf-md/product.mjs index 83eed4c..93d77a7 100644 --- a/test/fixtures/conf-md/product.mjs +++ b/test/fixtures/conf-md/product.mjs @@ -1445,14 +1445,95 @@ function compileWorkspace(ws) { // Commands (SPEC 12.0 conventions; the §CONF-MD surface) // --------------------------------------------------------------------------- +// SPEC 14's stable code tokens by condition ordinal ("14.N" → token). The +// JSON report carries the token string alone (SPEC 12.7, 14); the ordinal +// orders findings and is no part of the value. Only the conditions this +// conformer's scope reports appear. +const CODE_TOKENS = { + 14.1: "missing-id", + 14.5: "unknown-dependency", + 14.6: "unknown-text-target", + 14.8: "invalid-argument", + 14.9: "cycle", + 14.15: "invalid-import", + "14.20": "unparseable-source", +}; + +/** A condition's ordinal (the `N` of `14.N`), ordering findings (SPEC 12.7). */ +function conditionOrdinal(condition) { + return Number(condition.slice(3)); +} + +/** + * The pinned findings order (SPEC 12.7): by code (numbered conditions in + * numeric order), then locations element-wise (file path bytes, range start, + * range end; a proper prefix first), then concerned path (null first), then + * identities, then message — over this conformer's all-located findings the + * live dimensions are ordinal, single location, and message. + */ +function compareFindingDocs(a, b) { + const byOrdinal = + conditionOrdinal(a.internalCondition) - + conditionOrdinal(b.internalCondition); + if (byOrdinal !== 0) return byOrdinal; + const shared = Math.min(a.locations.length, b.locations.length); + for (let i = 0; i < shared; i += 1) { + const byFile = Buffer.compare( + Buffer.from(a.locations[i].file, "utf8"), + Buffer.from(b.locations[i].file, "utf8"), + ); + if (byFile !== 0) return byFile; + if (a.locations[i].range.start !== b.locations[i].range.start) { + return a.locations[i].range.start - b.locations[i].range.start; + } + if (a.locations[i].range.end !== b.locations[i].range.end) { + return a.locations[i].range.end - b.locations[i].range.end; + } + } + if (a.locations.length !== b.locations.length) { + return a.locations.length - b.locations.length; + } + return Buffer.compare( + Buffer.from(a.message, "utf8"), + Buffer.from(b.message, "utf8"), + ); +} + +/** + * The findings report in the 12.7 form: one `{"code", "message", + * "locations", "path", "identities"}` per finding — every condition this + * scope reports locates in source, so `locations` carries the offending + * construct and `path` is null — in the pinned findings order, findings + * identical in every member collapsed to one (SPEC 12.7). + */ function findingsDoc(findings) { + const docs = findings.map((finding) => ({ + code: CODE_TOKENS[finding.condition], + message: finding.message, + locations: [{ file: finding.file, range: finding.location }], + path: null, + identities: [], + internalCondition: finding.condition, + })); + docs.sort(compareFindingDocs); + const collapsed = []; + for (const doc of docs) { + const previous = collapsed[collapsed.length - 1]; + if (previous !== undefined && compareFindingDocs(previous, doc) === 0) { + continue; + } + collapsed.push(doc); + } return { - findings: findings.map((finding) => ({ - condition: finding.condition, - message: finding.message, - file: finding.file, - location: finding.location, - })), + findings: collapsed.map( + ({ code, message, locations, path, identities }) => ({ + code, + message, + locations, + path, + identities, + }), + ), }; } diff --git a/test/fixtures/conf-valid/product.mjs b/test/fixtures/conf-valid/product.mjs index 39d886f..771ebaf 100644 --- a/test/fixtures/conf-valid/product.mjs +++ b/test/fixtures/conf-valid/product.mjs @@ -995,14 +995,93 @@ async function loadWorkspace(cwd, configFlag) { // Commands (SPEC 12.0 conventions; the §CONF-VALID surface) // --------------------------------------------------------------------------- +// SPEC 14's stable code tokens by condition ordinal ("14.N" → token). The +// JSON report carries the token string alone (SPEC 12.7, 14); the ordinal +// orders findings and is no part of the value. Only the conditions this +// conformer's scope reports appear. +const CODE_TOKENS = { + 14.1: "missing-id", + 14.2: "invalid-structural-id", + 14.3: "duplicate-id", + 14.4: "invalid-segment-or-tag", + "14.20": "unparseable-source", +}; + +/** A condition's ordinal (the `N` of `14.N`), ordering findings (SPEC 12.7). */ +function conditionOrdinal(condition) { + return Number(condition.slice(3)); +} + +/** + * The pinned findings order (SPEC 12.7): by code (numbered conditions in + * numeric order), then locations element-wise (file path bytes, range start, + * range end; a proper prefix first), then concerned path (null first), then + * identities, then message — over this conformer's all-located findings the + * live dimensions are ordinal, single location, and message. + */ +function compareFindingDocs(a, b) { + const byOrdinal = + conditionOrdinal(a.internalCondition) - + conditionOrdinal(b.internalCondition); + if (byOrdinal !== 0) return byOrdinal; + const shared = Math.min(a.locations.length, b.locations.length); + for (let i = 0; i < shared; i += 1) { + const byFile = Buffer.compare( + Buffer.from(a.locations[i].file, "utf8"), + Buffer.from(b.locations[i].file, "utf8"), + ); + if (byFile !== 0) return byFile; + if (a.locations[i].range.start !== b.locations[i].range.start) { + return a.locations[i].range.start - b.locations[i].range.start; + } + if (a.locations[i].range.end !== b.locations[i].range.end) { + return a.locations[i].range.end - b.locations[i].range.end; + } + } + if (a.locations.length !== b.locations.length) { + return a.locations.length - b.locations.length; + } + return Buffer.compare( + Buffer.from(a.message, "utf8"), + Buffer.from(b.message, "utf8"), + ); +} + +/** + * The findings report in the 12.7 form: one `{"code", "message", + * "locations", "path", "identities"}` per finding — every condition this + * scope reports locates in source, so `locations` carries the offending + * construct and `path` is null — in the pinned findings order, findings + * identical in every member collapsed to one (SPEC 12.7). + */ function findingsDoc(findings) { + const docs = findings.map((finding) => ({ + code: CODE_TOKENS[finding.condition], + message: finding.message, + locations: [{ file: finding.file, range: finding.location }], + path: null, + identities: [], + internalCondition: finding.condition, + })); + docs.sort(compareFindingDocs); + const collapsed = []; + for (const doc of docs) { + const previous = collapsed[collapsed.length - 1]; + if (previous !== undefined && compareFindingDocs(previous, doc) === 0) { + continue; + } + collapsed.push(doc); + } return { - findings: findings.map((finding) => ({ - condition: finding.condition, - message: finding.message, - file: finding.file, - location: finding.location, - })), + findings: collapsed.map( + ({ code, message, locations, path, identities }) => ({ + code, + message, + locations, + path, + identities, + }), + ), }; } diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts new file mode 100644 index 0000000..be7cc2a --- /dev/null +++ b/test/helpers/adapters/forms.ts @@ -0,0 +1,498 @@ +// The literal SPEC.md 12.7 decode layer — form-exact surfaces (TEST-SPEC §0 +// H-3, §17 S-5). +// +// SPEC.md 12.7 fixes the concrete JSON shape — member names, `null`-vs- +// omission, `[]`-vs-`null`, the range, path, byte-form, unavailability-marker, +// and finding value forms, and findings order — of every findings array and +// findings-only report, the exit-2 error document, and the document forms of +// 6.6, 11.3–11.6, and 12.6. Assertions on those surfaces are form-exact: +// this module decodes the 12.7 member names and forms literally, and unlike +// the adapters beside it (query.ts, reports.ts, review.ts) it is NEVER +// adjustable to a product's shape — output differing from 12.7 in shape is a +// conformance failure, not an adapter fixture (H-3, T12.7-1..3). It shares +// the adapters' fail-loud discipline (decode.ts, S-5): a wrong form is a +// diagnosed test failure, never a default. +// +// Contents: +// - path values: UTF-8 string vs the marked byte form (12.0/12.7) +// - the finding form {"code","message","locations","path","identities"} +// with the harness-pinned token→condition table (model.ts) +// - the pinned findings-order comparator and duplicate collapse (12.7) +// - findings arrays and the findings-only report {"findings": […]} +// - the three-state datum decode: plain value / `null` / +// {"unavailable": true} (11.4, 12.7) +// - the unavailability-marker structural walk T12.7-1 relies on: no object +// of any form other than the marker carries a member named "unavailable" + +import { Buffer, isUtf8 } from "node:buffer"; +import type { + Finding, + FindingLocation, + FindingsReport, + MarkedBytePath, + PathValue, + SourceRange, +} from "./model.js"; +import { + CONDITION_CODE_TOKENS, + REFUSAL_CODE_TOKENS, + conditionIdentityOf, +} from "./model.js"; +import type { DecodeSite } from "./decode.js"; +import { + at, + describeJsonValue, + expectArray, + expectNonEmptyString, + expectNonNegativeInteger, + expectObject, + requiredKey, + requiredMember, + rootSite, +} from "./decode.js"; +import { fail } from "../assertions.js"; + +/** + * Fail a form-exact decode loudly. Unlike the adjustable adapters' + * `decodeFail`, the diagnosis never invites adjusting the decode: SPEC.md + * 12.7 fixes these member names and forms literally, so a mismatch is a + * product conformance failure (H-3), and the fix is never here. + */ +function formFail(site: DecodeSite, expected: string, actual: unknown): never { + fail( + `${site.adapter} adapter: at ${site.path}: expected ${expected}, got ${describeJsonValue(actual)}. ` + + `H-3: this surface is form-exact — SPEC 12.7 fixes its member names and forms literally, ` + + `so output differing from them is a product conformance failure; this decode is never adjusted to a product's shape.`, + ); +} + +// --- form-exact object membership -------------------------------------------- + +/** + * 12.7: each object carries exactly the members its form names — a member + * whose datum does not arise is `null`, never omitted, and no member outside + * the form appears. Callers check presence per member; this rejects extras. + */ +function expectOnlyMembers( + obj: Record, + allowed: readonly string[], + site: DecodeSite, +): void { + for (const key of Object.keys(obj)) { + if (!allowed.includes(key)) { + formFail( + at(site, key), + `no member ${JSON.stringify(key)} — the form carries exactly ` + + `${allowed.map((k) => JSON.stringify(k)).join(", ")} (SPEC 12.7)`, + obj[key], + ); + } + } +} + +// --- value forms -------------------------------------------------------------- + +/** A source range in the literal 12.7 form: `{"start", "end"}` exactly. */ +export function decodeRangeForm(value: unknown, site: DecodeSite): SourceRange { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["start", "end"], site); + const start = expectNonNegativeInteger( + requiredKey(obj, "start", site), + at(site, "start"), + ); + const end = expectNonNegativeInteger( + requiredKey(obj, "end", site), + at(site, "end"), + ); + if (end < start) formFail(site, "a range with end >= start", value); + return { start, end }; +} + +const MARKED_BYTES_PATTERN = /^(?:[0-9a-f]{2})+$/; + +/** + * A 12.7 path value: a string whose bytes are valid UTF-8, or the marked + * byte form `{"bytes": "…"}` — lowercase hexadecimal, two digits per byte — + * used exactly where the path's bytes are NOT valid UTF-8 (a valid-UTF-8 + * path presented in byte form differs from 12.7 and is rejected). + */ +export function decodePathValue(value: unknown, site: DecodeSite): PathValue { + if (typeof value === "string") { + // A JSON string with lone surrogates encodes no UTF-8 byte sequence, so + // it is no 12.7 path string (UTF-8 round-trip replaces lone surrogates, + // so inequality detects them). + if (Buffer.from(value, "utf8").toString("utf8") !== value) { + formFail( + site, + "a path string whose bytes are valid UTF-8 (SPEC 12.7; lone " + + "surrogates encode none)", + value, + ); + } + return value; + } + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["bytes"], site); + const hex = expectNonEmptyString( + requiredKey(obj, "bytes", site), + at(site, "bytes"), + ); + if (!MARKED_BYTES_PATTERN.test(hex)) { + formFail( + at(site, "bytes"), + "the path's exact bytes as lowercase hexadecimal, two digits per " + + "byte (SPEC 12.0, 12.7)", + value, + ); + } + if (isUtf8(Buffer.from(hex, "hex"))) { + formFail( + site, + "a marked byte form only for a path whose bytes are NOT valid UTF-8 " + + "(SPEC 12.7: a valid-UTF-8 path is a plain string)", + value, + ); + } + return { bytes: hex }; +} + +/** The exact bytes a 12.7 path value denotes (paths compare byte-wise). */ +export function pathValueBytes(value: PathValue): Buffer { + return typeof value === "string" + ? Buffer.from(value, "utf8") + : Buffer.from(value.bytes, "hex"); +} + +/** Render a path value for diagnoses and file-mention matching. */ +export function renderPathValue(value: PathValue | null): string { + if (value === null) return ""; + return typeof value === "string" ? value : `bytes:${value.bytes}`; +} + +// --- the finding form --------------------------------------------------------- + +const FINDING_MEMBERS = [ + "code", + "message", + "locations", + "path", + "identities", +] as const; + +const KNOWN_CODE_TOKENS: readonly string[] = [ + ...CONDITION_CODE_TOKENS, + ...REFUSAL_CODE_TOKENS, +]; + +function decodeFindingLocation( + value: unknown, + site: DecodeSite, +): FindingLocation { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["file", "range"], site); + return { + file: decodePathValue(requiredKey(obj, "file", site), at(site, "file")), + range: decodeRangeForm(requiredKey(obj, "range", site), at(site, "range")), + }; +} + +function compareLocations(a: FindingLocation, b: FindingLocation): number { + const byFile = Buffer.compare(pathValueBytes(a.file), pathValueBytes(b.file)); + if (byFile !== 0) return byFile; + if (a.range.start !== b.range.start) return a.range.start - b.range.start; + return a.range.end - b.range.end; +} + +/** + * Decode one finding in the literal 12.7 form: exactly the five members, + * `code` the stable token 14 assigns (or `null`), locations ordered by file + * path bytes, then range start, then range end (12.7, T14-8). The decoded + * finding additionally carries the derived `14.N` condition identity + * (model.ts: `conditionIdentityOf`) — a lookup, never a document member. + */ +export function decodeFindingForm(value: unknown, site: DecodeSite): Finding { + const obj = expectObject(value, site); + expectOnlyMembers(obj, FINDING_MEMBERS, site); + const codeValue = requiredMember(obj, "code", site); + let code: string | null = null; + if (codeValue !== null) { + const codeSite = at(site, "code"); + const token = expectNonEmptyString(codeValue, codeSite); + if (!KNOWN_CODE_TOKENS.includes(token)) { + formFail( + codeSite, + "a stable code: one of SPEC 14's condition tokens " + + "(missing-id … unreadable-record) or refusal codes " + + "(refused-invalid-id … refused-invalid-destination), or null " + + "where 14 assigns none", + codeValue, + ); + } + code = token; + } + const message = expectNonEmptyString( + requiredKey(obj, "message", site), + at(site, "message"), + ); + const locationsSite = at(site, "locations"); + const locations = expectArray( + requiredKey(obj, "locations", site), + locationsSite, + ).map((element, index) => + decodeFindingLocation(element, at(locationsSite, index)), + ); + for (let i = 1; i < locations.length; i += 1) { + if (compareLocations(locations[i - 1]!, locations[i]!) > 0) { + formFail( + at(locationsSite, i), + "locations ordered by file path bytes, then range start, then " + + "range end (SPEC 12.7)", + obj["locations"], + ); + } + } + const pathValue = requiredMember(obj, "path", site); + const path = + pathValue === null ? null : decodePathValue(pathValue, at(site, "path")); + const identitiesSite = at(site, "identities"); + const identities = expectArray( + requiredKey(obj, "identities", site), + identitiesSite, + ).map((element, index) => + expectNonEmptyString(element, at(identitiesSite, index)), + ); + return { + code, + message, + locations, + path, + identities, + condition: conditionIdentityOf(code), + }; +} + +// --- the pinned findings-order comparator (12.7) ------------------------------ + +/** + * A code's rank in the findings order: the numbered conditions in numeric + * order, then the refusal reasons in the order 14 lists them, then code-less + * findings (SPEC 12.7). Total over decoded findings — decode admits only the + * known tokens. + */ +function codeRank(code: string | null): number { + if (code === null) { + return CONDITION_CODE_TOKENS.length + REFUSAL_CODE_TOKENS.length; + } + const condition = (CONDITION_CODE_TOKENS as readonly string[]).indexOf(code); + if (condition !== -1) return condition; + return ( + CONDITION_CODE_TOKENS.length + + (REFUSAL_CODE_TOKENS as readonly string[]).indexOf(code) + ); +} + +function compareSequences( + a: readonly T[], + b: readonly T[], + compareElement: (x: T, y: T) => number, +): number { + const shared = Math.min(a.length, b.length); + for (let i = 0; i < shared; i += 1) { + const byElement = compareElement(a[i]!, b[i]!); + if (byElement !== 0) return byElement; + } + // A sequence that is a proper prefix of another sorts first (SPEC 12.7). + return a.length - b.length; +} + +function compareStringBytes(a: string, b: string): number { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + +/** + * The pinned SPEC 12.7 findings-order comparator: by code (numbered + * conditions in numeric order, then refusal reasons in 14's order, then + * code-less), then by locations element-wise (file path bytes, range start, + * range end; proper prefix first), then by concerned path (`null` before any + * path; byte-wise whatever the presentation form), then by identities + * (element-wise by bytes, prefix rule), then by message (bytes). Returns 0 + * exactly for findings identical in every member — which 12.7 collapses to + * one, so a compliant array is strictly ascending. + */ +export function compareFindings(a: Finding, b: Finding): number { + const byCode = codeRank(a.code) - codeRank(b.code); + if (byCode !== 0) return byCode; + const byLocations = compareSequences( + a.locations, + b.locations, + compareLocations, + ); + if (byLocations !== 0) return byLocations; + if ((a.path === null) !== (b.path === null)) return a.path === null ? -1 : 1; + if (a.path !== null && b.path !== null) { + const byPath = Buffer.compare( + pathValueBytes(a.path), + pathValueBytes(b.path), + ); + if (byPath !== 0) return byPath; + } + const byIdentities = compareSequences( + a.identities, + b.identities, + compareStringBytes, + ); + if (byIdentities !== 0) return byIdentities; + return compareStringBytes(a.message, b.message); +} + +// --- findings arrays and the findings-only report ----------------------------- + +/** + * Decode a `"findings"` array value in the literal 12.7 form: every element + * a well-formed finding, the array in the pinned findings order, findings + * identical in every member collapsed to one (adjacent equality is an + * uncollapsed duplicate; both violations reject, form-exact per H-3). + */ +export function decodeFindingsArray( + value: unknown, + site: DecodeSite, +): Finding[] { + const findings = expectArray(value, site).map((element, index) => + decodeFindingForm(element, at(site, index)), + ); + for (let i = 1; i < findings.length; i += 1) { + const order = compareFindings(findings[i - 1]!, findings[i]!); + if (order === 0) { + formFail( + at(site, i), + "findings identical in every member collapsed to one (SPEC 12.7)", + value, + ); + } + if (order > 0) { + formFail( + at(site, i), + "findings in the pinned 12.7 order: by code (numbered conditions " + + "in numeric order, then refusal reasons in 14's order, then " + + "code-less), then locations, concerned path, identities, message", + value, + ); + } + } + return findings; +} + +/** + * A findings-only report — `{"findings": […]}` exactly (SPEC 12.7): a + * failing `build`'s validation errors, `check`'s findings, the findings of + * refusing reads (13.3) and refused operations (6.4, 6.5, 10.7). Form-exact + * (H-3): the one member, the literal finding form, the pinned order. + */ +export function decodeFindingsReport( + doc: unknown, + context?: string, +): FindingsReport { + const site = rootSite("12.7 findings report", context); + const obj = expectObject(doc, site); + expectOnlyMembers(obj, ["findings"], site); + return { + findings: decodeFindingsArray( + requiredKey(obj, "findings", site), + at(site, "findings"), + ), + }; +} + +// --- the three-state datum decode (11.4, 12.7) -------------------------------- + +/** + * The three observable states of a datum (SPEC 11.4, 12.7): a plain value, + * the stated `null`, or explicit unavailability `{"unavailable": true}`. + */ +export type DecodedDatum = + | { readonly state: "value"; readonly value: T } + | { readonly state: "null" } + | { readonly state: "unavailable" }; + +/** + * Decode one datum's three states literally. The member must be present — + * `null` is never omission (12.7) — so callers pass the raw member value + * read via `requiredMember` semantics: `undefined` (an absent member) + * rejects here. An object carrying a member named `unavailable` must be + * exactly the marker `{"unavailable": true}` (12.7); anything else with that + * member is a wrong form, never a plain value. Plain values decode through + * the caller's `decodeValue`, so `null` and the marker never collapse into a + * defaulted value (S-5). + */ +export function decodeDatum( + value: unknown, + site: DecodeSite, + decodeValue: (value: unknown, site: DecodeSite) => T, +): DecodedDatum { + if (value === undefined) { + formFail( + site, + "a present member: a datum that does not arise is null, never " + + "omitted (SPEC 12.7)", + value, + ); + } + if (value === null) return { state: "null" }; + if ( + typeof value === "object" && + !Array.isArray(value) && + Object.hasOwn(value, "unavailable") + ) { + const obj = value as Record; + if (Object.keys(obj).length !== 1 || obj["unavailable"] !== true) { + formFail( + site, + 'the unavailability marker {"unavailable": true} exactly (SPEC ' + + '12.7: no other object carries a member named "unavailable")', + value, + ); + } + return { state: "unavailable" }; + } + return { state: "value", value: decodeValue(value, site) }; +} + +// --- the unavailability-marker structural walk (T12.7-1) ----------------------- + +/** + * Walk a decoded JSON document and assert 12.7's marker uniqueness: no + * object of any form other than the unavailability marker carries a member + * named `unavailable` — every object with that member is exactly + * `{"unavailable": true}`. Diagnoses name the offending JSON path. + */ +export function assertUnavailabilityMarkerForms( + doc: unknown, + context?: string, +): void { + const walk = (value: unknown, site: DecodeSite): void => { + if (Array.isArray(value)) { + value.forEach((element, index) => { + walk(element, at(site, index)); + }); + return; + } + if (typeof value !== "object" || value === null) return; + const obj = value as Record; + if ( + Object.hasOwn(obj, "unavailable") && + (Object.keys(obj).length !== 1 || obj["unavailable"] !== true) + ) { + formFail( + site, + "no object of any form other than the unavailability marker " + + '{"unavailable": true} carrying a member named "unavailable" ' + + "(SPEC 12.7)", + value, + ); + } + for (const [key, member] of Object.entries(obj)) { + walk(member, at(site, key)); + } + }; + walk(doc, rootSite("12.7 unavailability-marker walk", context)); +} diff --git a/test/helpers/adapters/index.ts b/test/helpers/adapters/index.ts index 73910af..717b5e2 100644 --- a/test/helpers/adapters/index.ts +++ b/test/helpers/adapters/index.ts @@ -5,20 +5,28 @@ // // model.ts the fixed information model tests assert against // decode.ts fail-loud shape-decoding primitives +// forms.ts the literal SPEC 12.7 forms — findings, findings-only +// reports, path/range/datum value forms, the +// unavailability-marker walk. Form-exact (H-3): NEVER +// adjustable to a product's shape // query.ts query node/show, rows, edges, reachable, ids -// reports.ts build/check findings, coverage, impact +// reports.ts coverage, impact // review.ts review list/status/next/show/export // human.ts robust required-information matching on human reports // session-staging.ts T10.1-4 corruption transformations (shape-aware, // value-blind, over product-written session files) // sorted-keys.ts T13.4-1 byte-sorted-keys assertion (shape/value-blind) // -// These modules are the only place aware of concrete output shape; they may -// be adjusted to shape, never to values, and they fail loudly (a diagnosed -// test error, never a default) when required information is absent. +// The adapter modules are the only place aware of concrete output shape; they +// may be adjusted to shape, never to values, and they fail loudly (a +// diagnosed test error, never a default) when required information is absent. +// forms.ts shares the fail-loud discipline but decodes shapes SPEC.md 12.7 +// pins: output differing from those forms is a conformance failure, never an +// adapter fixture. export * from "./model.js"; export * from "./decode.js"; +export * from "./forms.js"; export * from "./query.js"; export * from "./reports.js"; export * from "./review.js"; diff --git a/test/helpers/adapters/model.ts b/test/helpers/adapters/model.ts index 136e2c7..46856ed 100644 --- a/test/helpers/adapters/model.ts +++ b/test/helpers/adapters/model.ts @@ -173,24 +173,113 @@ export interface IdsTreeNode { } /** - * One validation/check finding (SPEC.md 14; T14-1, T7.5-2, T5.3-1, T6.1-3). - * `condition` is the SPEC.md 14 condition identity (`"14.2"`); `message` is - * the correction-oriented text (information presence, never exact wording). - * The optional fields carry the extra information particular findings must - * identify: source file and location, the violated policy rule and offending - * edge (7.5), a full cycle path (5.3). + * SPEC.md 14's numbered-condition stable code tokens, in ordinal order: + * index N-1 holds condition 14.N's token. The numeral is the condition's + * ordinal — it orders findings (SPEC 12.7) and is no part of the code's + * value, which is the token string alone (SPEC 14, T14-6). + */ +export const CONDITION_CODE_TOKENS = [ + "missing-id", // 14.1 + "invalid-structural-id", // 14.2 + "duplicate-id", // 14.3 + "invalid-segment-or-tag", // 14.4 + "unknown-dependency", // 14.5 + "unknown-text-target", // 14.6 + "unknown-ts-reference", // 14.7 + "invalid-argument", // 14.8 + "cycle", // 14.9 + "stale-output", // 14.10 + "cross-module-text", // 14.11 + "policy-violation", // 14.12 + "journal-error", // 14.13 + "configuration-error", // 14.14 + "invalid-import", // 14.15 + "invalid-construct", // 14.16 + "invalid-prop", // 14.17 + "unsupported-node-usage", // 14.18 + "invalid-source-path", // 14.19 + "unparseable-source", // 14.20 + "corrupt-session", // 14.21 + "obstructed-write-path", // 14.22 + "unreadable-record", // 14.23 +] as const; +export type ConditionCodeToken = (typeof CONDITION_CODE_TOKENS)[number]; + +/** + * SPEC.md 14's refusal-reason stable codes, in the order 14 lists them — + * the findings order after the numbered conditions (SPEC 12.7, T14-7). + */ +export const REFUSAL_CODE_TOKENS = [ + "refused-invalid-id", + "refused-identity-unchanged", + "refused-id-collision", + "refused-structural-parent", + "refused-unresolvable-reference", + "refused-cycle", + "refused-destination-exists", + "refused-missing-target-parent", + "refused-invalid-destination", +] as const; +export type RefusalCodeToken = (typeof REFUSAL_CODE_TOKENS)[number]; + +/** + * The harness-pinned SPEC.md 14 token→condition table: the `14.N` condition + * identity of a numbered-condition code token, `null` for refusal reasons + * and code-less findings. The `14.N` spelling is harness vocabulary derived + * from the token — the reported value is always the token string (12.7) — + * so condition-identity assertions are assertions against tokens. + */ +export function conditionIdentityOf(code: string | null): string | null { + if (code === null) return null; + const index = (CONDITION_CODE_TOKENS as readonly string[]).indexOf(code); + return index === -1 ? null : `14.${String(index + 1)}`; +} + +/** + * A SPEC.md 12.7 path value: a string where the path's bytes are valid + * UTF-8, and otherwise the marked byte form of 12.0 — `{"bytes": "…"}`, + * lowercase hexadecimal, two digits per byte, an object equal to no path + * string. + */ +export type PathValue = string | MarkedBytePath; +export interface MarkedBytePath { + readonly bytes: string; +} + +/** One finding location: an offending construct's file and range (12.7). */ +export interface FindingLocation { + readonly file: PathValue; + readonly range: SourceRange; +} + +/** + * One finding in the literal SPEC.md 12.7 form (a form-exact surface, H-3): + * `code` is the stable token 14 assigns (`null` where 14 assigns none); + * `message` the human-readable description; `locations` one `{file, range}` + * per offending construct, ordered by file path bytes, then range start, + * then range end, empty for conditions without in-source locations; `path` + * the concerned file or path (`null` for located conditions); `identities` + * the identities or other context strings the condition names, empty where + * none. `condition` is NOT a document member: it is the derived `14.N` + * condition identity of a numbered-condition token (`conditionIdentityOf`), + * `null` for refusal reasons and code-less findings, kept so existing + * condition-identity assertions are expressed against the decoded token. */ export interface Finding { - readonly condition: string; + readonly code: string | null; readonly message: string; - readonly file?: string; - readonly location?: SourceRange; - readonly rule?: string; - readonly edge?: GraphEdge; - readonly cycle?: readonly string[]; + readonly locations: readonly FindingLocation[]; + readonly path: PathValue | null; + readonly identities: readonly string[]; + /** Derived via the pinned token table — never read from the document. */ + readonly condition: string | null; } -/** A failing `build` / `check` findings report (exit 1, stdout). */ +/** + * A findings-only report — `{"findings": […]}` exactly (SPEC 12.7): a + * failing `build`'s validation errors, `check`'s findings, the findings of + * refusing reads (13.3) and refused operations (6.4, 6.5, 10.7). + */ export interface FindingsReport { readonly findings: readonly Finding[]; } diff --git a/test/helpers/adapters/reports.ts b/test/helpers/adapters/reports.ts index d7f3451..0fd3f93 100644 --- a/test/helpers/adapters/reports.ts +++ b/test/helpers/adapters/reports.ts @@ -1,16 +1,13 @@ -// H-3 output adapters — findings and analysis reports: failing `build` / -// `check` findings (SPEC.md 14; TEST-SPEC §14), `coverage` (SPEC.md 8; -// T8.2-1), and `impact --base` (SPEC.md 5.6, 9; T9.1-1, T9.2-*, T9.3-*). +// H-3 output adapters — analysis reports: `coverage` (SPEC.md 8; T8.2-1) and +// `impact --base` (SPEC.md 5.6, 9; T9.1-1, T9.2-*, T9.3-*). // // Shape-aware, value-blind, fail-loud (H-3) — see query.ts for the layer's // contract. Adjust the ASSUMED SHAPE below when the real product's output -// shape legitimately differs; never adjust values. +// shape legitimately differs; never adjust values. Findings and findings-only +// reports are NOT here: they are form-exact 12.7 surfaces, decoded literally +// and never adjusted (forms.ts). // // ASSUMED SHAPE: -// build (exit 1) / check (exit 1) → -// { "findings": [ { "condition": "14.N", "message", -// "file"?, "location"?: {"start","end"}, -// "rule"?, "edge"?: Edge, "cycle"?: [identity...] } ] } // coverage → // { "profiles": [ { "name", // "counts": {"required","covered","uncovered","ignored"}, @@ -28,8 +25,6 @@ import type { CoverageProfileReport, CoverageReport, CoveredNode, - Finding, - FindingsReport, IgnoredNode, ImpactCategoryEntry, ImpactReport, @@ -52,86 +47,7 @@ import { requiredKey, rootSite, } from "./decode.js"; -import { decodeEdge, decodeSourceRange } from "./query.js"; - -/** - * A SPEC.md §14 condition identity: `14.` followed by a condition number. - * The token shape is spec-fixed; which condition a finding carries is a value - * the tests assert. - */ -const CONDITION_PATTERN = /^14\.[1-9][0-9]*$/; - -function decodeFinding(value: unknown, site: DecodeSite): Finding { - const obj = expectObject(value, site); - const conditionSite = at(site, "condition"); - const condition = expectNonEmptyString( - requiredKey(obj, "condition", site), - conditionSite, - ); - if (!CONDITION_PATTERN.test(condition)) { - decodeFail( - conditionSite, - 'a SPEC.md 14 condition identity ("14.")', - condition, - ); - } - const finding: { - condition: string; - message: string; - file?: string; - location?: Finding["location"]; - rule?: string; - edge?: Finding["edge"]; - cycle?: readonly string[]; - } = { - condition, - message: expectNonEmptyString( - requiredKey(obj, "message", site), - at(site, "message"), - ), - }; - const file = optionalKey(obj, "file"); - if (file !== undefined) { - finding.file = expectNonEmptyString(file, at(site, "file")); - } - const location = optionalKey(obj, "location"); - if (location !== undefined) { - finding.location = decodeSourceRange(location, at(site, "location")); - } - const rule = optionalKey(obj, "rule"); - if (rule !== undefined) { - finding.rule = expectNonEmptyString(rule, at(site, "rule")); - } - const edge = optionalKey(obj, "edge"); - if (edge !== undefined) { - finding.edge = decodeEdge(edge, at(site, "edge")); - } - const cycle = optionalKey(obj, "cycle"); - if (cycle !== undefined) { - finding.cycle = expectNonEmptyStringArray(cycle, at(site, "cycle")); - } - return finding; -} - -/** - * A failing `build`'s validation errors or `check`'s findings (exit 1, - * stdout). Every finding carries its SPEC.md 14 condition identity and a - * message; file, location, rule, edge, and cycle path are decoded when - * present and asserted for presence by the tests that require them (T14-1). - */ -export function decodeFindingsReport( - doc: unknown, - context?: string, -): FindingsReport { - const site = rootSite("build/check findings", context); - const obj = expectObject(doc, site); - const findingsSite = at(site, "findings"); - const findings = expectArray( - requiredKey(obj, "findings", site), - findingsSite, - ).map((element, index) => decodeFinding(element, at(findingsSite, index))); - return { findings }; -} +import { decodeEdge } from "./query.js"; function decodeCoveredNode(value: unknown, site: DecodeSite): CoveredNode { const obj = expectObject(value, site); diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index aaeb361..ca32b69 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -20,13 +20,17 @@ import { Buffer } from "node:buffer"; import { expect, onTestFinished, test } from "vitest"; import { HarnessAssertionError } from "../helpers/assertions.js"; import type { RunResult } from "../helpers/subprocess.js"; +import type { Finding } from "../helpers/adapters/index.js"; import { ITEM_STATUSES, assertJsonKeysByteSorted, assertReportMentions, + assertUnavailabilityMarkerForms, classifyIgnoredReasons, + compareFindings, conditionMention, decodeCoverageReport, + decodeDatum, decodeEdgesReport, decodeExportReport, decodeFindingsReport, @@ -44,6 +48,8 @@ import { decodeReachableReport, decodeSessionListReport, decodeSessionStatusReport, + expectNonNegativeInteger, + rootSite, stageBlockedByAbsentItem, stageBlockedByCycle, stageDeleteItemField, @@ -207,24 +213,63 @@ const GOOD_IDS_TREE = { ], }; +// A findings-only report in the literal SPEC 12.7 form (a form-exact +// surface, H-3): entries deliberately span a located condition, a +// multi-location cycle, a policy finding (locations [] / path null / +// contractual identities), a path-level condition, a refusal reason, and a +// code-less finding — in the pinned findings order (numbered conditions in +// numeric order, then refusal reasons, then code-less). const GOOD_FINDINGS = { findings: [ { - condition: "14.2", - message: 'expected nested inside login', - file: "specs/A.mdx", - location: { start: 40, end: 78 }, + code: "invalid-structural-id", // 14.2 + message: 'expected nested inside login', + locations: [{ file: "specs/A.mdx", range: { start: 40, end: 78 } }], + path: null, + identities: [], + }, + { + code: "cycle", // 14.9 — one finding locating every participant (T14-8) + message: "dependency cycle", + locations: [ + { file: "specs/A.mdx", range: { start: 10, end: 30 } }, + { file: "specs/B.mdx", range: { start: 5, end: 25 } }, + ], + path: null, + identities: ["specs/A.mdx#a", "specs/B.mdx#b"], }, { - condition: "14.12", + code: "policy-violation", // 14.12 — no locations, no path, identities message: "policy rule violated", - rule: "no-derived-to-base", - edge: EDGE_OUT, + locations: [], + path: null, + identities: [ + "no-derived-to-base", + "specs/A.mdx#login", + "depends", + "specs/B.mdx#account", + ], }, { - condition: "14.9", - message: "dependency cycle", - cycle: ["specs/A.mdx#a", "specs/B.mdx#b", "specs/A.mdx#a"], + code: "unreadable-record", // 14.23 — a path-level condition + message: "graph data cannot be read as a record; rebuild", + locations: [], + path: ".xspec", + identities: [], + }, + { + code: "refused-id-collision", // refusal reasons sort after 14.1–14.23 + message: "the new id collides with a remaining bearer", + locations: [{ file: "specs/A.mdx", range: { start: 3, end: 9 } }], + path: null, + identities: ["specs/A.mdx#login"], + }, + { + code: null, // code-less findings sort last (12.7) + message: "refused: the review operation names a blocked item", + locations: [], + path: null, + identities: [], }, ], }; @@ -762,39 +807,103 @@ const DECODERS: readonly DecoderSpec[] = [ ], }, { - name: "build/check findings", + name: "12.7 findings report", decode: decodeFindingsReport, good: GOOD_FINDINGS, verify: (decoded: ReturnType) => { - expect(decoded.findings).toHaveLength(3); + expect(decoded.findings).toHaveLength(6); + // The document members decode literally (form-exact, H-3) … + expect(decoded.findings[0].code).toBe("invalid-structural-id"); + expect(decoded.findings[0].locations).toEqual([ + { file: "specs/A.mdx", range: { start: 40, end: 78 } }, + ]); + expect(decoded.findings[0].path).toBeNull(); + expect(decoded.findings[0].identities).toEqual([]); + // … and the 14.N condition identity is DERIVED through the pinned + // token table (model.ts), never read from the document. expect(decoded.findings[0].condition).toBe("14.2"); - expect(decoded.findings[0].file).toBe("specs/A.mdx"); - expect(decoded.findings[0].location).toEqual({ start: 40, end: 78 }); - expect(decoded.findings[1].rule).toBe("no-derived-to-base"); - expect(decoded.findings[1].edge).toEqual(EDGE_OUT); - expect(decoded.findings[2].cycle).toEqual([ - "specs/A.mdx#a", - "specs/B.mdx#b", - "specs/A.mdx#a", + expect(decoded.findings[1].condition).toBe("14.9"); + expect(decoded.findings[1].locations).toHaveLength(2); + expect(decoded.findings[2].condition).toBe("14.12"); + expect(decoded.findings[2].identities).toEqual([ + "no-derived-to-base", + "specs/A.mdx#login", + "depends", + "specs/B.mdx#account", ]); + expect(decoded.findings[3].path).toBe(".xspec"); + expect(decoded.findings[4].code).toBe("refused-id-collision"); + expect(decoded.findings[4].condition).toBeNull(); // refusal: no 14.N + expect(decoded.findings[5].code).toBeNull(); + expect(decoded.findings[5].condition).toBeNull(); }, + alsoGood: [ + { + label: "an empty findings array (a finding-free report)", + doc: { findings: [] }, + verify: (decoded: ReturnType): void => { + expect(decoded.findings).toEqual([]); + }, + }, + { + label: + "a non-UTF-8 concerned path in the marked byte form (SPEC 12.0/12.7)", + doc: { + findings: [ + { + code: "invalid-source-path", + message: "a discovered source path is not valid UTF-8", + locations: [], + path: { bytes: "ff2f61" }, + identities: [], + }, + ], + }, + verify: (decoded: ReturnType): void => { + expect(decoded.findings[0]!.path).toEqual({ bytes: "ff2f61" }); + }, + }, + ], bad: [ { label: "missing findings list", doc: {} }, { - label: "finding missing condition", - doc: omit(GOOD_FINDINGS, "findings", 0, "condition"), + label: "null findings (null never encodes emptiness, SPEC 12.7)", + doc: { findings: null }, }, { - label: "condition not a 14. identity", - doc: put(GOOD_FINDINGS, "oops", "findings", 0, "condition"), + label: "an extra member on the report (12.7: exactly {findings})", + doc: { findings: [], summary: "3 errors" }, }, { - label: "condition outside section 14", - doc: put(GOOD_FINDINGS, "15.1", "findings", 0, "condition"), + label: "finding missing its code member (null is never omitted)", + doc: omit(GOOD_FINDINGS, "findings", 0, "code"), }, { - label: "condition 14.0 (no such condition)", - doc: put(GOOD_FINDINGS, "14.0", "findings", 0, "condition"), + label: "unknown code token", + doc: put(GOOD_FINDINGS, "oops", "findings", 0, "code"), + }, + { + label: + 'the condition ordinal spelled as the code ("14.2" is no token — ' + + "the numeral is no part of the value, SPEC 14)", + doc: put(GOOD_FINDINGS, "14.2", "findings", 0, "code"), + }, + { + label: "the retired pre-12.7 finding shape (condition/file/location)", + doc: { + findings: [ + { + condition: "14.2", + message: "old shape", + file: "specs/A.mdx", + location: { start: 40, end: 78 }, + }, + ], + }, + }, + { + label: "an extra member on a finding (12.7: exactly the five)", + doc: put(GOOD_FINDINGS, "extra", "findings", 0, "hint"), }, { label: "finding missing message", @@ -805,26 +914,159 @@ const DECODERS: readonly DecoderSpec[] = [ doc: put(GOOD_FINDINGS, "", "findings", 1, "message"), }, { - label: "malformed location", + label: "finding missing locations", + doc: omit(GOOD_FINDINGS, "findings", 0, "locations"), + }, + { + label: "null locations (a list-valued member is [] when empty)", + doc: put(GOOD_FINDINGS, null, "findings", 0, "locations"), + }, + { + label: "location missing its range", + doc: omit(GOOD_FINDINGS, "findings", 0, "locations", 0, "range"), + }, + { + label: "location with an extra member", + doc: put(GOOD_FINDINGS, 3, "findings", 0, "locations", 0, "line"), + }, + { + label: "malformed range (end < start)", doc: put( GOOD_FINDINGS, { start: 78, end: 40 }, "findings", 0, - "location", + "locations", + 0, + "range", + ), + }, + { + label: "range with an extra member (12.7: exactly {start, end})", + doc: put( + GOOD_FINDINGS, + { start: 40, end: 78, length: 38 }, + "findings", + 0, + "locations", + 0, + "range", + ), + }, + { + label: + "locations out of order within a finding (12.7: file bytes, " + + "then start, then end)", + doc: put( + GOOD_FINDINGS, + [ + { file: "specs/B.mdx", range: { start: 5, end: 25 } }, + { file: "specs/A.mdx", range: { start: 10, end: 30 } }, + ], + "findings", + 1, + "locations", + ), + }, + { + label: "finding missing its path member (null is never omitted)", + doc: omit(GOOD_FINDINGS, "findings", 3, "path"), + }, + { + label: "wrong-typed path", + doc: put(GOOD_FINDINGS, 9, "findings", 3, "path"), + }, + { + label: "byte-form path with uppercase hex", + doc: put(GOOD_FINDINGS, { bytes: "FF2F61" }, "findings", 3, "path"), + }, + { + label: "byte-form path with odd-length hex", + doc: put(GOOD_FINDINGS, { bytes: "ff2" }, "findings", 3, "path"), + }, + { + label: + "byte-form path whose bytes are valid UTF-8 (12.7: such a path " + + "is a plain string)", + doc: put(GOOD_FINDINGS, { bytes: "612f62" }, "findings", 3, "path"), + }, + { + label: "byte-form path with an extra member", + doc: put( + GOOD_FINDINGS, + { bytes: "ff", hint: "raw" }, + "findings", + 3, + "path", ), }, { - label: "wrong-typed file (must reject, not default)", - doc: put(GOOD_FINDINGS, 9, "findings", 0, "file"), + label: "path string carrying a lone surrogate (no UTF-8 bytes)", + doc: put(GOOD_FINDINGS, "\ud800", "findings", 3, "path"), }, { - label: "edge with unknown kind", - doc: put(GOOD_FINDINGS, "dependz", "findings", 1, "edge", "kind"), + label: "finding missing identities", + doc: omit(GOOD_FINDINGS, "findings", 2, "identities"), }, { - label: "cycle with empty identity", - doc: put(GOOD_FINDINGS, [""], "findings", 2, "cycle"), + label: "identities with an empty string", + doc: put(GOOD_FINDINGS, [""], "findings", 2, "identities"), + }, + { + label: "identities not an array", + doc: put( + GOOD_FINDINGS, + "no-derived-to-base", + "findings", + 2, + "identities", + ), + }, + { + label: + "findings out of the pinned order (numeric condition order: " + + "14.9 may not precede 14.2)", + doc: { + findings: [ + structuredClone(GOOD_FINDINGS.findings[1]), + structuredClone(GOOD_FINDINGS.findings[0]), + ], + }, + }, + { + label: + "lexicographic code-ordinal order passed off as numeric " + + "(14.10 sorts after 14.2, not before)", + doc: { + findings: [ + { + code: "stale-output", // 14.10 + message: "stale module", + locations: [], + path: "specs/A.xspec.ts", + identities: [], + }, + structuredClone(GOOD_FINDINGS.findings[0]), // 14.2 + ], + }, + }, + { + label: "a code-less finding sorted before a coded one", + doc: { + findings: [ + structuredClone(GOOD_FINDINGS.findings[5]), + structuredClone(GOOD_FINDINGS.findings[0]), + ], + }, + }, + { + label: "findings identical in every member (12.7 collapses duplicates)", + doc: { + findings: [ + structuredClone(GOOD_FINDINGS.findings[0]), + structuredClone(GOOD_FINDINGS.findings[0]), + ], + }, }, ], }, @@ -1262,6 +1504,185 @@ test("S-5: decoder context labels surface in diagnoses (two-document compares st expect(failure.message).toContain("second run"); }); +// --- the pinned 12.7 findings-order comparator --------------------------------- + +/** A decoded finding literal for comparator vectors (condition is derived + * information the comparator never reads). */ +function findingWith(over: Partial): Finding { + return { + code: null, + condition: null, + message: "m", + locations: [], + path: null, + identities: [], + ...over, + }; +} + +test("S-5: the findings comparator orders codes numerically, refusals in 14's order, code-less last", () => { + const c14_2 = findingWith({ code: "invalid-structural-id" }); + const c14_10 = findingWith({ code: "stale-output" }); + // Numeric condition order, not lexicographic: 14.2 before 14.10 even + // though "14.10" < "14.2" as strings. + expect(compareFindings(c14_2, c14_10)).toBeLessThan(0); + // Refusal reasons sort after every numbered condition, in 14's own order. + const refusalFirst = findingWith({ code: "refused-invalid-id" }); + const refusalLater = findingWith({ code: "refused-cycle" }); + expect( + compareFindings(findingWith({ code: "unreadable-record" }), refusalFirst), + ).toBeLessThan(0); + expect(compareFindings(refusalFirst, refusalLater)).toBeLessThan(0); + // Code-less findings sort last. + expect( + compareFindings(refusalLater, findingWith({ code: null })), + ).toBeLessThan(0); +}); + +test("S-5: the findings comparator compares locations, paths, and identities byte-wise with the prefix rule", () => { + const locA = { file: "specs/A.mdx", range: { start: 10, end: 30 } }; + const locB = { file: "specs/B.mdx", range: { start: 5, end: 25 } }; + // Element-wise location order, proper prefix first. + expect( + compareFindings( + findingWith({ locations: [locA] }), + findingWith({ locations: [locA, locB] }), + ), + ).toBeLessThan(0); + expect( + compareFindings( + findingWith({ locations: [locA] }), + findingWith({ locations: [locB] }), + ), + ).toBeLessThan(0); + // A null concerned path sorts before any path. + expect( + compareFindings(findingWith({ path: null }), findingWith({ path: "a" })), + ).toBeLessThan(0); + // Paths compare byte-wise whatever their presentation form: the marked + // byte form 0xFF sorts after the string "a" (0x61) in one byte order. + expect( + compareFindings( + findingWith({ path: "a" }), + findingWith({ path: { bytes: "ff" } }), + ), + ).toBeLessThan(0); + // Identities compare by UTF-8 bytes, not UTF-16 code units: U+FFFD + // (EF BF BD) sorts before U+10000 (F0 90 80 80), while UTF-16 compares + // them the other way around. + expect( + compareFindings( + findingWith({ identities: ["�"] }), + findingWith({ identities: ["\u{10000}"] }), + ), + ).toBeLessThan(0); + expect("�" < "\u{10000}").toBe(false); // the UTF-16 trap being guarded + // The message is the final tie-break; full equality is 0 (a duplicate). + expect( + compareFindings( + findingWith({ message: "a" }), + findingWith({ message: "b" }), + ), + ).toBeLessThan(0); + expect(compareFindings(findingWith({}), findingWith({}))).toBe(0); +}); + +// --- the three-state datum decode (11.4, 12.7) --------------------------------- + +test("S-5: the datum decode separates plain value, null, and the unavailability marker", () => { + const site = rootSite("datum self-test"); + expect(decodeDatum(5, site, expectNonNegativeInteger)).toEqual({ + state: "value", + value: 5, + }); + expect(decodeDatum(null, site, expectNonNegativeInteger)).toEqual({ + state: "null", + }); + // The marker never reaches the value decoder — a decoder that throws + // proves the marker (and null) are recognized structurally, not defaulted. + const neverCalled = (): never => { + throw new Error("the value decoder must not run for null or the marker"); + }; + expect(decodeDatum({ unavailable: true }, site, neverCalled)).toEqual({ + state: "unavailable", + }); + expect(decodeDatum(null, site, neverCalled)).toEqual({ state: "null" }); +}); + +test("S-5: the datum decode rejects omission, malformed markers, and malformed plain values", () => { + const site = rootSite("datum self-test"); + // An absent member is never a state: null is never omission (12.7). + expectDiagnosed("omitted member", () => + decodeDatum(undefined, site, expectNonNegativeInteger), + ); + // An object carrying "unavailable" must be exactly the marker. + expectDiagnosed("unavailable: false", () => + decodeDatum({ unavailable: false }, site, expectNonNegativeInteger), + ); + expectDiagnosed("marker with an extra member", () => + decodeDatum( + { unavailable: true, reason: "x" }, + site, + expectNonNegativeInteger, + ), + ); + expectDiagnosed('unavailable: "true" (not the boolean)', () => + decodeDatum({ unavailable: "true" }, site, expectNonNegativeInteger), + ); + // A plain value still decodes through the value decoder, fail-loud. + expectDiagnosed("plain value failing its decoder", () => + decodeDatum("five", site, expectNonNegativeInteger), + ); +}); + +// --- the unavailability-marker structural walk (T12.7-1) ------------------------ + +test("S-5: the marker walk accepts documents whose only unavailable-bearing objects are exact markers", () => { + assertUnavailabilityMarkerForms( + { + findings: [], + views: [ + { + root: { + identity: { unavailable: true }, + tags: null, + children: [{ identity: "a", tags: ["x"] }], + }, + }, + ], + delta: { unavailable: true }, + }, + "clean document", + ); + // The marker itself at top level is a legitimate document value. + assertUnavailabilityMarkerForms({ unavailable: true }, "bare marker"); + // Scalars and arrays carry no objects to offend. + assertUnavailabilityMarkerForms([1, "two", null], "scalar array"); +}); + +test("S-5: the marker walk rejects near-markers anywhere in the tree, naming the path", () => { + const wrongValue = expectDiagnosed("unavailable: false", () => + assertUnavailabilityMarkerForms( + { resolution: { unavailable: false } }, + "wrong value", + ), + ); + expect(wrongValue.message).toContain("$.resolution"); + const extraMember = expectDiagnosed("marker with a sibling member", () => + assertUnavailabilityMarkerForms( + { views: [{ source: { unavailable: true, identity: "a" } }] }, + "extra member", + ), + ); + expect(extraMember.message).toContain("$.views[0].source"); + expectDiagnosed("unavailable as an ordinary member", () => + assertUnavailabilityMarkerForms( + { node: { unavailable: "soon", other: 1 } }, + "ordinary member", + ), + ); +}); + // --- human-report matcher ---------------------------------------------------- function syntheticResult(stdout: string, stderr = ""): RunResult { diff --git a/test/suite/registry/section-1.3.ts b/test/suite/registry/section-1.3.ts index 83b8717..7512c1a 100644 --- a/test/suite/registry/section-1.3.ts +++ b/test/suite/registry/section-1.3.ts @@ -303,18 +303,20 @@ const T1_3_5 = defineProductTest({ for (const finding of findings) { const findingContext = `${sameFileContext}: a 14.3 finding`; assertFindingLocated(finding, { file: "specs/A.mdx" }, findingContext); - const { location } = finding; - const within = (window: { start: number; end: number }): boolean => - location !== undefined && - location.start >= window.start && - location.end <= window.end; - if (!within(firstWindow) && !within(secondWindow)) { - fail( - `${findingContext}: its location must point at one of the two duplicate ` + - `constructs (byte windows [${String(firstWindow.start)}, ${String(firstWindow.end)}] ` + - `and [${String(secondWindow.start)}, ${String(secondWindow.end)}]); got ` + - `[${String(location?.start)}, ${String(location?.end)})`, - ); + const within = ( + location: { start: number; end: number }, + window: { start: number; end: number }, + ): boolean => + location.start >= window.start && location.end <= window.end; + for (const { range } of finding.locations) { + if (!within(range, firstWindow) && !within(range, secondWindow)) { + fail( + `${findingContext}: every location must point at one of the two duplicate ` + + `constructs (byte windows [${String(firstWindow.start)}, ${String(firstWindow.end)}] ` + + `and [${String(secondWindow.start)}, ${String(secondWindow.end)}]); got ` + + `[${String(range.start)}, ${String(range.end)})`, + ); + } } } diff --git a/test/suite/registry/section-1.5.ts b/test/suite/registry/section-1.5.ts index 3efb6ab..c423e00 100644 --- a/test/suite/registry/section-1.5.ts +++ b/test/suite/registry/section-1.5.ts @@ -546,11 +546,14 @@ function assertFindingFile( expectedFile: string, context: string, ): void { - if (finding.file !== expectedFile) { + // 14.19 is a path-level condition: it carries the file's path as the + // concerned path (SPEC 14, 12.7 — no in-source location). + if (finding.path !== expectedFile) { fail( `${context}: the finding must identify the offending workspace-relative source ` + - `path (SPEC 14, 1.5); expected file ${JSON.stringify(expectedFile)}, got ` + - `${JSON.stringify(finding.file)} (message: ${JSON.stringify(finding.message)})`, + `path as its concerned path (SPEC 14, 1.5, 12.7); expected ` + + `${JSON.stringify(expectedFile)}, got ${JSON.stringify(finding.path)} ` + + `(message: ${JSON.stringify(finding.message)})`, ); } } diff --git a/test/suite/registry/section-1.6-1.7.ts b/test/suite/registry/section-1.6-1.7.ts index 2affc8a..b7adf49 100644 --- a/test/suite/registry/section-1.6-1.7.ts +++ b/test/suite/registry/section-1.6-1.7.ts @@ -959,16 +959,21 @@ const BOM = "\u{FEFF}"; const VALID_SECTION_SOURCE = '\nValid content.\n\n'; /** - * Exactly one finding names the file, and it carries condition 14.20 (SPEC - * 14: errors identify the file; 14.20 is a whole-file condition, so no - * in-file location is demanded of it). + * Exactly one finding names the file — locating in it, or carrying it as + * the concerned path — and it carries condition 14.20 (SPEC 14: errors + * identify the file; where a parser fails inside an unparseable file is + * parser-specific, so no particular range is demanded of it). */ function assertUnparseableFinding( findings: readonly Finding[], file: string, context: string, ): void { - const matching = findings.filter((finding) => finding.file === file); + const matching = findings.filter( + (finding) => + finding.locations.some((location) => location.file === file) || + finding.path === file, + ); if (matching.length !== 1) { fail( `${context}: expected exactly one finding naming ${JSON.stringify(file)} ` + @@ -976,7 +981,8 @@ function assertUnparseableFinding( JSON.stringify( findings.map((finding) => ({ condition: finding.condition, - file: finding.file ?? null, + locations: finding.locations, + path: finding.path, })), ), ); diff --git a/test/suite/registry/section-12.0-i.ts b/test/suite/registry/section-12.0-i.ts index 3c909d3..0262279 100644 --- a/test/suite/registry/section-12.0-i.ts +++ b/test/suite/registry/section-12.0-i.ts @@ -461,11 +461,17 @@ const T12_0_2 = defineProductTest({ ), jsonContext, ).findings; - if (!findings.some((finding) => finding.file === "specs/A.mdx")) { + if ( + !findings.some((finding) => + finding.locations.some( + (location) => location.file === "specs/A.mdx", + ), + ) + ) { fail( `${jsonContext}: the findings report carries the same ` + `information as the human report (SPEC 12.0) — expected a ` + - `finding naming specs/A.mdx, got ` + + `finding locating in specs/A.mdx, got ` + `${JSON.stringify(findings)}`, ); } diff --git a/test/suite/registry/section-12.1-12.2.ts b/test/suite/registry/section-12.1-12.2.ts index ba3b0f3..1faec24 100644 --- a/test/suite/registry/section-12.1-12.2.ts +++ b/test/suite/registry/section-12.1-12.2.ts @@ -201,11 +201,11 @@ function assertAllStale(findings: readonly Finding[], context: string): void { `${JSON.stringify(finding.condition)} (message: ${JSON.stringify(finding.message)})`, ); } - if (finding.file === undefined) { + if (finding.path === null) { fail( - `${context}: a 14.10 finding names the stale or orphaned file ` + - `(SPEC 14.10); got a finding without a file (message: ` + - `${JSON.stringify(finding.message)})`, + `${context}: a 14.10 finding names the stale or orphaned file as ` + + `its concerned path (SPEC 14.10, 12.7); got a finding without ` + + `one (message: ${JSON.stringify(finding.message)})`, ); } if (!/build/i.test(finding.message)) { @@ -225,13 +225,13 @@ function assertSingleStaleFile( context: string, ): void { assertAllStale(findings, context); - if (findings.length !== 1 || findings[0]!.file !== rel) { + if (findings.length !== 1 || findings[0]!.path !== rel) { fail( `${context}: the fixture's only stale file is ${JSON.stringify(rel)} — ` + `sources, configuration, and every other derived file are fresh — so ` + `exactly one 14.10 finding naming it is expected (SPEC 14.10); got ` + JSON.stringify( - findings.map(({ condition, file }) => ({ condition, file })), + findings.map(({ condition, path }) => ({ condition, path })), ), ); } @@ -243,11 +243,11 @@ function assertStaleFileNamed( rel: string, context: string, ): void { - if (!findings.some((finding) => finding.file === rel)) { + if (!findings.some((finding) => finding.path === rel)) { fail( `${context}: a 14.10 finding must name ${JSON.stringify(rel)} (SPEC ` + - `14.10: the error names the file); named files: ` + - JSON.stringify(findings.map((finding) => finding.file)), + `14.10: the error names the file; 12.7 concerned path); named: ` + + JSON.stringify(findings.map((finding) => finding.path)), ); } } @@ -965,15 +965,15 @@ const T12_2_2 = defineProductTest({ (finding) => finding.condition === "14.21", )!; if ( - corrupt.file !== CORRUPT_SESSION_PATH && + corrupt.path !== CORRUPT_SESSION_PATH && !/bad/.test(corrupt.message) ) { fail( `${context}: the 14.21 finding must identify the corrupt ` + `session — the finding naming the session file ` + `${CORRUPT_SESSION_PATH} or the message naming the session ` + - `"bad" (SPEC 14, 14.21; H-3 information presence); got file ` + - `${JSON.stringify(corrupt.file)}, message ${JSON.stringify(corrupt.message)}`, + `"bad" (SPEC 14, 14.21; H-3 information presence); got path ` + + `${JSON.stringify(corrupt.path)}, message ${JSON.stringify(corrupt.message)}`, ); } }, diff --git a/test/suite/registry/section-13.3.ts b/test/suite/registry/section-13.3.ts index 1e68120..083c93c 100644 --- a/test/suite/registry/section-13.3.ts +++ b/test/suite/registry/section-13.3.ts @@ -1279,20 +1279,20 @@ function assertStaleModuleFindings( `${JSON.stringify(finding.message)})`, ); } - if (finding.file === undefined || !finding.file.startsWith(prefix)) { + if (typeof finding.path !== "string" || !finding.path.startsWith(prefix)) { fail( - `${context}: a 14.10 finding must name the stale derived file, all ` + - `of which are ${prefix}* here (SPEC 14.10, 13.1); got ` + - `${finding.file === undefined ? "no file" : JSON.stringify(finding.file)} ` + + `${context}: a 14.10 finding must name the stale derived file as ` + + `its concerned path, all of which are ${prefix}* here (SPEC ` + + `14.10, 13.1, 12.7); got ${JSON.stringify(finding.path)} ` + `(message: ${JSON.stringify(finding.message)})`, ); } } - if (!findings.some((finding) => finding.file === module)) { + if (!findings.some((finding) => finding.path === module)) { fail( `${context}: the generated module ${module} must be among the named ` + `stale files (SPEC 14.10, 13.1); named: ` + - JSON.stringify(findings.map((finding) => finding.file)), + JSON.stringify(findings.map((finding) => finding.path)), ); } } @@ -1488,7 +1488,9 @@ const T13_3_3 = defineProductTest({ !findings.some( (finding) => finding.condition === "14.1" && - finding.file === "specs/B.mdx", + finding.locations.some( + (location) => location.file === "specs/B.mdx", + ), ) ) { fail( @@ -1497,7 +1499,7 @@ const T13_3_3 = defineProductTest({ JSON.stringify( findings.map((finding) => ({ condition: finding.condition, - file: finding.file, + locations: finding.locations, })), ), ); diff --git a/test/suite/registry/section-14.ts b/test/suite/registry/section-14.ts index bc4979a..7b73c50 100644 --- a/test/suite/registry/section-14.ts +++ b/test/suite/registry/section-14.ts @@ -462,7 +462,11 @@ const T14_2 = defineProductTest({ const typescriptFindings = findings .filter((finding) => finding.condition === "14.7") .slice() - .sort((a, b) => (a.location?.start ?? -1) - (b.location?.start ?? -1)); + .sort( + (a, b) => + (a.locations[0]?.range.start ?? -1) - + (b.locations[0]?.range.start ?? -1), + ); assertFindingLocated( typescriptFindings[0]!, { @@ -616,7 +620,9 @@ function assertMaskingReport( `unresolved (SPEC 14, 14.20, 14.5–14.7)`, ); for (const file of T14_3_UNPARSEABLE_FILES) { - const matching = findings.filter((finding) => finding.file === file); + const matching = findings.filter((finding) => + finding.locations.some((location) => location.file === file), + ); if (matching.length !== 1 || matching[0]!.condition !== "14.20") { fail( `${context}: expected exactly one finding naming ` + @@ -640,7 +646,10 @@ function assertMaskingReport( const filesOf = (condition: string): string[] => findings .filter((finding) => finding.condition === condition) - .map((finding) => finding.file ?? "") + .map((finding) => { + const file = finding.locations[0]?.file; + return typeof file === "string" ? file : ""; + }) .sort(); assertSameJson( filesOf("14.5"), diff --git a/test/suite/registry/section-16-p1.ts b/test/suite/registry/section-16-p1.ts index 109e175..45e5e05 100644 --- a/test/suite/registry/section-16-p1.ts +++ b/test/suite/registry/section-16-p1.ts @@ -422,7 +422,7 @@ function assertRejectionFindings( ); } for (const finding of findings) { - if (!allowed.includes(finding.condition)) { + if (finding.condition === null || !allowed.includes(finding.condition)) { fail( `${context}: reported condition ${JSON.stringify(finding.condition)} is not ` + `among the staged condition(s) ${JSON.stringify(allowed)} ` + diff --git a/test/suite/registry/section-16-p7.ts b/test/suite/registry/section-16-p7.ts index dc04d7e..33c2e18 100644 --- a/test/suite/registry/section-16-p7.ts +++ b/test/suite/registry/section-16-p7.ts @@ -974,13 +974,19 @@ function expectedFindingRenderings(trial: CaptureTrial): string[] { return expected.sort(); } +/** + * Render one policy finding from its contractual identities — in order, the + * violated rule's name and the offending edge's source identity, kind token, + * and target identity (SPEC 14.12, 12.7) — as `rule :: kind: from -> to`. + * A finding without the four identities renders verbatim, failing the + * comparison with the offense visible. + */ function renderFinding(finding: Finding): string { - return ( - `${finding.rule ?? ""} :: ` + - (finding.edge === undefined - ? "" - : `${finding.edge.kind}: ${finding.edge.from} -> ${finding.edge.to}`) - ); + if (finding.identities.length !== 4) { + return ` ${JSON.stringify(finding.identities)}`; + } + const [rule, from, kind, to] = finding.identities; + return `${rule} :: ${kind}: ${from} -> ${to}`; } function renderCaptureTrial(trial: CaptureTrial): string { diff --git a/test/suite/registry/section-2.1.ts b/test/suite/registry/section-2.1.ts index 8d0d63c..a744945 100644 --- a/test/suite/registry/section-2.1.ts +++ b/test/suite/registry/section-2.1.ts @@ -23,6 +23,7 @@ import type { Finding } from "../../helpers/adapters/index.js"; import { DEPENDENCY_EDGE_KINDS, decodeEdgesReport, + renderPathValue, } from "../../helpers/adapters/index.js"; import { fail } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; @@ -444,19 +445,18 @@ const T2_1_3 = defineProductTest({ { file: "specs/A.mdx" }, findingContext, ); - const { location } = finding; - const within = windows.some( - (window) => - location !== undefined && - location.start >= window.start && - location.end <= window.end, - ); - if (!within) { - fail( - `${findingContext}: its location [${String(location?.start)}, ` + - `${String(location?.end)}) must point at one of the two colliding ` + - `import statements (byte windows ${JSON.stringify(windows)})`, + for (const { range } of finding.locations) { + const within = windows.some( + (window) => + range.start >= window.start && range.end <= window.end, ); + if (!within) { + fail( + `${findingContext}: every location [${String(range.start)}, ` + + `${String(range.end)}) must point at one of the two colliding ` + + `import statements (byte windows ${JSON.stringify(windows)})`, + ); + } } } }, @@ -558,8 +558,9 @@ const SELF_IMPORT_SOURCE = * present in these fixtures — both files parse, and every reference * resolves), at most one finding per participating file (whether a product * reports a cycle once or per file is not fixed), and the report identifies - * every participating file (SPEC 14: actionable errors identify the file) - * through any of a finding's file, message, or cycle-path information. + * every participating file (SPEC 14: actionable errors identify the file — + * each participating import declaration located in the file containing it) + * through any of a finding's located files, message, or identity context. */ function assertImportCycleFindings( findings: readonly Finding[], @@ -580,9 +581,11 @@ function assertImportCycleFindings( } const identified = findings .map((finding) => - [finding.message, finding.file ?? "", ...(finding.cycle ?? [])].join( - "\n", - ), + [ + finding.message, + ...finding.locations.map((location) => renderPathValue(location.file)), + ...finding.identities, + ].join("\n"), ) .join("\n"); for (const file of expectedFiles) { diff --git a/test/suite/registry/section-2.5-2.6.ts b/test/suite/registry/section-2.5-2.6.ts index 305f893..86c612e 100644 --- a/test/suite/registry/section-2.5-2.6.ts +++ b/test/suite/registry/section-2.5-2.6.ts @@ -1231,15 +1231,12 @@ const T2_6_3 = defineProductTest({ ); const violation = findings[0]!; assertSameJson( - violation.rule, - "no-ui-to-core", - "T2.6-3 the violation names its rule (SPEC 7.5)", - ); - assertSameJson( - violation.edge, - { from: T2_6_3_SRC, to: T2_6_3_TAGGED, kind: "depends" }, - "T2.6-3 the violation reports the offending edge — selected by the nodes' " + - "tags (SPEC 2.6, 7.5)", + violation.identities, + ["no-ui-to-core", T2_6_3_SRC, "depends", T2_6_3_TAGGED], + "T2.6-3 the violation's identities are, in order, the violated " + + "rule's name and the offending edge's source identity, kind " + + "token, and target identity — the edge selected by the nodes' " + + "tags (SPEC 2.6, 7.5, 14.12, 12.7)", ); }, ); diff --git a/test/suite/registry/section-4.ts b/test/suite/registry/section-4.ts index c143cd8..1b2b4f3 100644 --- a/test/suite/registry/section-4.ts +++ b/test/suite/registry/section-4.ts @@ -473,19 +473,17 @@ async function runInvalidTsImportArm( for (const finding of findings) { const findingContext = `${context}: a 14.15 finding`; assertFindingLocated(finding, { file: "src/app.ts" }, findingContext); - const { location } = finding; - const within = windows.some( - (window) => - location !== undefined && - location.start >= window.start && - location.end <= window.end, - ); - if (!within) { - fail( - `${findingContext}: its location [${String(location?.start)}, ` + - `${String(location?.end)}) must point at one of the colliding ` + - `import statements (byte windows ${JSON.stringify(windows)})`, + for (const { range } of finding.locations) { + const within = windows.some( + (window) => range.start >= window.start && range.end <= window.end, ); + if (!within) { + fail( + `${findingContext}: every location [${String(range.start)}, ` + + `${String(range.end)}) must point at one of the colliding ` + + `import statements (byte windows ${JSON.stringify(windows)})`, + ); + } } } }, diff --git a/test/suite/registry/section-5.1-5.3.ts b/test/suite/registry/section-5.1-5.3.ts index 7a12e61..8f1459e 100644 --- a/test/suite/registry/section-5.1-5.3.ts +++ b/test/suite/registry/section-5.1-5.3.ts @@ -17,13 +17,16 @@ // 14.9 is reported by `build` and `check` alike (SPEC 14). // // Conservative operationalizations (noted per H-4): -// - Cycle-path acceptance: SPEC 5.3 fixes the information — the full cycle — -// not its rendering, so a reported path is accepted in any rotation (any -// starting node) and in open or closed-walk form (first identity repeated -// at the end). Direction is never relaxed: the path follows the cycle's -// edges, so a reversed or partial sequence is rejected — in particular the -// ancestor arms' three-node cycles must include the intermediate section -// the `contains` chain runs through. +// - Cycle-path acceptance: SPEC 12.7/14 render a cycle's full path through +// the finding's `locations` — every reference spelling recording a +// participating dependency edge, each located in the file containing it — +// while the identity sequence is informational context (12.7: identities +// are contractual only where 14 states them). These fixtures do not +// precompute per-spelling byte offsets, so the assertion here binds the +// file dimension: every finding locates only within the participating +// files, and every participating file is identified through located +// files, message, or identity context. Byte-precise full-path location +// assertion is T14-8's (section-14.ts). // - The cross-file `depends` arm of T5.3-1 necessarily co-stages a spec // import cycle: a cross-file `depends` edge needs an external reference // (the local string form is same-file only, SPEC 2.2), external references @@ -118,45 +121,6 @@ async function checkFindings( .findings; } -/** - * A reported cycle path reduced to its open cyclic form: a closed walk (the - * first identity repeated at the end) reduces to its open rotation, so - * `[a, b, a]` and `[a, b]` name the same cycle (SPEC 5.3 fixes the - * information, not the rendering). - */ -function openCycleForm(path: readonly string[]): readonly string[] { - if (path.length > 1 && path[0] === path[path.length - 1]) { - return path.slice(0, -1); - } - return path; -} - -/** - * Whether a reported cycle path names exactly the staged cycle: the same - * identities in the same cyclic edge order, from any starting node - * (rotation-invariant), open or closed form. Direction is never relaxed — - * the path follows the cycle's edges — and no node may be missing or extra. - */ -function matchesCycle( - reported: readonly string[], - staged: readonly string[], -): boolean { - const open = openCycleForm(reported); - const n = staged.length; - if (open.length !== n) return false; - for (let shift = 0; shift < n; shift += 1) { - let matched = true; - for (let i = 0; i < n; i += 1) { - if (open[(shift + i) % n] !== staged[i]) { - matched = false; - break; - } - } - if (matched) return true; - } - return false; -} - /** The file path of a requirement-node identity (SPEC 1.5: `path#id`). */ function fileOfIdentity(identity: string): string { const hash = identity.indexOf("#"); @@ -177,10 +141,14 @@ interface CycleExpectation { /** * Assert a findings report over a fixture staging exactly one dependency * cycle (plus, when stated, the import cycle its cross-file staging - * necessarily carries): every finding is 14.9, the dependency cycle is - * reported with its full cycle path — once, or at most once per - * participating file — and the co-staged import cycle accounts for every - * remaining finding (SPEC 5.3, 14, 14.9). + * necessarily carries): every finding is 14.9 and locates only within the + * participating files — a cycle's full path renders through its locations, + * every participating reference spelling (or import declaration) located in + * the file containing it (SPEC 5.3, 14, 12.7) — the finding count is + * bounded (one per cycle, or at most one per participating file), and every + * participating file is identified through located files, message, or + * identity context (the T2.1-5 convention; byte-precise path location is + * T14-8's assertion). */ function assertDependencyCycleFindings( findings: readonly Finding[], @@ -199,65 +167,72 @@ function assertDependencyCycleFindings( ); } - // The dependency-cycle report: the finding(s) carrying the staged cycle's - // full path. SPEC 5.3 mandates the full path, so a finding without one (or - // with a rotated-but-wrong, partial, or reversed one) never counts. - const cycleFindings = findings.filter( - (finding) => - finding.cycle !== undefined && - matchesCycle(finding.cycle, expectation.cycle), - ); - const cycleFileCount = new Set(expectation.cycle.map(fileOfIdentity)).size; - if (cycleFindings.length < 1 || cycleFindings.length > cycleFileCount) { + const cycleFiles = [...new Set(expectation.cycle.map(fileOfIdentity))]; + const importCycleFiles = expectation.importCycleFiles ?? []; + const participatingFiles = new Set([...cycleFiles, ...importCycleFiles]); + + // Count bounds: each staged cycle is its own condition instance, so each + // is reported (SPEC 14: every present error reported) — at least one + // finding per staged cycle — and at most once per cycle or per + // participating file (the T1.3-5/T2.1-5 per-file tolerance). + const min = 1 + (expectation.importCycleFiles === undefined ? 0 : 1); + const max = + cycleFiles.length + + (expectation.importCycleFiles === undefined ? 0 : importCycleFiles.length); + if (findings.length < min || findings.length > max) { fail( - `${context}: the dependency cycle must be reported with its full cycle ` + - `path — ${JSON.stringify(expectation.cycle)}, accepted in any rotation, ` + - `open or closed form — once, or at most once per participating file ` + - `(${String(cycleFileCount)}); got ${String(cycleFindings.length)} such ` + - `finding(s) among ${JSON.stringify(findings)}`, + `${context}: between ${String(min)} and ${String(max)} 14.9 finding(s) ` + + `report the staged cycle(s) — each cycle reported, once or at most ` + + `once per participating file — got ${String(findings.length)}: ` + + `${JSON.stringify(findings)}`, ); } - const rest = findings.filter((finding) => !cycleFindings.includes(finding)); - const importCycleFiles = expectation.importCycleFiles; - if (importCycleFiles === undefined) { - if (rest.length > 0) { + // Every finding locates its cycle's participating spellings: at least one + // location, every located file a participating file (SPEC 14: every + // reference spelling recording a participating dependency edge, or each + // participating import declaration, located in the file containing it). + for (const finding of findings) { + if (finding.locations.length === 0) { fail( - `${context}: the staged dependency cycle is the fixture's only cycle, ` + - `so nothing beyond its report may appear (SPEC 14: each present error ` + - `reported, nothing double-reported); got extra findings ` + - JSON.stringify(rest), + `${context}: a 14.9 finding locates its cycle's participating ` + + `spellings in source (SPEC 14, 12.7); got a finding with no ` + + `locations: ${JSON.stringify(finding)}`, ); } - return; - } - // The co-staged spec import cycle: reported once, or at most once per - // participating file, identifying every participating file through any of - // a finding's file, message, or cycle-path information (the T2.1-5 - // convention; SPEC 2.1, 14). - if (rest.length < 1 || rest.length > importCycleFiles.length) { - fail( - `${context}: the mutual imports this cross-file cycle needs are ` + - `themselves a spec import cycle (SPEC 2.1), reported as one further ` + - `14.9 finding — or at most one per participating file ` + - `(${String(importCycleFiles.length)}); got ${String(rest.length)} ` + - `finding(s) beyond the dependency-cycle report: ${JSON.stringify(findings)}`, - ); + for (const location of finding.locations) { + if ( + typeof location.file !== "string" || + !participatingFiles.has(location.file) + ) { + fail( + `${context}: a 14.9 finding's locations lie in the cycle's ` + + `participating files ${JSON.stringify([...participatingFiles])} ` + + `(SPEC 14); got a location in ${JSON.stringify(location.file)}`, + ); + } + } } - const identified = rest + + // Every participating file is identified (SPEC 14: actionable errors + // identify the file) — through located files, message, or identity context. + const identified = findings .map((finding) => - [finding.message, finding.file ?? "", ...(finding.cycle ?? [])].join( - "\n", - ), + [ + finding.message, + ...finding.locations.map((location) => + typeof location.file === "string" ? location.file : "", + ), + ...finding.identities, + ].join("\n"), ) .join("\n"); - for (const file of importCycleFiles) { + for (const file of participatingFiles) { if (!identified.includes(file)) { fail( - `${context}: the import-cycle report must identify the participating ` + - `file ${JSON.stringify(file)} (SPEC 14: actionable errors identify ` + - `the file); findings beyond the dependency-cycle report: ` + - JSON.stringify(rest), + `${context}: the cycle report must identify the participating file ` + + `${JSON.stringify(file)} (SPEC 14: actionable errors identify the ` + + `file); findings: ${JSON.stringify(findings)}`, ); } } diff --git a/test/suite/registry/section-6.1.ts b/test/suite/registry/section-6.1.ts index 6b2f08c..4f97838 100644 --- a/test/suite/registry/section-6.1.ts +++ b/test/suite/registry/section-6.1.ts @@ -454,9 +454,11 @@ async function checkReportsJournalError( /** * Does a 14.13 finding name the garbage line (line 2)? Accepted forms (H-4 - * operationalization, see the module header): a location within the garbage - * line's byte window in `.xspec/journal`; the message echoing the garbage - * line; or the message citing line/entry 2. + * operationalization, see the module header): the message echoing the + * garbage line or citing line/entry 2 — a journal condition carries the + * journal path it concerns and no in-source location (SPEC 14, 12.7), so + * the lines are named in the message — or, tolerated, a location within the + * garbage line's byte window in `.xspec/journal`. */ function findingNamesGarbageLine( finding: Finding, @@ -465,11 +467,11 @@ function findingNamesGarbageLine( if (finding.message.includes(GARBAGE_LINE)) return true; if (/\b(?:line|entry)\s*#?\s*2\b/i.test(finding.message)) return true; if (finding.message.includes("journal:2")) return true; - return ( - finding.location !== undefined && - (finding.file === undefined || finding.file === JOURNAL_PATH) && - finding.location.start >= window.start && - finding.location.end <= window.end + 1 + return finding.locations.some( + (location) => + location.file === JOURNAL_PATH && + location.range.start >= window.start && + location.range.end <= window.end + 1, ); } diff --git a/test/suite/registry/section-6.6.ts b/test/suite/registry/section-6.6.ts index db1be16..5633524 100644 --- a/test/suite/registry/section-6.6.ts +++ b/test/suite/registry/section-6.6.ts @@ -475,7 +475,9 @@ const T6_6_1 = defineProductTest({ [V2_ORIGIN, staleOrigin, "same-file local string reference"], [V2_WATCH, staleWatch, "cross-file imported chain reference"], ] as const) { - const located = findings.filter((finding) => finding.file === file); + const located = findings.filter((finding) => + finding.locations.some((location) => location.file === file), + ); if (located.length !== 1) { fail( `${staleLabel}: expected exactly one 14.5 finding naming ` + diff --git a/test/suite/registry/section-7.1-7.3.ts b/test/suite/registry/section-7.1-7.3.ts index 1ce5699..f3d8f32 100644 --- a/test/suite/registry/section-7.1-7.3.ts +++ b/test/suite/registry/section-7.1-7.3.ts @@ -316,17 +316,24 @@ function assertProfileSeesSharedFile( ); } +/** + * Render one policy finding from its contractual identities — in order, the + * violated rule's name and the offending edge's source identity, kind token, + * and target identity (SPEC 14.12, 12.7) — as `rule :: kind: from -> to`. + * A finding without the four identities renders verbatim, failing the + * comparison with the offense visible. + */ +function renderPolicyIdentities(finding: Finding): string { + if (finding.identities.length !== 4) { + return ` ${JSON.stringify(finding.identities)}`; + } + const [rule, from, kind, to] = finding.identities; + return `${rule} :: ${kind}: ${from} -> ${to}`; +} + /** Render policy findings for order-insensitive exact comparison (7.5). */ function renderPolicyFindings(findings: readonly Finding[]): string[] { - return findings - .map( - (finding) => - `${finding.rule ?? ""} :: ` + - (finding.edge === undefined - ? "" - : `${finding.edge.kind}: ${finding.edge.from} -> ${finding.edge.to}`), - ) - .sort(); + return findings.map(renderPolicyIdentities).sort(); } const T7_1_1 = defineProductTest({ @@ -436,11 +443,12 @@ export default defineConfig({ const findings = await buildFindings(product, workspace, context); assertConditionCounts(findings, { "14.19": 1 }, context); const finding = findings[0]!; - if (finding.file !== "specs/notes.txt") { + if (finding.path !== "specs/notes.txt") { fail( `${context}: the 14.19 finding must identify the offending ` + - `workspace-relative source path (SPEC 14, 7.1, 1.5); expected ` + - `file "specs/notes.txt", got ${JSON.stringify(finding.file)} ` + + `workspace-relative source path as its concerned path (SPEC ` + + `14, 7.1, 1.5, 12.7); expected "specs/notes.txt", got ` + + `${JSON.stringify(finding.path)} ` + `(message: ${JSON.stringify(finding.message)})`, ); } diff --git a/test/suite/registry/section-7.4-7.5.ts b/test/suite/registry/section-7.4-7.5.ts index deb0510..9f7703c 100644 --- a/test/suite/registry/section-7.4-7.5.ts +++ b/test/suite/registry/section-7.4-7.5.ts @@ -259,19 +259,32 @@ function assertPolicyFindings( expected.length === 0 ? {} : { "14.12": expected.length }, context, ); + // SPEC 14.12/12.7: the offending entity is a graph edge, not a spelling — + // `locations` empty, `path` null, the identities in order the violated + // rule's name and the edge's source identity, kind token, and target. + for (const finding of findings) { + if (finding.locations.length !== 0 || finding.path !== null) { + fail( + `${context}: a policy finding carries no in-source locations and ` + + `concerns no path — \`locations\` [], \`path\` null (SPEC 14.12, ` + + `12.7); got locations ${JSON.stringify(finding.locations)}, path ` + + `${JSON.stringify(finding.path)}`, + ); + } + } assertSameJson( findings - .map( - (finding) => - `${finding.rule ?? ""} :: ` + - (finding.edge === undefined - ? "" - : `${finding.edge.kind}: ${finding.edge.from} -> ${finding.edge.to}`), - ) + .map((finding) => { + if (finding.identities.length !== 4) { + return ` ${JSON.stringify(finding.identities)}`; + } + const [rule, from, kind, to] = finding.identities; + return `${rule} :: ${kind}: ${from} -> ${to}`; + }) .sort(), expected.map((entry) => renderPolicyPair(entry.rule, entry.edge)).sort(), - `${context}: policy findings as (rule name, offending edge) pairs ` + - `(SPEC 7.5, 14.12)`, + `${context}: policy findings' identities as (rule name, offending edge) ` + + `pairs (SPEC 7.5, 14.12, 12.7)`, ); } diff --git a/test/suite/registry/section-8.ts b/test/suite/registry/section-8.ts index a7f33b1..b8919c6 100644 --- a/test/suite/registry/section-8.ts +++ b/test/suite/registry/section-8.ts @@ -763,17 +763,24 @@ Depends on the derived file as a whole. "specs/B.mdx": derivedSource("Derived leaf one."), }; +/** + * Render one policy finding from its contractual identities — in order, the + * violated rule's name and the offending edge's source identity, kind token, + * and target identity (SPEC 14.12, 12.7) — as `rule :: kind: from -> to`. + * A finding without the four identities renders verbatim, failing the + * comparison with the offense visible. + */ +function renderPolicyIdentities(finding: Finding): string { + if (finding.identities.length !== 4) { + return ` ${JSON.stringify(finding.identities)}`; + } + const [rule, from, kind, to] = finding.identities; + return `${rule} :: ${kind}: ${from} -> ${to}`; +} + /** Render policy findings for order-insensitive exact comparison (7.5). */ function renderPolicyFindings(findings: readonly Finding[]): string[] { - return findings - .map( - (finding) => - `${finding.rule ?? ""} :: ` + - (finding.edge === undefined - ? "" - : `${finding.edge.kind}: ${finding.edge.from} -> ${finding.edge.to}`), - ) - .sort(); + return findings.map(renderPolicyIdentities).sort(); } /** diff --git a/test/suite/registry/support.ts b/test/suite/registry/support.ts index 3453231..ffbd27a 100644 --- a/test/suite/registry/support.ts +++ b/test/suite/registry/support.ts @@ -162,7 +162,10 @@ export async function readGeneratedModule( * Assert the exact multiset of SPEC.md 14 condition identities present in a * findings report (`{"14.2": 1, ...}`): every condition staged in the fixture * is reported — none masked away, none phantom, none double-reported (§14: - * when several error conditions are present, each is reported). + * when several error conditions are present, each is reported). Counting keys + * are the derived `14.N` identities of numbered-condition code tokens + * (model.ts: the harness-pinned token table); a refusal finding counts under + * its refusal code, and a code-less finding under `"(code-less)"`. */ export function assertConditionCounts( findings: readonly Finding[], @@ -171,7 +174,8 @@ export function assertConditionCounts( ): void { const counts: Record = {}; for (const finding of findings) { - counts[finding.condition] = (counts[finding.condition] ?? 0) + 1; + const key = finding.condition ?? finding.code ?? "(code-less)"; + counts[key] = (counts[key] ?? 0) + 1; } const render = (record: Readonly>): string[] => Object.entries(record) @@ -205,8 +209,8 @@ export interface FindingSourceExpectation { /** The workspace-relative, `/`-separated source file (SPEC.md 1.5, 14). */ readonly file: string; /** - * Byte window the finding's location must fall within — as computed by the - * caller from its fixture's exact bytes (typically the offending + * Byte window the finding's location ranges must fall within — as computed + * by the caller from its fixture's exact bytes (typically the offending * construct's own range, end-widened where the caller tolerates a * line-granular location). */ @@ -214,40 +218,44 @@ export interface FindingSourceExpectation { } /** - * Assert a finding identifies its source: the file it names, a location, and - * optionally that the location falls within the offending construct's byte - * window (SPEC.md 14: errors identify the file, location, and correction). + * Assert a finding locates its offending construct(s): at least one + * `locations` entry (SPEC.md 14: every condition that locates in source + * carries the containing file and a range; 12.7), every entry naming the + * expected workspace-relative file, and — when a window is given — every + * range falling within the offending construct's byte window. */ export function assertFindingLocated( finding: Finding, expected: FindingSourceExpectation, context: string, ): void { - if (finding.file !== expected.file) { - fail( - `${context}: the finding must name the workspace-relative source file ` + - `(SPEC.md 14, 1.5); expected ${JSON.stringify(expected.file)}, got ` + - `${JSON.stringify(finding.file)} (message: ${JSON.stringify(finding.message)})`, - ); - } - if (finding.location === undefined) { + if (finding.locations.length === 0) { fail( `${context}: the finding must carry a location (SPEC.md 14: errors identify ` + - `the file, location, and correction); got none (message: ` + + `the file, location, and correction; 12.7 locations); got none (message: ` + `${JSON.stringify(finding.message)})`, ); } - const { window } = expected; - if ( - window !== undefined && - (finding.location.start < window.start || finding.location.end > window.end) - ) { - fail( - `${context}: the finding's location [${String(finding.location.start)}, ` + - `${String(finding.location.end)}) must fall within the offending construct's ` + - `byte window [${String(window.start)}, ${String(window.end)}] (message: ` + - `${JSON.stringify(finding.message)})`, - ); + for (const location of finding.locations) { + if (location.file !== expected.file) { + fail( + `${context}: the finding must locate in the workspace-relative source ` + + `file (SPEC.md 14, 1.5, 12.7); expected ${JSON.stringify(expected.file)}, ` + + `got ${JSON.stringify(location.file)} (message: ${JSON.stringify(finding.message)})`, + ); + } + const { window } = expected; + if ( + window !== undefined && + (location.range.start < window.start || location.range.end > window.end) + ) { + fail( + `${context}: the finding's location [${String(location.range.start)}, ` + + `${String(location.range.end)}) must fall within the offending construct's ` + + `byte window [${String(window.start)}, ${String(window.end)}] (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } } } From 2ebe4b9e43a4cc2aa5b15500520901bf1ac4774d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 22:19:17 +0000 Subject: [PATCH 049/226] sdg(phase-9): assert the 12.7 error document on exit 2 with JSON in effect (FP-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC 12.0/12.7 and amended H-5: with JSON output in effect (--json among the arguments, even erroneous ones, or a JSON-only surface), an exit-2 invocation emits the single 12.7 error document {"error": ...} as its entire stdout; stdout is byte-empty on exit 2 only when JSON is NOT in effect; the output form never changes exit codes or stderr content. - assertions.ts: invert assertJsonOutputConvention's exit-2 branch to the error-document contract (protocol grain; P-8 inherits it). - forms.ts/model.ts: add the form-exact decodeErrorDocument ({"error": ...} holding one literal finding form) with S-5 good/bad decoder guards. - support.ts: expectErrorDocument sugar; expectConfigurationError now asserts the error document with stable code configuration-error and a non-null concerned path, stderr /config/i duty unchanged. - Sweep every exit-2-with-JSON stdout-empty assertion to the error document: sections 6.3/6.4/6.5 usage-error helpers, 7-basics and 7.1-7.3 T7-3/T7.3-1 query arms (JSON-only surface), 7.4-7.5 T7.4-1, 10.1, 10.2-10.3, 10.7-i/ii, 11, 12.0-i (T12.0-2/-4/-5/-6), 12.3-12.5; human exit-2 arms keep byte-empty stdout. - T12.0-2: add the stderr-invariance arms — a failing build (exit 1) and the unknown-flag usage error (exit 2), each run with and without --json, stderr byte-identical across forms (H-4 product-to-itself). - assertion-protocol self-test reworked to guard the inverted convention. - conf-disc fixture: emit the error document under --json (UsageError carries code/path; configuration errors decorated centrally with configuration-error and the anchoring-form concerned path) so T7-4 stays certified; violators unchanged. Verified: npm run test:self at 5 failed / 230 passed - exactly the pre-existing s1-traceability x2 (awaits FP-003/stages E-G) and certification-document x3 (awaits FP-091); certification fully green. Affected suite files fail red-as-diagnosed only (HarnessAssertionError) against the current product, e.g. 12.0-i 5/6, 6.3 2/4, 7.4-7.5 7/8, P-8 falsified with seed+shrink via the shared helper. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 2 +- test/fixtures/conf-disc/product.mjs | 81 ++++++++++++++- test/helpers/adapters/forms.ts | 31 ++++++ test/helpers/adapters/model.ts | 11 +++ test/helpers/assertions.ts | 44 +++++++-- test/self/assertion-protocol.test.ts | 58 ++++++++++- test/self/s5-output-adapters.test.ts | 121 +++++++++++++++++++++++ test/suite/registry/section-10.1.ts | 22 +++-- test/suite/registry/section-10.2-10.3.ts | 22 +++-- test/suite/registry/section-10.7-i.ts | 23 +++-- test/suite/registry/section-10.7-ii.ts | 31 +++--- test/suite/registry/section-11.ts | 15 +-- test/suite/registry/section-12.0-i.ts | 116 ++++++++++++++++------ test/suite/registry/section-12.3-12.5.ts | 19 ++-- test/suite/registry/section-16-p8.ts | 6 +- test/suite/registry/section-6.3.ts | 37 +++---- test/suite/registry/section-6.4.ts | 30 +++--- test/suite/registry/section-6.5.ts | 27 ++--- test/suite/registry/section-7-basics.ts | 33 ++++--- test/suite/registry/section-7.1-7.3.ts | 13 ++- test/suite/registry/section-7.4-7.5.ts | 25 ++--- test/suite/registry/support.ts | 66 ++++++++++--- 22 files changed, 635 insertions(+), 198 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index ba8852e..4125e25 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -88,7 +88,7 @@ A "new test T" task always means, in one change: in-scope path, apply the minimal 12.7 form change to that fixture here (CONF-VALID's full behavioral rework stays FP-009). -- [ ] FP-002 — Fix the exit-2 output-stream protocol everywhere it is +- [x] FP-002 — Fix the exit-2 output-stream protocol everywhere it is asserted: with JSON output in effect, exit-2 stdout is exactly one 12.7 error document (`{"error": …}`); stdout is byte-empty on exit 2 only when JSON is NOT in effect; stderr is byte-identical across output forms. diff --git a/test/fixtures/conf-disc/product.mjs b/test/fixtures/conf-disc/product.mjs index 11eca92..5d7614e 100644 --- a/test/fixtures/conf-disc/product.mjs +++ b/test/fixtures/conf-disc/product.mjs @@ -44,7 +44,10 @@ // segments resolve lexically (wildcard segments count as ordinary names); // an absolute pattern, or one whose resolution escapes the root, is a // configuration error (14.14) reported at load by every command as a usage -// error (12.0) — exit 2, message on stderr, stdout empty. +// error (12.0) — exit 2, message on stderr; with `--json` the single 12.7 +// error document ({"error": …} carrying the stable code +// `configuration-error` and the concerned path in the anchoring form) is +// the entire stdout, and without it stdout stays empty. // - Discovery pipeline order (SPEC 7, 13.4): walk plain files (symbolic links // never discovered, never traversed — so link cycles cannot hang the walk), // match the union of all groups' globs, then apply the 13.4 source @@ -113,8 +116,23 @@ import * as path from "node:path"; // Outcome carriers // --------------------------------------------------------------------------- -/** Usage or configuration error (SPEC 12.0 exit 2): message on stderr. */ -class UsageError extends Error {} +/** + * Usage or configuration error (SPEC 12.0 exit 2): message on stderr in both + * output forms; with JSON output in effect the 12.7 error document is the + * entire stdout. `code`/`path` are the error finding's stable code and + * concerned path — set for configuration errors (14.14: `configuration-error` + * plus the concerned path in the anchoring form), `null` for plain usage + * errors (SPEC 12.7). + */ +class UsageError extends Error { + /** @param {string} message + * @param {{ code?: string | null, path?: string | null }} [finding] */ + constructor(message, { code = null, path = null } = {}) { + super(message); + this.code = code; + this.path = path; + } +} /** Findings (SPEC 12.0 exit 1): a findings report on stdout. */ class FindingsError extends Error { @@ -183,12 +201,27 @@ let deviations = {}; const CONFIG_NAME = "xspec.config.ts"; +/** + * The anchoring form of SPEC 11.6/14 for a path identified relative to the + * invocation working directory: the segments ascending to the nearest common + * ancestor spelled `..`, then the descending segments, `/`-joined on every + * platform; the working directory itself is `.`. + */ +function anchoringPath(cwd, absPath) { + const rel = path.relative(path.resolve(cwd), absPath); + if (rel === "") return "."; + return rel.split(path.sep).join("/"); +} + async function findConfigPath(cwd, configFlag) { if (configFlag !== undefined) { const abs = path.resolve(cwd, configFlag); if (!(await pathOccupied(abs))) { throw new UsageError( `configuration file not found: --config ${configFlag}`, + // Missing configuration: the concerned path is the file --config + // names, in the anchoring form (SPEC 14, 11.6). + { code: "configuration-error", path: anchoringPath(cwd, abs) }, ); } return abs; @@ -201,6 +234,9 @@ async function findConfigPath(cwd, configFlag) { if (parent === dir) { throw new UsageError( `configuration error: no ${CONFIG_NAME} found by upward search from the working directory`, + // Missing configuration with no --config: the concerned path is the + // directory the failed search started from, spelled "." (SPEC 14). + { code: "configuration-error", path: "." }, ); } dir = parent; @@ -420,6 +456,24 @@ function validatedPattern(glob, groupName) { */ async function loadConfig(cwd, configFlag) { const configPath = await findConfigPath(cwd, configFlag); + try { + return await parseAndValidateConfig(configPath); + } catch (error) { + // Every defect found while reading, parsing, or validating the + // configuration is a configuration error (SPEC 14.14): its error + // finding carries the stable code and the concerned configuration file + // in the anchoring form (SPEC 14, 12.7). One invocation reports one + // error, however many defects are present (12.7). + if (error instanceof UsageError && error.code === null) { + error.code = "configuration-error"; + error.path = anchoringPath(cwd, configPath); + } + throw error; + } +} + +/** The post-discovery half of {@link loadConfig}: read, parse, validate. */ +async function parseAndValidateConfig(configPath) { let text; try { text = await fsp.readFile(configPath, "utf8"); @@ -1607,7 +1661,26 @@ async function dispatchCommand(io, cwd, argv) { } } catch (error) { if (error instanceof UsageError) { - // Usage/configuration errors: stderr content, empty stdout (SPEC 12.0). + // Usage/configuration errors (SPEC 12.0): the message is stderr + // content in both output forms. With JSON output in effect the single + // 12.7 error document — {"error": …} holding one finding form, its + // stable code and concerned path for a configuration error, null/null + // for a plain usage error — is the entire stdout; without it, stdout + // stays empty. The output form never changes the exit code or the + // standard-error content. + if (wantsJson) { + io.stdout( + canonicalJson({ + error: { + code: error.code, + message: error.message, + locations: [], + path: error.path, + identities: [], + }, + }) + "\n", + ); + } io.stderr(`xspec: ${error.message}\n`); return 2; } diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts index be7cc2a..5fadac2 100644 --- a/test/helpers/adapters/forms.ts +++ b/test/helpers/adapters/forms.ts @@ -19,6 +19,7 @@ // with the harness-pinned token→condition table (model.ts) // - the pinned findings-order comparator and duplicate collapse (12.7) // - findings arrays and the findings-only report {"findings": […]} +// - the exit-2 error document {"error": …} holding one finding form (12.0) // - the three-state datum decode: plain value / `null` / // {"unavailable": true} (11.4, 12.7) // - the unavailability-marker structural walk T12.7-1 relies on: no object @@ -26,6 +27,7 @@ import { Buffer, isUtf8 } from "node:buffer"; import type { + ErrorDocument, Finding, FindingLocation, FindingsReport, @@ -403,6 +405,35 @@ export function decodeFindingsReport( }; } +// --- the exit-2 error document (12.0, 12.7) ----------------------------------- + +/** + * The exit-2 error document — `{"error": …}` exactly, holding one finding + * form (SPEC 12.0, 12.7). With JSON output in effect — `--json` among the + * invocation's arguments, even when the arguments are themselves the error, + * or a JSON-only surface (10.7 export, 11, 12.6) — an invocation failing + * with a usage or configuration error (exit 2) emits this document as its + * entire stdout. Form-exact (H-3): the one member, the literal finding form; + * the document carries no `findings` member (12.7). Content: a configuration + * error carries the stable code and concerned path (14); a plain usage error + * carries `code` and `path` `null` — value assertions belong to callers + * (T12.7-3), this decode admits any well-formed finding. + */ +export function decodeErrorDocument( + doc: unknown, + context?: string, +): ErrorDocument { + const site = rootSite("12.7 error document", context); + const obj = expectObject(doc, site); + expectOnlyMembers(obj, ["error"], site); + return { + error: decodeFindingForm( + requiredKey(obj, "error", site), + at(site, "error"), + ), + }; +} + // --- the three-state datum decode (11.4, 12.7) -------------------------------- /** diff --git a/test/helpers/adapters/model.ts b/test/helpers/adapters/model.ts index 46856ed..dfccc74 100644 --- a/test/helpers/adapters/model.ts +++ b/test/helpers/adapters/model.ts @@ -284,6 +284,17 @@ export interface FindingsReport { readonly findings: readonly Finding[]; } +/** + * The exit-2 error document — `{"error": …}` exactly, holding one finding + * form (SPEC 12.0, 12.7): with JSON output in effect, an invocation failing + * with a usage or configuration error emits this document as its entire + * stdout. For a configuration error the finding carries the stable code and + * concerned path (14); for a plain usage error `code` and `path` are `null`. + */ +export interface ErrorDocument { + readonly error: Finding; +} + /** `coverage` (T8.2-1): all profiles by default, one when named. */ export interface CoverageReport { readonly profiles: readonly CoverageProfileReport[]; diff --git a/test/helpers/assertions.ts b/test/helpers/assertions.ts index bab5922..0184c71 100644 --- a/test/helpers/assertions.ts +++ b/test/helpers/assertions.ts @@ -12,7 +12,10 @@ // stdout/stderr separation of SPEC.md 12.0 — `assertExitCode`, // `assertStdoutEmpty`/`assertStderrEmpty`, `parseJsonStdout` (stdout is // exactly one JSON document), and `assertJsonOutputConvention` (one JSON -// document on exit 0/1; empty stdout on exit 2; anything else diagnosed). +// document on every exit: a report/answer document on exit 0/1, the 12.7 +// error document `{"error": …}` on exit 2; anything else diagnosed). +// Exit-2 stdout is byte-empty only when JSON output is NOT in effect +// (SPEC.md 12.0) — asserted per call site via `assertStdoutEmpty`. // - H-8: a `HarnessAssertionError` is the harness's *diagnosed assertion // failure* — the failure shape every product-facing test must produce // against a missing or stub product. Anything else thrown is a harness @@ -167,10 +170,17 @@ export function parseJsonStdout(result: RunResult, context?: string): unknown { } /** - * Assert the full `--json` stream convention of SPEC.md 12.0 / H-5 for a run: - * exit 0 or 1 → stdout is exactly one JSON document (returned parsed); - * exit 2 → stdout is byte-empty (returns undefined). Any other exit code — - * a stub's unexpected code included — or a signal death fails diagnosed. + * Assert the stream convention of SPEC.md 12.0 / H-5 for a run with JSON + * output in effect (`--json` among the arguments, or a JSON-only surface): + * whatever the exit code, stdout is exactly one JSON document (returned + * parsed) — on exit 0/1 the report or answer document, on exit 2 the 12.7 + * error document, `{"error": …}` exactly (asserted here at the protocol + * grain: a top-level object whose only member is `error`, holding an object; + * the literal finding-form decode is `decodeErrorDocument`, + * adapters/forms.ts). Any other exit code — a stub's unexpected code + * included — or a signal death fails diagnosed. Exit-2 stdout is byte-empty + * only when JSON output is NOT in effect — assert that per call site via + * `assertStdoutEmpty`, never through this helper. */ export function assertJsonOutputConvention( result: RunResult, @@ -183,13 +193,29 @@ export function assertJsonOutputConvention( ); } switch (result.exitCode) { - case 2: - if (result.stdoutBytes.length > 0) { + case 2: { + const doc = parseJsonStdout( + result, + context === undefined + ? "exit-2 error document (SPEC.md 12.0: with JSON output in effect, a usage or configuration error emits the 12.7 error document as the entire stdout)" + : `${context} — exit-2 error document (SPEC.md 12.0: with JSON output in effect, a usage or configuration error emits the 12.7 error document as the entire stdout)`, + ); + if ( + typeof doc !== "object" || + doc === null || + Array.isArray(doc) || + Object.keys(doc).length !== 1 || + !Object.hasOwn(doc, "error") || + typeof (doc as Record)["error"] !== "object" || + (doc as Record)["error"] === null || + Array.isArray((doc as Record)["error"]) + ) { fail( - `${prefix}under --json, stdout must be empty on exit 2 (H-5; usage/configuration diagnostics belong on stderr), but ${result.commandLine} wrote ${String(result.stdoutBytes.length)} bytes to stdout: ${renderStream(result.stdoutBytes)}`, + `${prefix}on exit 2 with JSON output in effect, stdout must be the 12.7 error document — {"error": …} exactly, one member holding one finding form (SPEC.md 12.0, 12.7; H-5) — but ${result.commandLine} wrote: ${renderStream(result.stdoutBytes)}`, ); } - return undefined; + return doc; + } case 0: case 1: return parseJsonStdout(result, context); diff --git a/test/self/assertion-protocol.test.ts b/test/self/assertion-protocol.test.ts index b831a7f..603ad1e 100644 --- a/test/self/assertion-protocol.test.ts +++ b/test/self/assertion-protocol.test.ts @@ -290,7 +290,7 @@ test("parseJsonStdout fails diagnosed on empty stdout, concatenated documents, t ); }); -test("assertJsonOutputConvention: one document on exit 0/1, empty stdout on exit 2, everything else diagnosed (12.0/H-5)", () => { +test("assertJsonOutputConvention: one document on every exit — a report/answer document on exit 0/1, the 12.7 error document on exit 2 — everything else diagnosed (12.0/H-5)", () => { expect( assertJsonOutputConvention( syntheticResult({ exitCode: 0, stdout: '{"ok":true}\n' }), @@ -301,18 +301,66 @@ test("assertJsonOutputConvention: one document on exit 0/1, empty stdout on exit syntheticResult({ exitCode: 1, stdout: '{"findings":[]}\n' }), ), ).toEqual({ findings: [] }); + // Exit 2 with JSON output in effect: the 12.7 error document — {"error":…} + // exactly — is the entire stdout (SPEC 12.0), returned parsed. expect( assertJsonOutputConvention( - syntheticResult({ exitCode: 2, stderr: "usage: xspec\n" }), + syntheticResult({ + exitCode: 2, + stdout: + '{"error":{"code":null,"message":"unknown flag","locations":[],"path":null,"identities":[]}}\n', + stderr: "usage: xspec\n", + }), ), - ).toBeUndefined(); + ).toEqual({ + error: { + code: null, + message: "unknown flag", + locations: [], + path: null, + identities: [], + }, + }); + // Byte-empty exit-2 stdout is the JSON-NOT-in-effect form — under this + // convention (JSON in effect) it is a missing error document, diagnosed. + expectDiagnosed( + () => + assertJsonOutputConvention( + syntheticResult({ exitCode: 2, stderr: "usage: xspec\n" }), + ), + /stdout is empty/, + ); expectDiagnosed( () => assertJsonOutputConvention( syntheticResult({ exitCode: 2, stdout: "contaminated\n" }), ), - /stdout must be empty on exit 2/, - "contaminated", + /not exactly one JSON document/, + ); + // One JSON document that is not the error document form: diagnosed. + expectDiagnosed( + () => + assertJsonOutputConvention( + syntheticResult({ exitCode: 2, stdout: '{"findings":[]}\n' }), + ), + /error document/, + ); + expectDiagnosed( + () => + assertJsonOutputConvention( + syntheticResult({ + exitCode: 2, + stdout: '{"error":{"code":null},"extra":1}\n', + }), + ), + /error document/, + ); + expectDiagnosed( + () => + assertJsonOutputConvention( + syntheticResult({ exitCode: 2, stdout: '{"error":"oops"}\n' }), + ), + /error document/, ); expectDiagnosed( () => assertJsonOutputConvention(syntheticResult({ exitCode: 0 })), diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index ca32b69..3b5e252 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -32,6 +32,7 @@ import { decodeCoverageReport, decodeDatum, decodeEdgesReport, + decodeErrorDocument, decodeExportReport, decodeFindingsReport, decodeIdsReport, @@ -1070,6 +1071,126 @@ const DECODERS: readonly DecoderSpec[] = [ }, ], }, + { + name: "12.7 error document", + decode: decodeErrorDocument, + good: { + error: { + code: "configuration-error", // 14.14 + message: "unknown key `bogus` in xspec.config.ts", + locations: [], + path: "xspec.config.ts", + identities: [], + }, + }, + verify: (decoded: ReturnType) => { + // {"error": …} holding one literal finding form (SPEC 12.0, 12.7): + // a configuration error carries the stable code and concerned path. + expect(decoded.error.code).toBe("configuration-error"); + expect(decoded.error.condition).toBe("14.14"); + expect(decoded.error.path).toBe("xspec.config.ts"); + expect(decoded.error.locations).toEqual([]); + expect(decoded.error.identities).toEqual([]); + }, + alsoGood: [ + { + label: "a plain usage error: code and path null (SPEC 12.7)", + doc: { + error: { + code: null, + message: "unknown flag --definitely-not-a-flag", + locations: [], + path: null, + identities: [], + }, + }, + verify: (decoded: ReturnType): void => { + expect(decoded.error.code).toBeNull(); + expect(decoded.error.condition).toBeNull(); + expect(decoded.error.path).toBeNull(); + }, + }, + { + label: + "a missing-configuration error concerning the working directory " + + '(anchoring form "." for a failed upward search, SPEC 14)', + doc: { + error: { + code: "configuration-error", + message: "no xspec.config.ts found by upward search", + locations: [], + path: ".", + identities: [], + }, + }, + verify: (decoded: ReturnType): void => { + expect(decoded.error.path).toBe("."); + }, + }, + ], + bad: [ + { label: "missing error member", doc: {} }, + { + label: "null error member (the finding form is an object)", + doc: { error: null }, + }, + { + label: "an extra member beside error (12.7: exactly {error})", + doc: { + error: { + code: null, + message: "unknown flag", + locations: [], + path: null, + identities: [], + }, + findings: [], + }, + }, + { + label: + "the findings-only report shape passed off as the error document", + doc: { findings: [] }, + }, + { label: "error as a bare string", doc: { error: "unknown flag" } }, + { + label: "error finding missing its code member (null is never omitted)", + doc: { + error: { + message: "unknown flag", + locations: [], + path: null, + identities: [], + }, + }, + }, + { + label: "error finding with an unknown code token", + doc: { + error: { + code: "usage-error", + message: "unknown flag", + locations: [], + path: null, + identities: [], + }, + }, + }, + { + label: "error finding with an extra member (12.7: exactly the five)", + doc: { + error: { + code: null, + message: "unknown flag", + locations: [], + path: null, + identities: [], + hint: "try --help", + }, + }, + }, + ], + }, { name: "coverage", decode: decodeCoverageReport, diff --git a/test/suite/registry/section-10.1.ts b/test/suite/registry/section-10.1.ts index c583c39..fd3f8b2 100644 --- a/test/suite/registry/section-10.1.ts +++ b/test/suite/registry/section-10.1.ts @@ -64,7 +64,6 @@ import { import { assertBytesEqual, assertExitCode, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -80,6 +79,7 @@ import { TestWorkspace } from "../../helpers/workspace.js"; import { assertSameJson, buildOk, + expectErrorDocument, expectExit, runCli, runJson, @@ -412,9 +412,10 @@ export async function probeSessionNameCasing( 2, probeContext, ); - assertStdoutEmpty( + expectErrorDocument( probe, - `${probeContext} — under --json, stdout is byte-empty on exit 2 (H-5)`, + `${probeContext} — under --json, the exit-2 error document is the ` + + `entire stdout (SPEC 12.0, 12.7, H-5)`, ); } @@ -469,9 +470,10 @@ const T10_1_2 = defineProductTest({ 2, `${context} — an invalid session name is a usage error (SPEC 10.1, 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context} — under --json, stdout is byte-empty on exit 2 (SPEC 12.0, H-5)`, + `${context} — under --json, the exit-2 error document is the ` + + `entire stdout (SPEC 12.0, 12.7, H-5)`, ); }, `${context} — nothing created`, @@ -568,9 +570,10 @@ export async function probeWrongCaseExtensionSession( 2, context, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context} — under --json, stdout is byte-empty on exit 2 (H-5)`, + `${context} — under --json, the exit-2 error document is the entire ` + + `stdout (SPEC 12.0, 12.7, H-5)`, ); } @@ -687,9 +690,10 @@ const T10_1_3 = defineProductTest({ 2, context, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context} — under --json, stdout is byte-empty on exit 2 (H-5)`, + `${context} — under --json, the exit-2 error document is the ` + + `entire stdout (SPEC 12.0, 12.7, H-5)`, ); } await probeWrongCaseExtensionSession(product, workspace); diff --git a/test/suite/registry/section-10.2-10.3.ts b/test/suite/registry/section-10.2-10.3.ts index a150cb9..27abcfa 100644 --- a/test/suite/registry/section-10.2-10.3.ts +++ b/test/suite/registry/section-10.2-10.3.ts @@ -66,13 +66,19 @@ import { decodeNodeReport, decodeSessionStatusReport, } from "../../helpers/adapters/index.js"; -import { assertStdoutEmpty, fail } from "../../helpers/assertions.js"; +import { fail } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import { assertSameJson, buildOk, expectExit, runJson } from "./support.js"; +import { + assertSameJson, + buildOk, + expectErrorDocument, + expectExit, + runJson, +} from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" @@ -1611,7 +1617,7 @@ function t5Spec(xText: string): string { const T10_3_1 = defineProductTest({ id: "T10.3-1", title: - "`resolve --status` accepts exactly `updated`, `no-change`, `skipped`; any other value (unknown token, wrong case, the non-resolve statuses `unresolved`/`invalidated`, empty) is a usage error — exit 2, empty stdout under `--json`, nothing modified; items with `unresolved` or `invalidated` status need review and appear in `next`, resolved ones do not (`next` walks the audit items to fully-resolved, and an edit re-surfaces the invalidated item) (SPEC 10.3, 10.4, 10.7, 12.0)", + "`resolve --status` accepts exactly `updated`, `no-change`, `skipped`; any other value (unknown token, wrong case, the non-resolve statuses `unresolved`/`invalidated`, empty) is a usage error — exit 2, the 12.7 error document as the entire stdout under `--json`, nothing modified; items with `unresolved` or `invalidated` status need review and appear in `next`, resolved ones do not (`next` walks the audit items to fully-resolved, and an edit re-surfaces the invalidated item) (SPEC 10.3, 10.4, 10.7, 12.0)", timeoutMs: 240_000, run: async (product) => { await withWorkspace( @@ -1652,8 +1658,9 @@ const T10_3_1 = defineProductTest({ "T10.3-1", ).id; - // Any other `--status` value is a usage error: exit 2, empty stdout - // under --json (H-5), nothing modified (SPEC 10.7, 12.0). The + // Any other `--status` value is a usage error: exit 2, the 12.7 + // error document as the entire stdout under --json (12.0, H-5), + // nothing modified (SPEC 10.7, 12.0). The // non-resolve statuses of 10.3 are values too — `resolve` accepts // exactly the three resolved statuses. const invalidValues: readonly (readonly [string, string])[] = [ @@ -1675,9 +1682,10 @@ const T10_3_1 = defineProductTest({ 2, `${context} — any value other than updated/no-change/skipped is a usage error (SPEC 10.7, 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context} — under --json, stdout is byte-empty on exit 2 (SPEC 12.0, H-5)`, + `${context} — under --json, the exit-2 error document is ` + + `the entire stdout (SPEC 12.0, 12.7, H-5)`, ); }, `${context} — a usage error modifies nothing`, diff --git a/test/suite/registry/section-10.7-i.ts b/test/suite/registry/section-10.7-i.ts index 153f0df..699d4cb 100644 --- a/test/suite/registry/section-10.7-i.ts +++ b/test/suite/registry/section-10.7-i.ts @@ -83,7 +83,6 @@ import { } from "../../helpers/adapters/index.js"; import { assertExitCode, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -95,6 +94,7 @@ import { TestWorkspace } from "../../helpers/workspace.js"; import { assertSameJson, buildOk, + expectErrorDocument, expectExit, runCli, runJson, @@ -443,7 +443,8 @@ function collectStringLeaves(value: unknown, into: string[] = []): string[] { /** * Run `review create` with `--json` appended, expecting a usage error: exact - * exit 2 with byte-empty stdout (SPEC 12.0; H-5). + * exit 2 with the single 12.7 error document as the entire stdout (SPEC + * 12.0, 12.7; H-5). */ async function expectCreateUsageError( product: ProductBinding, @@ -458,9 +459,10 @@ async function expectCreateUsageError( 2, `${context} — a usage error (SPEC 10.7, 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context} — under --json, stdout is byte-empty on exit 2 (SPEC 12.0, H-5)`, + `${context} — under --json, the exit-2 error document is the entire ` + + `stdout (SPEC 12.0, 12.7, H-5)`, ); } @@ -1043,9 +1045,10 @@ const T10_7_3 = defineProductTest({ const baseline = await workspace.gitCommitAll("baseline"); await buildOk(product, workspace, `${prefix} \`build\``); - // Arm 1: create with an unresolvable ref — exit 2, byte-empty - // stdout under --json, and nothing modified anywhere (SPEC 6.3, - // 10.7, 12.0; the compare includes .git/ and .xspec/). + // Arm 1: create with an unresolvable ref — exit 2, the 12.7 error + // document as the entire stdout under --json, and nothing modified + // anywhere (SPEC 6.3, 10.7, 12.0; the compare includes .git/ and + // .xspec/). for (const [ref, why] of [ ["no-such-ref", "a nonexistent branch name"], [ @@ -1151,10 +1154,10 @@ const T10_7_3 = defineProductTest({ `${context} — fails per 6.3 as a usage error (SPEC 6.3, ` + `10.7, 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context} — under --json, stdout is byte-empty on exit 2 ` + - `(SPEC 12.0, H-5)`, + `${context} — under --json, the exit-2 error document is ` + + `the entire stdout (SPEC 12.0, 12.7, H-5)`, ); }, `${context} — modifying nothing (SPEC 6.3, 10.7)`, diff --git a/test/suite/registry/section-10.7-ii.ts b/test/suite/registry/section-10.7-ii.ts index f088c3f..a2b14f8 100644 --- a/test/suite/registry/section-10.7-ii.ts +++ b/test/suite/registry/section-10.7-ii.ts @@ -84,16 +84,18 @@ import { decodeNodeReport, decodeSessionStatusReport, } from "../../helpers/adapters/index.js"; -import { - assertStdoutEmpty, - fail, - parseJsonStdout, -} from "../../helpers/assertions.js"; +import { fail, parseJsonStdout } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import { assertSameJson, buildOk, expectExit, runJson } from "./support.js"; +import { + assertSameJson, + buildOk, + expectErrorDocument, + expectExit, + runJson, +} from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" @@ -1400,10 +1402,10 @@ const T10_7_8 = defineProductTest({ `${context} — an unknown item ID in a review command's ` + `arguments is a usage error (SPEC 10.7, 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context} — under --json, stdout is byte-empty on exit 2 ` + - `(SPEC 12.0, H-5)`, + `${context} — under --json, the exit-2 error document is the ` + + `entire stdout (SPEC 12.0, 12.7, H-5)`, ); } @@ -2096,7 +2098,7 @@ const R10_NOTE = "reviewed; left as-is pending spec sync"; const T10_7_10 = defineProductTest({ id: "T10.7-10", title: - "`review resolve` sets the status and records the current relevant state — `current` holds the resolve-moment hash captures and, after a re-resolve bracketing an edit, the new moment's values and not the old (pairwise-distinct `query node` captures discriminate); it works on any unblocked item regardless of status: flipping a resolved `no-change` to `skipped` without any edit works, and re-resolving an `invalidated` item works and clears the invalidation; resolving a blocked item is refused (exit 1) leaving the session's rows unchanged; an unknown session name or item ID is exit 2 with byte-empty stdout under `--json`; `--note` text is stored and reported by `show` and `export` (SPEC 10.2, 10.3, 10.4, 10.7, 12.0)", + "`review resolve` sets the status and records the current relevant state — `current` holds the resolve-moment hash captures and, after a re-resolve bracketing an edit, the new moment's values and not the old (pairwise-distinct `query node` captures discriminate); it works on any unblocked item regardless of status: flipping a resolved `no-change` to `skipped` without any edit works, and re-resolving an `invalidated` item works and clears the invalidation; resolving a blocked item is refused (exit 1) leaving the session's rows unchanged; an unknown session name or item ID is exit 2 with the 12.7 error document as the entire stdout under `--json`; `--note` text is stored and reported by `show` and `export` (SPEC 10.2, 10.3, 10.4, 10.7, 12.0)", timeoutMs: 360_000, run: async (product) => { await withWorkspace( @@ -2146,7 +2148,8 @@ const T10_7_10 = defineProductTest({ ); // Unknown session and unknown item are usage errors (SPEC 10.7, - // 12.0): exit 2, byte-empty stdout under --json. + // 12.0): exit 2, the 12.7 error document as the entire stdout + // under --json. for (const [argv, why] of [ [ ["review", "resolve", "nosuch", idPA, "--status", "no-change"], @@ -2166,10 +2169,10 @@ const T10_7_10 = defineProductTest({ `${context} — unknown names in a review command's arguments are ` + `usage errors (SPEC 10.7, 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context} — under --json, stdout is byte-empty on exit 2 ` + - `(SPEC 12.0, H-5)`, + `${context} — under --json, the exit-2 error document is the ` + + `entire stdout (SPEC 12.0, 12.7, H-5)`, ); } diff --git a/test/suite/registry/section-11.ts b/test/suite/registry/section-11.ts index cf3d234..fc025a7 100644 --- a/test/suite/registry/section-11.ts +++ b/test/suite/registry/section-11.ts @@ -74,7 +74,6 @@ import { } from "../../helpers/adapters/index.js"; import { assertExitCode, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -92,6 +91,7 @@ import { assertEdgeSetEqual, assertSameJson, buildOk, + expectErrorDocument, expectExit, runJson, sortedIdentities, @@ -284,9 +284,10 @@ async function queryBothForms(options: BothFormsOptions): Promise { } /** - * A usage-error arm: exit 2 exactly (H-5) and, under `--json`, byte-empty - * stdout — the exit-2 error prevents emitting the single JSON document - * (SPEC 12.0). `why` names the staged error class in the diagnosis. + * A usage-error arm: exit 2 exactly (H-5) with the single 12.7 error + * document as the entire stdout — the run carries `--json`, so JSON output + * is in effect and the exit-2 invocation emits the error document (SPEC + * 12.0, 12.7). `why` names the staged error class in the diagnosis. */ async function expectUsageError( product: ProductBinding, @@ -302,10 +303,10 @@ async function expectUsageError( 2, `${context} — ${why} is a usage error, exit 2 (SPEC 11, 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context} — under --json, stdout is byte-empty on exit 2: the usage ` + - `error prevents emitting the single JSON document (SPEC 12.0, H-5)`, + `${context} — under --json, the exit-2 error document is the entire ` + + `stdout (SPEC 12.0, 12.7, H-5)`, ); } diff --git a/test/suite/registry/section-12.0-i.ts b/test/suite/registry/section-12.0-i.ts index 0262279..a594530 100644 --- a/test/suite/registry/section-12.0-i.ts +++ b/test/suite/registry/section-12.0-i.ts @@ -6,7 +6,8 @@ // only via diagnosed assertion failures (H-8). // // SPEC 12.0: every command supports `--json` (one JSON document as the entire -// stdout; when an exit-2 error prevents emitting one, stdout is empty) and +// stdout; an exit-2 error emits the 12.7 error document as that document, +// while exit-2 stdout is empty only when JSON output is NOT in effect) and // `--config ` (a filesystem path resolved against the working // directory); reports — findings included — are stdout content while usage // and configuration error messages and all other diagnostic text are stderr @@ -42,7 +43,12 @@ // - T12.0-2 asserts non-empty stderr on the exit-2 arms (the test's own text: // usage/configuration errors *print diagnostics* to standard error) and // leaves stderr unasserted on the exit-1 arms (12.0 lets diagnostic text -// ride stderr beside a stdout report). +// ride stderr beside a stdout report). Its stderr-invariance arms compare +// stderr bytes across the two output forms of one invocation (H-4, +// product-to-itself): 12.0 — the output form never changes an exit code or +// standard-error content. Exit-2 arms with `--json` decode the 12.7 error +// document (12.0); the human exit-2 arms assert byte-empty stdout (JSON +// not in effect). // - T12.0-5 uses exit 0 from a subdirectory as the resolution observable for // ``/``/`` arguments — resolved against the cwd // each would name a nonexistent file and exit 2 — and content for `--file`, @@ -65,6 +71,7 @@ import { decodeReachableReport, } from "../../helpers/adapters/index.js"; import { + assertBytesEqual, assertExitCode, assertStdoutEmpty, fail, @@ -84,6 +91,7 @@ import { assertSameJson, buildOk, expectConfigurationError, + expectErrorDocument, expectExit, runCli, runJson, @@ -416,10 +424,12 @@ export default defineConfig({ const T12_0_2 = defineProductTest({ id: "T12.0-2", title: - "streams: a failing `build`'s validation errors and `check`'s findings are standard-output content (exit 1) in both output forms; usage and configuration errors print diagnostics to standard error with byte-empty standard output under `--json` (exit 2); non-JSON diagnostics never contaminate a `--json` stdout — the entire exit-1 stdout parses as one JSON document (SPEC 12.0, 14.14, H-5)", + "streams: a failing `build`'s validation errors and `check`'s findings are standard-output content (exit 1) in both output forms; usage and configuration errors print diagnostics to standard error, with JSON output in effect an exit-2 invocation emits the 12.7 error document as its entire stdout, and without JSON in effect exit-2 stdout is empty; non-JSON diagnostics never contaminate a JSON stdout, and the output form never changes an exit code or standard-error content — a representative exit-2 usage error and a failing `build`, each run with and without `--json`, exit identically with stderr byte-identical across the two forms (SPEC 12.0, 12.7, 14.14, H-4, H-5)", run: async (product) => { // Findings are stdout content (exit 1) — human and --json forms of a - // failing `build` and of `check` over the same invalid workspace. + // failing `build` and of `check` over the same invalid workspace. The + // failing `build` pair is also the exit-1 stderr-invariance arm: stderr + // byte-identical across the two output forms (12.0, H-4). await withWorkspace( { files: { @@ -475,11 +485,22 @@ const T12_0_2 = defineProductTest({ `${JSON.stringify(findings)}`, ); } + if (command === "build") { + assertBytesEqual( + result.stderrBytes, + human.stderrBytes, + `T12.0-2 stderr invariance, exit 1: a failing \`build\` run ` + + `with and without --json — the output form never changes ` + + `standard-error content (SPEC 12.0; product-to-itself, H-4)`, + ); + } } }, ); - // Usage errors: diagnostics on stderr; byte-empty stdout under --json. + // Usage errors: diagnostics on stderr; without --json stdout is empty; + // with --json the 12.7 error document is the entire stdout. The unknown + // -flag pair is the exit-2 stderr-invariance arm (12.0, H-4). await withWorkspace( { files: { @@ -501,6 +522,11 @@ const T12_0_2 = defineProductTest({ 2, `${humanUsageContext} — an unknown flag is a usage error (SPEC 12.0)`, ); + assertStdoutEmpty( + humanUsage, + `${humanUsageContext} — without JSON output in effect, an exit-2 ` + + `error leaves standard output empty (SPEC 12.0, H-5)`, + ); assertStderrNonEmpty(humanUsage, humanUsageContext); const jsonUsageContext = "T12.0-2 `ids --definitely-not-a-flag --json`"; const jsonUsage = await expectExit( @@ -510,12 +536,23 @@ const T12_0_2 = defineProductTest({ 2, jsonUsageContext, ); - assertStdoutEmpty( + expectErrorDocument( jsonUsage, - `${jsonUsageContext} — the exit-2 error prevents emitting the ` + - `single JSON document, so stdout is empty (SPEC 12.0, H-5)`, + `${jsonUsageContext} — --json among the arguments puts JSON ` + + `output in effect even when the arguments are themselves the ` + + `error, so the exit-2 invocation emits the 12.7 error document ` + + `as its entire stdout (SPEC 12.0, 12.7)`, ); assertStderrNonEmpty(jsonUsage, jsonUsageContext); + assertBytesEqual( + jsonUsage.stderrBytes, + humanUsage.stderrBytes, + `T12.0-2 stderr invariance, exit 2: \`ids ` + + `--definitely-not-a-flag\` run with and without --json — the ` + + `output form never changes standard-error content, failing a ` + + `product that appends or substitutes stderr diagnostics when ` + + `JSON output is in effect (SPEC 12.0; product-to-itself, H-4)`, + ); const unknownFileContext = "T12.0-2 `show specs/Missing.mdx --json`"; const unknownFile = await expectExit( product, @@ -525,16 +562,18 @@ const T12_0_2 = defineProductTest({ `${unknownFileContext} — an unknown file named in arguments is a ` + `usage error (SPEC 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( unknownFile, - `${unknownFileContext} — stdout is empty under --json on exit 2 ` + - `(SPEC 12.0, H-5)`, + `${unknownFileContext} — the exit-2 error document is the entire ` + + `stdout under --json (SPEC 12.0, 12.7)`, ); assertStderrNonEmpty(unknownFile, unknownFileContext); }, ); - // Configuration errors: stderr diagnostics; empty stdout under --json. + // Configuration errors: stderr diagnostics; the error document under + // --json (expectConfigurationError asserts it, stable code and concerned + // path included); empty stdout without JSON in effect. await withWorkspace( { files: { @@ -559,6 +598,11 @@ const T12_0_2 = defineProductTest({ `${humanConfigContext} — a configuration error is a usage-class ` + `error, exit 2 (SPEC 14.14, 12.0)`, ); + assertStdoutEmpty( + humanConfig, + `${humanConfigContext} — without JSON output in effect, an exit-2 ` + + `error leaves standard output empty (SPEC 12.0, H-5)`, + ); assertStderrNonEmpty(humanConfig, humanConfigContext); }, ); @@ -723,10 +767,10 @@ const T12_0_4 = defineProductTest({ `even with identical values; the ${step.what} invocation with ` + `\`--config\` given once, run next, succeeds (SPEC 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context} — under --json, stdout is byte-empty on exit 2 ` + - `(SPEC 12.0, H-5)`, + `${context} — under --json, the exit-2 error document is the ` + + `entire stdout (SPEC 12.0, 12.7, H-5)`, ); }, }); @@ -761,7 +805,11 @@ const T12_0_4 = defineProductTest({ `${kindsRepeatedContext} — the list belongs in one comma-separated ` + `value; repeating --kinds is a usage error (SPEC 12.0, 11)`, ); - assertStdoutEmpty(kindsRepeated, kindsRepeatedContext); + expectErrorDocument( + kindsRepeated, + `${kindsRepeatedContext} — under --json, the exit-2 error document ` + + `is the entire stdout (SPEC 12.0, 12.7, H-5)`, + ); // A repeated single-valued flag (`--tag`): the single form is valid. const tagContext = "T12.0-4 `query nodes --tag keep --json`"; @@ -792,7 +840,11 @@ const T12_0_4 = defineProductTest({ `${tagRepeatedContext} — repeating a value flag is a usage error ` + `(SPEC 12.0)`, ); - assertStdoutEmpty(tagRepeated, tagRepeatedContext); + expectErrorDocument( + tagRepeated, + `${tagRepeatedContext} — under --json, the exit-2 error document ` + + `is the entire stdout (SPEC 12.0, 12.7, H-5)`, + ); // A repeated boolean flag (`--json --json`): exit code only — see the // module header on why the stream stays unasserted here. @@ -945,10 +997,10 @@ const T12_0_5 = defineProductTest({ `spelled with \\ names no workspace file: an unknown-file usage ` + `error (SPEC 12.0; discriminating on the Windows leg, E-6)`, ); - assertStdoutEmpty( + expectErrorDocument( backslash, - `${backslashContext} — stdout is empty under --json on exit 2 ` + - `(SPEC 12.0, H-5)`, + `${backslashContext} — under --json, the exit-2 error document ` + + `is the entire stdout (SPEC 12.0, 12.7, H-5)`, ); // Non-UTF-8 argument value — Linux leg only: argv is a byte channel @@ -966,10 +1018,10 @@ const T12_0_5 = defineProductTest({ `${nonUtf8Context} — argument values are interpreted as UTF-8; ` + `a value that is not valid UTF-8 is a usage error (SPEC 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( nonUtf8, - `${nonUtf8Context} — stdout is empty under --json on exit 2 ` + - `(SPEC 12.0, H-5)`, + `${nonUtf8Context} — under --json, the exit-2 error document ` + + `is the entire stdout (SPEC 12.0, 12.7, H-5)`, ); } @@ -1103,10 +1155,10 @@ export async function runT1206SingleCasingPathProbe( `case-insensitive filesystem lookup would find the file ` + `(SPEC 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( probe, - `${probeContext} — stdout is empty under --json on exit 2 ` + - `(SPEC 12.0, H-5)`, + `${probeContext} — under --json, the exit-2 error document is the ` + + `entire stdout (SPEC 12.0, 12.7, H-5)`, ); }, ); @@ -1245,10 +1297,10 @@ const T12_0_6 = defineProductTest({ `case-sensitively: no session bears this spelling, an ` + `unknown-session usage error (SPEC 12.0, 10.7)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context} — stdout is empty under --json on exit 2 ` + - `(SPEC 12.0, H-5)`, + `${context} — under --json, the exit-2 error document is the ` + + `entire stdout (SPEC 12.0, 12.7, H-5)`, ); } }, @@ -1321,10 +1373,10 @@ const T12_0_6 = defineProductTest({ `identities compare byte-wise, so specs/a.mdx#upper names no ` + `node — an unknown-node usage error (SPEC 12.0, 1.5)`, ); - assertStdoutEmpty( + expectErrorDocument( cross, - `${crossContext} — stdout is empty under --json on exit 2 ` + - `(SPEC 12.0, H-5)`, + `${crossContext} — under --json, the exit-2 error document is ` + + `the entire stdout (SPEC 12.0, 12.7, H-5)`, ); }, ); diff --git a/test/suite/registry/section-12.3-12.5.ts b/test/suite/registry/section-12.3-12.5.ts index 3ae9f99..4fd6d2a 100644 --- a/test/suite/registry/section-12.3-12.5.ts +++ b/test/suite/registry/section-12.3-12.5.ts @@ -61,7 +61,8 @@ // (deep behavior is covered in sections 8, 9, 10, 11, 6, per TEST-SPEC) — // so the unknown-command arms discriminate "unknown → exit 2" from a CLI // that exits 2 for everything. Unknown arms assert exit 2 exactly and, -// under `--json`, byte-empty stdout (SPEC 12.0). +// under `--json`, the 12.7 error document as the entire stdout (SPEC +// 12.0). // - T12.3-2's coverage arm asserts the demonstration facts (the profile's // uncovered set, the referenced-yet-uncovered node among it) — full §8 // report content is T8-*'s subject. @@ -84,7 +85,7 @@ import { } from "../../helpers/adapters/index.js"; import type { Mention } from "../../helpers/adapters/index.js"; import type { GraphEdge } from "../../helpers/adapters/index.js"; -import { assertStdoutEmpty, fail } from "../../helpers/assertions.js"; +import { fail } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; @@ -92,6 +93,7 @@ import { TestWorkspace } from "../../helpers/workspace.js"; import { assertSameJson, buildOk, + expectErrorDocument, expectExit, runJson, sortedIdentities, @@ -142,9 +144,10 @@ async function withWorkspace( } /** - * A usage-error arm: exit 2 exactly (H-5) and, under `--json`, byte-empty - * stdout — the exit-2 error prevents emitting the single JSON document - * (SPEC 12.0). `why` names the staged error class in the diagnosis. + * A usage-error arm: exit 2 exactly (H-5) with the single 12.7 error + * document as the entire stdout — the run carries `--json`, so JSON output + * is in effect and the exit-2 invocation emits the error document (SPEC + * 12.0, 12.7). `why` names the staged error class in the diagnosis. */ async function expectUsageError( product: ProductBinding, @@ -160,10 +163,10 @@ async function expectUsageError( 2, `${context} — ${why} is a usage error, exit 2 (SPEC 12.5, 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context} — under --json, stdout is byte-empty on exit 2: the usage ` + - `error prevents emitting the single JSON document (SPEC 12.0, H-5)`, + `${context} — under --json, the exit-2 error document is the entire ` + + `stdout (SPEC 12.0, 12.7, H-5)`, ); } diff --git a/test/suite/registry/section-16-p8.ts b/test/suite/registry/section-16-p8.ts index 129a741..d4c7b1c 100644 --- a/test/suite/registry/section-16-p8.ts +++ b/test/suite/registry/section-16-p8.ts @@ -15,9 +15,9 @@ // * a command never dies by signal and always exits 0, 1, or 2 — the // SPEC 12.0 exit-code partition ("exit codes partition all outcomes"); // * under `--json`, stdout is never a partial JSON document: exit 0/1 -// emits exactly one JSON document as the entire stdout, and exit 2 emits -// byte-empty stdout (SPEC 12.0; the shared `assertJsonOutputConvention`, -// H-5); +// emits exactly one JSON document as the entire stdout, and exit 2 +// emits the 12.7 error document — `{"error": …}` — as that document +// (SPEC 12.0; the shared `assertJsonOutputConvention`, H-5); // * a failing `build` — exit 1 or exit 2 — modifies nothing: the whole // workspace tree, prior derived files and graph data included, is // byte-identical around the invocation (SPEC 12.1, H-4; snapshot diff --git a/test/suite/registry/section-6.3.ts b/test/suite/registry/section-6.3.ts index 18f8392..a4146fc 100644 --- a/test/suite/registry/section-6.3.ts +++ b/test/suite/registry/section-6.3.ts @@ -25,9 +25,10 @@ // T1.5-1 interpretation (SPEC 9.3 groups output by category, so an // uncategorized node appears under none), carried through SUITE-20/22. // - Every failure arm runs with `--json`: exit 2 exactly (H-5), stdout -// byte-empty (H-5: with `--json`, stdout is exactly one JSON document or -// empty on exit 2), and the actionable error on stderr (12.0: usage and -// configuration error messages are standard-error content). +// exactly one 12.7 error document (12.0: with JSON output in effect, an +// exit-2 invocation emits the error document as its entire stdout), and +// the actionable error on stderr (12.0: usage and configuration error +// messages are standard-error content). // - "Naming the offending entries" for the garbage replay line (staged on // journal line 2, after one legitimate entry): entry content is opaque // (SPEC 6.1, H-4), so the harness accepts any of — stderr echoing the @@ -42,8 +43,9 @@ // - An unresolvable ref: the offending item is the ref itself, so the // actionable error must echo its spelling on stderr. // - "Report no validation findings" (the precedence arm): findings are -// report content — stdout (12.0) — so under `--json` the empty stdout of a -// proper exit-2 usage error is exactly "no validation findings reported". +// report content — stdout (12.0) — so under `--json` the exit-2 error +// document (which carries no `findings` member, 12.7) as the entire +// stdout is exactly "no validation findings reported". // - "Modifying nothing" is asserted as a whole-workspace-root byte snapshot // compare around the command, `.git/` included (git is read-only for the // product, SPEC preamble; T12.0-11 pins `.git/` byte-identity around every @@ -59,11 +61,7 @@ import { Buffer } from "node:buffer"; import * as fsp from "node:fs/promises"; import type { ImpactReport } from "../../helpers/adapters/index.js"; import { decodeImpactReport } from "../../helpers/adapters/index.js"; -import { - assertStdoutEmpty, - fail, - parseJsonStdout, -} from "../../helpers/assertions.js"; +import { fail, parseJsonStdout } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; @@ -74,6 +72,7 @@ import { assertSameJson, buildFindings, buildOk, + expectErrorDocument, expectExit, } from "./support.js"; @@ -212,9 +211,10 @@ function assertNoChanges( /** * A baseline-resolution failure at a baseline-taking command (T6.3-4's * contract): run with `--json`, assert exit 2 exactly (a usage error, - * SPEC 6.3, 12.0) and byte-empty stdout (H-5: with `--json`, stdout is empty - * on exit 2 — no report, no validation findings). The actionable error is - * stderr content (12.0); callers assert its naming duties on the result. + * SPEC 6.3, 12.0) and the single 12.7 error document as the entire stdout + * (12.0: with JSON output in effect, an exit-2 invocation emits the error + * document — no report, no validation findings; H-5). The actionable error + * is stderr content (12.0); callers assert its naming duties on the result. */ async function expectBaselineUsageError( product: ProductBinding, @@ -230,10 +230,11 @@ async function expectBaselineUsageError( `${context} — a baseline that cannot be read or reconstructed is a ` + `usage error (SPEC 6.3, 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context} — under --json, stdout is byte-empty on exit 2: the usage ` + - `error emits no report and no validation findings (SPEC 12.0, H-5)`, + `${context} — under --json, the exit-2 error document is the entire ` + + `stdout: the usage error emits no report and no validation findings ` + + `(SPEC 12.0, 12.7, H-5)`, ); return result; } @@ -882,8 +883,8 @@ const T6_3_4 = defineProductTest({ argv, `${context}: \`${command}\` — baseline resolution precedes ` + `source validation (SPEC 12.0), so the unresolvable ref ` + - `is reported as exit 2 with empty stdout (no validation ` + - `findings), never exit 1 with findings`, + `is reported as exit 2 with the error document alone (no ` + + `validation findings), never exit 1 with findings`, ), `${context}: \`${command}\` modifies nothing (SPEC 6.3, 10.7, 12.0)`, ); diff --git a/test/suite/registry/section-6.4.ts b/test/suite/registry/section-6.4.ts index 8ecd11e..f092081 100644 --- a/test/suite/registry/section-6.4.ts +++ b/test/suite/registry/section-6.4.ts @@ -46,11 +46,13 @@ // regenerates on refusal, fails the compare. Refusal report content is // deliberately unasserted (12.0 classes refusals exit 1; TEST-SPEC pins no // report content for them), so refusal arms run without `--json`. -// - T6.4-4 exit-2 arms run with `--json`: stdout byte-empty (H-5: no report, -// no validation findings — the 12.0-ordering discriminator) and the usage -// error message on stderr (12.0), asserted for presence, not wording. The -// masking arm asserts exit 1 with a findings report of exactly one 14.20 -// naming the unparseable file with a location (SPEC 14, H-3). +// - T6.4-4 exit-2 arms run with `--json`: stdout exactly one 12.7 error +// document (12.0: with JSON output in effect, an exit-2 invocation emits +// the error document as its entire stdout — no report, no validation +// findings: the 12.0-ordering discriminator) and the usage error message +// on stderr (12.0), asserted for presence, not wording. The masking arm +// asserts exit 1 with a findings report of exactly one 14.20 naming the +// unparseable file with a location (SPEC 14, H-3). // - T6.4-7 "byte-identical to a fresh build of the rewritten sources" is the // H-6 two-directory protocol: a second workspace is seeded with the // post-rename configuration, sources, and journal (derived files are @@ -70,7 +72,6 @@ import { } from "../../helpers/adapters/index.js"; import { assertFileBytes, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -87,6 +88,7 @@ import { assertSameJson, buildFindings, buildOk, + expectErrorDocument, expectExit, runJson, sortedIdentities, @@ -324,10 +326,10 @@ async function expectRefusalModifiesNothing( /** * A rename usage error (SPEC 6.4, 12.0: nonexistent `` or old ID): run - * with `--json`, assert exit 2 exactly, byte-empty stdout (H-5: no report and - * no validation findings — the 12.0-ordering discriminator), and a usage - * error message on stderr (12.0: standard-error content; presence, not - * wording). + * with `--json`, assert exit 2 exactly, the single 12.7 error document as + * the entire stdout (12.0: no report and no validation findings — the + * 12.0-ordering discriminator; H-5), and a usage error message on stderr + * (12.0: standard-error content; presence, not wording). */ async function expectRenameUsageError( product: ProductBinding, @@ -344,11 +346,11 @@ async function expectRenameUsageError( `${context}: \`${command} --json\` — a nonexistent or old ID is a ` + `usage error (SPEC 6.4, 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context}: \`${command} --json\` — under --json, stdout is byte-empty ` + - `on exit 2: the usage error emits no report and no validation findings ` + - `(SPEC 12.0, H-5)`, + `${context}: \`${command} --json\` — under --json, the exit-2 error ` + + `document is the entire stdout: the usage error emits no report and ` + + `no validation findings (SPEC 12.0, 12.7, H-5)`, ); if (result.stderrBytes.length === 0) { fail( diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index d895257..2023155 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -69,10 +69,12 @@ // not-valid-UTF-8 destination is staged on the Linux leg only (mirroring // T1.5-2's platform note): argv bytes exist as a channel there, carried by // the subprocess driver's raw-byte argv support. -// - T6.5-5 exit-2 arms run with `--json`: stdout byte-empty (H-5: no report, -// no validation findings — the 12.0-ordering discriminator) and the usage -// error message on stderr (presence, not wording). The masking arm asserts -// exit 1 with exactly one 14.20 finding naming the unparseable origin file. +// - T6.5-5 exit-2 arms run with `--json`: stdout exactly one 12.7 error +// document (12.0: with JSON output in effect, an exit-2 invocation emits +// the error document as its entire stdout — no report, no validation +// findings: the 12.0-ordering discriminator) and the usage error message +// on stderr (presence, not wording). The masking arm asserts exit 1 with +// exactly one 14.20 finding naming the unparseable origin file. // - T6.5-6's unstageable clauses are documented at the test, per TEST-SPEC: // the collision clause's after-the-removal qualifier admits no // discriminating fixture (structural IDs make the vacated set exactly the @@ -91,7 +93,6 @@ import { assertBytesEqual, assertExitCode, assertFileBytes, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -116,6 +117,7 @@ import { assertSameJson, buildFindings, buildOk, + expectErrorDocument, expectExit, runJson, sortedIdentities, @@ -385,9 +387,10 @@ async function expectRefusalModifiesNothing( /** * A move usage error (SPEC 6.5, 12.0: nonexistent origin file or origin ID): - * run with `--json`, assert exit 2 exactly, byte-empty stdout (H-5: no report - * and no validation findings — the 12.0-ordering discriminator), and a usage - * error message on stderr (presence, not wording). + * run with `--json`, assert exit 2 exactly, the single 12.7 error document + * as the entire stdout (12.0: no report and no validation findings — the + * 12.0-ordering discriminator; H-5), and a usage error message on stderr + * (presence, not wording). */ async function expectMoveUsageError( product: ProductBinding, @@ -404,11 +407,11 @@ async function expectMoveUsageError( `${context}: \`${command} --json\` — a nonexistent origin file or origin ` + `ID is a usage error (SPEC 6.5, 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${context}: \`${command} --json\` — under --json, stdout is byte-empty ` + - `on exit 2: the usage error emits no report and no validation findings ` + - `(SPEC 12.0, H-5)`, + `${context}: \`${command} --json\` — under --json, the exit-2 error ` + + `document is the entire stdout: the usage error emits no report and ` + + `no validation findings (SPEC 12.0, 12.7, H-5)`, ); if (result.stderrBytes.length === 0) { fail( diff --git a/test/suite/registry/section-7-basics.ts b/test/suite/registry/section-7-basics.ts index db2ff6b..f370a28 100644 --- a/test/suite/registry/section-7-basics.ts +++ b/test/suite/registry/section-7-basics.ts @@ -22,12 +22,13 @@ // // Conservative operationalizations (noted per H-3/H-4): // - 14.14 contract: `expectConfigurationError` (shared, ./support.ts) — run -// with `--json`, exit 2 exactly, byte-empty stdout (12.0: the exit-2 error -// prevents emitting the single JSON document; H-5), and a standard-error -// message matching /config/i — the actionable configuration-error message -// must identify the configuration as the failing subject, and any phrasing -// naming either the file (`xspec.config.ts`) or the condition -// ("configuration", "config…") qualifies; wording is otherwise free (H-3). +// with `--json`, exit 2 exactly, stdout exactly the single 12.7 error +// document carrying the stable code `configuration-error` and a concerned +// path (12.0/12.7, H-5), and a standard-error message matching /config/i — +// the actionable configuration-error message must identify the +// configuration as the failing subject, and any phrasing naming either +// the file (`xspec.config.ts`) or the condition ("configuration", +// "config…") qualifies; wording is otherwise free (H-3). // - T7-1 "no configuration reachable": the workspace is a fresh unique // temporary directory (H-1) whose filesystem ancestors (the OS temp // directory and its parents) hold no `xspec.config.ts`, so the upward @@ -44,11 +45,12 @@ // recursive scan of the workspace tree finds no file whose name ends in // `.md` (stronger than probing the default next-to-source destinations: // emission anywhere would fail it). -// - T7-3 `--from` unknown: exit 2 with byte-empty stdout (SPEC 11: query's -// single JSON document is its only output form, and 12.0 makes stdout -// empty when an exit-2 error prevents emitting one) and a non-empty -// stderr diagnostic (12.0: usage error messages are standard-error -// content). This usage error is not a 14.14, so no /config/i duty applies. +// - T7-3 `--from` unknown: exit 2 with the single 12.7 error document as +// the entire stdout (SPEC 11: `query` is a JSON-only surface, so JSON +// output is in effect without `--json`, and 12.0 makes an exit-2 error +// emit the error document) and a non-empty stderr diagnostic (12.0: usage +// error messages are standard-error content). This usage error is not a +// 14.14, so no /config/i duty applies. import * as fsp from "node:fs/promises"; import type { GraphEdge } from "../../helpers/adapters/index.js"; @@ -59,7 +61,6 @@ import { } from "../../helpers/adapters/index.js"; import { assertExitCode, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -74,6 +75,7 @@ import { assertSameJson, buildOk, expectConfigurationError, + expectErrorDocument, expectExit, runJson, } from "./support.js"; @@ -730,11 +732,12 @@ const T7_3 = defineProductTest({ `${fromLabel} — a path in no configured group is unknown, a ` + `usage error (SPEC 11, 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( fromResult, `${fromLabel} — query's single JSON document is its only output ` + - `form, and the exit-2 error prevents emitting one (SPEC 11, ` + - `12.0, H-5)`, + `form, so JSON output is in effect without --json and the ` + + `exit-2 error document is the entire stdout (SPEC 11, 12.0, ` + + `12.7, H-5)`, ); if (fromResult.stderrBytes.length === 0) { fail( diff --git a/test/suite/registry/section-7.1-7.3.ts b/test/suite/registry/section-7.1-7.3.ts index f3d8f32..a9690c3 100644 --- a/test/suite/registry/section-7.1-7.3.ts +++ b/test/suite/registry/section-7.1-7.3.ts @@ -24,7 +24,9 @@ // // Conservative operationalizations (noted per H-3/H-4): // - 14.14 contract: `expectConfigurationError` (shared, ./support.ts) — exit -// 2 exactly, byte-empty stdout under --json, stderr matching /config/i. +// 2 exactly, the single 12.7 error document (stable code +// `configuration-error`, concerned path) as the entire stdout under +// --json, stderr matching /config/i. // - T7.1-1 coverage: profiles are looked up by name (T8.2-1 owns report // ordering and the full report contract — counts and the ignored-node // composition are not asserted here); "sees it in both" is asserted as the @@ -70,7 +72,6 @@ import { import { assertBytesEqual, assertFileBytes, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -89,6 +90,7 @@ import { buildOk, byteWindow, expectConfigurationError, + expectErrorDocument, expectExit, runJson, } from "./support.js"; @@ -857,11 +859,12 @@ const T7_3_1 = defineProductTest({ `group, so the path is unknown, a usage error (SPEC 7.3, 13.4, ` + `11, 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( fromResult, `${fromLabel} — query's single JSON document is its only output ` + - `form, and the exit-2 error prevents emitting one (SPEC 11, ` + - `12.0, H-5)`, + `form, so JSON output is in effect without --json and the ` + + `exit-2 error document is the entire stdout (SPEC 11, 12.0, ` + + `12.7, H-5)`, ); if (fromResult.stderrBytes.length === 0) { fail( diff --git a/test/suite/registry/section-7.4-7.5.ts b/test/suite/registry/section-7.4-7.5.ts index 9f7703c..844eaa0 100644 --- a/test/suite/registry/section-7.4-7.5.ts +++ b/test/suite/registry/section-7.4-7.5.ts @@ -26,15 +26,18 @@ // // Conservative operationalizations (noted per H-3/H-4): // - 14.14 contract: `expectConfigurationError` (shared, ./support.ts) — exit -// 2 exactly, byte-empty stdout under --json, stderr matching /config/i. +// 2 exactly, the single 12.7 error document (stable code +// `configuration-error`, concerned path) as the entire stdout under +// --json, stderr matching /config/i. // Every invalid fixture stages valid sources for every configured group, so // the staged deviation is the workspace's only defect: a product that // wrongly accepts the configuration proceeds to a clean build (exit 0) and // fails the exit-code assertion — never exits 2 for a side reason. // - Unknown profile name at `coverage ` (T7.4-1) is a 12.0 usage error, -// not a 14.14: asserted as exit 2 with byte-empty stdout under --json (the -// exit-2 error prevents emitting the single JSON document, H-5) and a -// non-empty stderr diagnostic — no /config/i duty applies. +// not a 14.14: asserted as exit 2 with the single 12.7 error document as +// the entire stdout under --json (12.0: with JSON output in effect, an +// exit-2 invocation emits the error document; H-5) and a non-empty stderr +// diagnostic — no /config/i duty applies. // - Policy findings are compared as sorted "rule :: kind: from -> to" // renderings plus an exact 14.12 condition count: SPEC 7.5 fixes the // information (rule name + offending edge) and one finding per (rule, edge) @@ -76,7 +79,6 @@ import { } from "../../helpers/adapters/index.js"; import { assertFileBytes, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -92,6 +94,7 @@ import { assertSameJson, buildOk, expectConfigurationError, + expectErrorDocument, expectExit, readGeneratedModule, runJson, @@ -586,8 +589,9 @@ const T7_4_1 = defineProductTest({ ); // (d) Unknown profile name at `coverage ` → usage error (12.0): - // exit 2, byte-empty stdout under --json, a stderr diagnostic. Not a - // 14.14 (the configuration is valid), so no /config/i duty applies. + // exit 2, the 12.7 error document as the entire stdout under --json, a + // stderr diagnostic. Not a 14.14 (the configuration is valid), so no + // /config/i duty applies. await withWorkspace( { files: { "xspec.config.ts": VALID_COVERAGE_CONFIG, ...MATRIX_FILES } }, async (workspace) => { @@ -600,11 +604,10 @@ const T7_4_1 = defineProductTest({ `${label} — an unknown profile named in arguments is a usage ` + `error (SPEC 12.0)`, ); - assertStdoutEmpty( + expectErrorDocument( result, - `${label} — under --json, stdout is byte-empty on exit 2: the ` + - `usage error prevents emitting the single JSON document ` + - `(SPEC 12.0, H-5)`, + `${label} — under --json, the exit-2 error document is the ` + + `entire stdout (SPEC 12.0, 12.7, H-5)`, ); if (result.stderrBytes.length === 0) { fail( diff --git a/test/suite/registry/support.ts b/test/suite/registry/support.ts index ffbd27a..07d25eb 100644 --- a/test/suite/registry/support.ts +++ b/test/suite/registry/support.ts @@ -9,10 +9,12 @@ import { Buffer } from "node:buffer"; import type { Finding, GraphEdge } from "../../helpers/adapters/index.js"; -import { decodeFindingsReport } from "../../helpers/adapters/index.js"; +import { + decodeErrorDocument, + decodeFindingsReport, +} from "../../helpers/adapters/index.js"; import { assertExitCode, - assertStdoutEmpty, fail, parseJsonStdout, } from "../../helpers/assertions.js"; @@ -65,14 +67,41 @@ export async function runJson( return parseJsonStdout(result, context); } +/** + * Decode an exit-2 run's stdout as the single 12.7 error document — + * `{"error": …}` exactly, one finding form — and return the finding (SPEC + * 12.0: with JSON output in effect, a usage or configuration error emits the + * error document as the entire stdout; H-5). Callers assert the exit code + * first (`expectExit`) and pass runs with JSON output in effect: `--json` + * among the arguments, or a JSON-only surface (10.7 export, 11, 12.6). The + * decode is form-exact (H-3); value assertions on `code`/`path` stay with + * the caller (T12.7-3 pins them fully). + */ +export function expectErrorDocument( + result: RunResult, + context: string, +): Finding { + return decodeErrorDocument( + parseJsonStdout( + result, + `${context} — with JSON output in effect, an exit-2 invocation emits ` + + `the 12.7 error document as its entire stdout (SPEC 12.0, H-5)`, + ), + context, + ).error; +} + /** * Run a command with `--json` and assert the SPEC.md 14.14 configuration-error - * contract: exit 2 exactly (a usage error, 12.0), byte-empty stdout (the - * exit-2 error prevents emitting the single JSON document; H-5), and an - * actionable standard-error message identifying the configuration as the - * failing subject — any phrasing naming either the file (`xspec.config.ts`) - * or the condition ("configuration", "config…") qualifies, so the - * operationalization is /config/i; wording is otherwise free (H-3). + * contract: exit 2 exactly (a usage error, 12.0); stdout exactly the single + * 12.7 error document `{"error": …}` (12.0/12.7, H-5), its finding carrying + * the stable code `configuration-error` and a non-`null` concerned path (14 + * defines both for configuration errors; the exact anchoring-form spelling is + * T12.7-3's assertion); and an actionable standard-error message identifying + * the configuration as the failing subject — any phrasing naming either the + * file (`xspec.config.ts`) or the condition ("configuration", "config…") + * qualifies, so the operationalization is /config/i; wording is otherwise + * free (H-3). */ export async function expectConfigurationError( product: ProductBinding, @@ -92,12 +121,21 @@ export async function expectConfigurationError( `error, reported by every command at configuration load as a usage ` + `error (SPEC 14.14, 12.0)`, ); - assertStdoutEmpty( - result, - `${context} — under --json, stdout is byte-empty on exit 2: the ` + - `configuration error prevents emitting the single JSON document ` + - `(SPEC 12.0, H-5)`, - ); + const error = expectErrorDocument(result, context); + if (error.code !== "configuration-error") { + fail( + `${context}: the error document's finding must carry the stable code ` + + `"configuration-error" (SPEC 14 condition 14, 12.7); got ` + + `${JSON.stringify(error.code)} (message: ${JSON.stringify(error.message)})`, + ); + } + if (error.path === null) { + fail( + `${context}: a configuration error's finding carries its concerned ` + + `path — the configuration file, or "." for a failed upward search — ` + + `in the anchoring form (SPEC 14, 12.7); got null`, + ); + } if (!/config/i.test(result.stderr)) { fail( `${context}: the configuration-error message on stderr must identify ` + From 88840e22f8959c2e1499f8d849ee9ddf2317be77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 22:31:53 +0000 Subject: [PATCH 050/226] sdg(phase-9): re-pin the S-1 universe to 81 keys; renumber T6.6-1 to T6.7-1; remap T11-* to 11.1 (FP-003) - s1-traceability: EXPECTED_KEY_COUNT 71 -> 81 (SPEC.md now carries 70 subsections: 5.7, 6.7, 11.1-11.6, 12.6, 12.7 added); universe-count assertion green again. - Manual restructuring moved SPEC 6.6 -> 6.7 (new 6.6 is Previews): re-register the test as T6.7-1, rename registry module and wrapper to section-6.7.*, update all SPEC citations; red-as-diagnosed under the new ID. - Traceability: T6.7-1 -> [6.7]; T11-1..T11-5 -> [11, 11.1] (their both-forms arms assert the section-11 body's JSON-only contract), T11-6/T11-7 -> [11.1]; fix the body-key comment (11 now has subsections); note T12.0-10's aliasing ends once its own arms are implemented; SPEC_BODY_TEXT_KEY_SECTIONS unchanged per amended H-7. - S-1 unmapped-key red narrows to {5.7, 6.6, 11.2-11.6, 12.6, 12.7} -- 6.6 joins deliberately (the retired T6.6-1 no longer falsely covers Previews); stays red until stages E/G land. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 15 +++++-- test/self/s1-traceability.test.ts | 4 +- test/suite/registry/index.ts | 4 +- .../{section-6.6.ts => section-6.7.ts} | 36 ++++++++-------- test/suite/registry/traceability.ts | 41 ++++++++++++------- ...ection-6.6.test.ts => section-6.7.test.ts} | 6 +-- 6 files changed, 63 insertions(+), 43 deletions(-) rename test/suite/registry/{section-6.6.ts => section-6.7.ts} (96%) rename test/suite/{section-6.6.test.ts => section-6.7.test.ts} (62%) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 4125e25..b32b452 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -109,7 +109,7 @@ A "new test T" task always means, in one change: - Verify: affected suite files compile and fail only as red-as-diagnosed product assertions; `npm run test:self` no worse. -- [ ] FP-003 — Re-pin the H-7/S-1 traceability universe and apply the +- [x] FP-003 — Re-pin the H-7/S-1 traceability universe and apply the renumber/remap fixes that need no new tests. [VERIFY s1-traceability, R2 #39, R1 #12; TEST-SPEC §0 H-7, §17 S-1] - `test/self/s1-traceability.test.ts`: `EXPECTED_KEY_COUNT` 71 → 81 (line @@ -130,9 +130,16 @@ A "new test T" task always means, in one change: - Note in the map comment that T12.0-10 stops being alias-only once FP-039 lands (its own arms make it an implemented test). - Verify: S-1's universe-count assertion goes green; its unmapped-key - failure narrows to exactly {5.7, 11.2, 11.3, 11.4, 11.5, 11.6, 12.6, - 12.7} (6.7 and 11.1 become mapped here) and stays red until stages E/G - land — state left red on purpose. + failure narrows to exactly {5.7, 6.6, 11.2, 11.3, 11.4, 11.5, 11.6, + 12.6, 12.7} (6.7 and 11.1 become mapped here; 6.6 — Previews — joins + the red set as planned-task fallout the original note missed: the + retired T6.6-1's false "6.6" coverage is removed, and "6.6" stays + unmapped until FP-028+ register T6.6-2..-6) and stays red until stages + E/G land — state left red on purpose. [Done 2026-08-10: exactly that + 9-key set observed; T6.7-1 red-as-diagnosed under its new ID; registry + module renamed section-6.6.ts → section-6.7.ts (wrapper too) so FP-028 + creates a fresh section-6.6.ts for the preview tests; + SPEC_BODY_TEXT_KEY_SECTIONS reviewed against amended H-7 — unchanged.] ## Stage B — existing assertions that contradict the current spec diff --git a/test/self/s1-traceability.test.ts b/test/self/s1-traceability.test.ts index 71f2bc0..115ede9 100644 --- a/test/self/s1-traceability.test.ts +++ b/test/self/s1-traceability.test.ts @@ -36,12 +36,12 @@ const PREAMBLE_KEY = "preamble"; // The universe SPEC.md currently defines. H-7's section lists (the body-text // sections above; sections covered through their subsections) enumerate over -// exactly sections 1–15, and the full universe is preamble + 60 subsections +// exactly sections 1–15, and the full universe is preamble + 70 subsections // + 10 body keys. The detail is derived from the document below; these pins // force a deliberate visit when SPEC.md's structure changes and guard // against a parser regression losing headings wholesale. const EXPECTED_SECTION_COUNT = 15; -const EXPECTED_KEY_COUNT = 71; +const EXPECTED_KEY_COUNT = 81; // Heading shapes exactly as SPEC.md writes them: a section heading is // `## . `, a subsection heading `### <n>.<m> <title>`, numbers diff --git a/test/suite/registry/index.ts b/test/suite/registry/index.ts index cab4071..ce3261f 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -38,7 +38,7 @@ import { section62Tests } from "./section-6.2.js"; import { section63Tests } from "./section-6.3.js"; import { section64Tests } from "./section-6.4.js"; import { section65Tests } from "./section-6.5.js"; -import { section66Tests } from "./section-6.6.js"; +import { section67Tests } from "./section-6.7.js"; import { section7BasicsTests } from "./section-7-basics.js"; import { section7DiscoveryTests } from "./section-7-discovery.js"; import { section71to73Tests } from "./section-7.1-7.3.js"; @@ -100,7 +100,7 @@ export const productTestSuite = new ProductTestSuite([ ...section63Tests, ...section64Tests, ...section65Tests, - ...section66Tests, + ...section67Tests, ...section7BasicsTests, ...section7DiscoveryTests, ...section71to73Tests, diff --git a/test/suite/registry/section-6.6.ts b/test/suite/registry/section-6.7.ts similarity index 96% rename from test/suite/registry/section-6.6.ts rename to test/suite/registry/section-6.7.ts index 5633524..0da7b7c 100644 --- a/test/suite/registry/section-6.6.ts +++ b/test/suite/registry/section-6.7.ts @@ -1,11 +1,11 @@ -// TEST-SPEC §6.6 (manual restructuring) — SUITE-24: T6.6-1. +// TEST-SPEC §6.7 (manual restructuring) — SUITE-24: T6.7-1. // // Registered product-facing body (C-2 "one code path"): it builds its own // fresh workspaces (H-1), drives the product strictly as a subprocess (H-2), // asserts exact exit codes (H-5), decodes output through the H-3 adapters, // and rejects a product only via diagnosed assertion failures (H-8). // -// SPEC 6.6: renames or moves performed by editing files directly, without the +// SPEC 6.7: renames or moves performed by editing files directly, without the // commands, produce no journal entries and are treated as deletions plus // additions. The manually renamed node's text is kept byte-identical across // the edit, so a product inferring continuity (journaling the edit, or @@ -83,7 +83,7 @@ async function withWorkspace<T>( } /** - * Assert the journal file does not exist (SPEC 6.6, 6.1): manual + * Assert the journal file does not exist (SPEC 6.7, 6.1): manual * restructuring is never journaled, and the file comes into existence only * with the first journaled `rename`/`move` — so after direct edits and the * commands run on them, nothing may occupy `.xspec/journal`. @@ -99,7 +99,7 @@ async function assertNoJournal( `${context}: ${moment}, ${JOURNAL_PATH} holds a ${kind} — a rename ` + `performed by editing the file directly produces no journal entry, ` + `and the journal file comes into existence only with the first ` + - `journaled operation (SPEC 6.6, 6.1)`, + `journaled operation (SPEC 6.7, 6.1)`, ); } } @@ -225,7 +225,7 @@ function assertImpactTable( fail( `${context}: ${expected.identity} must carry exactly the categories ` + `${JSON.stringify(expectedNames)} — a manual rename is a deletion ` + - `plus an addition, never continuity (SPEC 6.6, 5.6) — but no ` + + `plus an addition, never continuity (SPEC 6.7, 5.6) — but no ` + `requirement entry names it`, ); } @@ -236,7 +236,7 @@ function assertImpactTable( fail( `${context}: ${expected.identity} must be reported ` + `${expectedDeleted ? "as deleted, under its baseline identity" : "as present, not deleted"} ` + - `(SPEC 6.6, 5.6, 9.3); an entry naming it has deleted: ${String(flag)}`, + `(SPEC 6.7, 5.6, 9.3); an entry naming it has deleted: ${String(flag)}`, ); } } @@ -286,7 +286,7 @@ function assertImpactTable( } // --------------------------------------------------------------------------- -// T6.6-1 — manual restructuring +// T6.7-1 — manual restructuring // --------------------------------------------------------------------------- // Impact arm: `a.mid` is manually renamed to `a.neo` by overwriting the file; @@ -360,16 +360,16 @@ function watchSource(ref: string): { return { text, prefix, construct }; } -const T6_6_1 = defineProductTest({ - id: "T6.6-1", +const T6_7_1 = defineProductTest({ + id: "T6.7-1", title: - "manual restructuring: renaming an ID by editing the file directly produces no journal entry, impact reports a deletion plus an addition (not continuity), and dependents referencing the old identity fail validation (14.5) until rewritten (SPEC 6.6, 6.1, 5.6, 9.3, 14)", + "manual restructuring: renaming an ID by editing the file directly produces no journal entry, impact reports a deletion plus an addition (not continuity), and dependents referencing the old identity fail validation (14.5) until rewritten (SPEC 6.7, 6.1, 5.6, 9.3, 14)", run: async (product) => { // --- Impact arm: deletion plus addition, never continuity --- await withWorkspace( { [I1_FILE]: impactArmSource("a.mid") }, async (workspace) => { - const context = "T6.6-1 impact arm"; + const context = "T6.7-1 impact arm"; await workspace.gitInit(); const base = await workspace.gitCommitAll("pre-edit baseline"); await buildOk(product, workspace, `${context}: \`build\``); @@ -380,7 +380,7 @@ const T6_6_1 = defineProductTest({ ); // The manual rename: only the one `id` attribute changes; the node's - // text is byte-identical, tempting continuity inference (SPEC 6.6). + // text is byte-identical, tempting continuity inference (SPEC 6.7). await workspace.file(I1_FILE, impactArmSource("a.neo")); await buildOk( @@ -400,7 +400,7 @@ const T6_6_1 = defineProductTest({ await impactAgainst(product, workspace, base, label), [ // The old identity: deleted and `changed` only — a manual rename - // is treated as a deletion plus an addition (SPEC 6.6, 5.6). + // is treated as a deletion plus an addition (SPEC 6.7, 5.6). { identity: I1_MID, deleted: true, @@ -451,7 +451,7 @@ const T6_6_1 = defineProductTest({ [V2_WATCH]: staleWatch.text, }, async (workspace) => { - const context = "T6.6-1 validation arm"; + const context = "T6.7-1 validation arm"; await buildOk(product, workspace, `${context}: \`build\``); await assertNoJournal( workspace, @@ -469,7 +469,7 @@ const T6_6_1 = defineProductTest({ { "14.5": 2 }, `${staleLabel} — each dependent's \`d\` reference to the vacated ` + `identity is an unknown dependency: the manual rename carries no ` + - `continuity, so the references resolve to nothing (SPEC 6.6, 14.5)`, + `continuity, so the references resolve to nothing (SPEC 6.7, 14.5)`, ); for (const [file, source, surface] of [ [V2_ORIGIN, staleOrigin, "same-file local string reference"], @@ -505,7 +505,7 @@ const T6_6_1 = defineProductTest({ product, workspace, `${context}: \`build\` after rewriting both dependents to the new ` + - `identity — the workspace validates again (SPEC 6.6, 14.5)`, + `identity — the workspace validates again (SPEC 6.7, 14.5)`, ); await assertNoJournal( workspace, @@ -517,5 +517,5 @@ const T6_6_1 = defineProductTest({ }, }); -/** TEST-SPEC §6.6, in canonical ID order (SUITE-24). */ -export const section66Tests: readonly ProductTestEntry[] = [T6_6_1]; +/** TEST-SPEC §6.7, in canonical ID order (SUITE-24). */ +export const section67Tests: readonly ProductTestEntry[] = [T6_7_1]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 7002a98..94f4778 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -11,9 +11,9 @@ // "<major>" a numbered section's own body text outside its // subsections. Per H-7 exactly sections 3, 4, 5, 7, 8, // 9, 10, 11, 14, and 15 carry requirements there (for -// 3, 11, 14, and 15 — which have no subsections — the -// key spans the whole section body); sections 1, 2, 6, -// 12, and 13 carry no requirements outside their +// 3, 14, and 15 — which have no subsections — the key +// spans the whole section body); sections 1, 2, 6, 12, +// and 13 carry no requirements outside their // subsections and are covered through them. // // Construction (what to maintain when tests change): @@ -23,6 +23,16 @@ // TEST-SPEC's combined heading §5.1–5.2 spans two SPEC.md passages: its one // test T5.2-1 exercises node kinds, edge kinds, and the project-wide graph // over spec and code groups, so it maps to "5", "5.1", and "5.2". +// TEST-SPEC 11.1 (`xspec query`) keeps the legacy `T11-<n>` IDs, so +// T11-1..T11-7's home passage is "11.1", not the section-11 body. +// - "11": SPEC.md 11's own body text — the five query surfaces and their +// JSON-only contract (a single JSON document as the only output form, with +// or without `--json`) — is asserted for `query` by the per-subcommand +// both-forms arms of T11-1..T11-5 (section-11.ts's §11-preamble helper), +// so those five carry "11" beside their home "11.1". Its remaining clauses +// are cross-references asserted at their home passages (11.2's +// availability contract; 13.3's gated reads, whose sweeps include +// `query`). // - Section 16's property tests (P-*) have no SPEC.md section 16; each maps // to the passages whose invariants it asserts per its TEST-SPEC entry. // - "14": SPEC.md 14 defines the validation conditions, so a test asserting @@ -33,9 +43,12 @@ // none). // - Alias entries: TEST-SPEC's pointer-only tests are not separately // implemented, so their coverage rides on the implementing tests — -// T12.0-10 ("covered by T6.4-4/T6.5-5, T6.3-4") puts "12.0" on those -// three; T12.1-2 ("T7.5-6") puts "12.1" on T7.5-6; T13.4-7 ("T7-6") puts -// "13.4" on T7-6. +// T12.0-10's rename/move and baseline arms ride on T6.4-4/T6.5-5 and +// T6.3-4, putting "12.0" on those three (alias-only for now: TEST-SPEC +// 12.0 also specifies gated-read and precedence arms as T12.0-10's own +// body, and once those are implemented it becomes a registered test with +// its own entry, ending the aliasing); T12.1-2 ("T7.5-6") puts "12.1" on +// T7.5-6; T13.4-7 ("T7-6") puts "13.4" on T7-6. // - "preamble": per H-7's own citation, T12.0-11 (git is read-only) and // T12.0-12 (git-less operation) cover the preamble's git contract; its // no-network clause is enforced at CI level (E-1), which needs no map @@ -191,7 +204,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.5-4": ["6.5"], "T6.5-5": ["6.5", "12.0"], "T6.5-6": ["6.5"], - "T6.6-1": ["6.6"], + "T6.7-1": ["6.7"], "T7-1": ["7", "14"], "T7-2": ["7", "14"], "T7-3": ["7", "14"], @@ -261,13 +274,13 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T10.7-10": ["10.7"], "T10.7-11": ["10.7"], "T10.7-12": ["10.7"], - "T11-1": ["11"], - "T11-2": ["11"], - "T11-3": ["11"], - "T11-4": ["11"], - "T11-5": ["11"], - "T11-6": ["11"], - "T11-7": ["11"], + "T11-1": ["11", "11.1"], + "T11-2": ["11", "11.1"], + "T11-3": ["11", "11.1"], + "T11-4": ["11", "11.1"], + "T11-5": ["11", "11.1"], + "T11-6": ["11.1"], + "T11-7": ["11.1"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], diff --git a/test/suite/section-6.6.test.ts b/test/suite/section-6.7.test.ts similarity index 62% rename from test/suite/section-6.6.test.ts rename to test/suite/section-6.7.test.ts index 8a03aca..c4ca7d8 100644 --- a/test/suite/section-6.6.test.ts +++ b/test/suite/section-6.7.test.ts @@ -1,9 +1,9 @@ -// TEST-SPEC §6.6 (SUITE-24): thin Vitest wrapper over the registered +// TEST-SPEC §6.7 (SUITE-24): thin Vitest wrapper over the registered // bodies — the identical bodies the certification runner executes against // fixture products (C-2 "one code path"). Expected to fail as diagnosed // assertion failures until the product exists (H-8). import { declareProductTests } from "./declare.js"; -import { section66Tests } from "./registry/section-6.6.js"; +import { section67Tests } from "./registry/section-6.7.js"; -declareProductTests(section66Tests); +declareProductTests(section67Tests); From b568d856ba3dc21db26a2ef4b2878ef9206a902f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 10 Aug 2026 22:53:23 +0000 Subject: [PATCH 051/226] sdg(phase-9): assert the present code-impact scope's source range; stage the deleted-location side (FP-004) T10.7-12 asserted a present code-impact scope must carry no source range; current SPEC 10.7/1.7 fixes the opposite: every present node, requirement node and code location alike, enters the payload with its range, and only a deleted location's entry carries none. The scope is now the named unit src/ref.ts#refUnit, its construct range byte-asserted against precomputed offsets behind a multi-byte prefix; src/del.ts (added v1, deleted v2) stages the deleted side. assertPresentState requires the range; T10.7-12 maps 1.7 beside 10.7 per TEST-SPEC's stated delegation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 14 +- test/suite/registry/section-10.7-ii.ts | 181 ++++++++++++++++++------- test/suite/registry/traceability.ts | 13 +- 3 files changed, 154 insertions(+), 54 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index b32b452..9c8d439 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -143,7 +143,7 @@ A "new test T<x>" task always means, in one change: ## Stage B — existing assertions that contradict the current spec -- [ ] FP-004 — Invert T10.7-12's code-impact-scope range assertion. +- [x] FP-004 — Invert T10.7-12's code-impact-scope range assertion. [R2 #19; SPEC 10.7, 1.7] `test/suite/registry/section-10.7-ii.ts` ~line 638 asserts a present `code-impact` scope "must carry no source range". Current SPEC 10.7/1.7: @@ -151,6 +151,18 @@ A "new test T<x>" task always means, in one change: location's entry carries none. Invert the assertion and stage both sides (present location → range asserted byte-precisely; deleted location → no range). Verify: red-as-diagnosed against stub; no self-test change. + [Done 2026-08-10: `assertPresentState` now requires the range (every + present node carries one, SPEC 10.7); the code-impact scope became the + named unit `src/ref.ts#refUnit` per TEST-SPEC's refreshed entry, its + construct range byte-asserted against precomputed offsets behind a + multi-byte prefix; a second location `src/del.ts` (added v1, deleted v2) + stages the deleted side — absent, no text, no range. T10.7-12 turned from + falsely-green to red-as-diagnosed exactly at the range assertion (stub + omits it); the reworked matrix including the deleted arm verified sound + against the stub via a local probe. Traceability gains "1.7" per + TEST-SPEC's stated delegation (T1.7-1/T1.7-2 → T10.7-12). Self-tests + unchanged: 4 planned mid-loop reds (certification-document ×3 → FP-091; + S-1's 9 unmapped keys → stages E/G).] - [ ] FP-005 — Re-stage the two `move` destination spellings that changed exit class from refusal (exit 1) to usage error (exit 2). diff --git a/test/suite/registry/section-10.7-ii.ts b/test/suite/registry/section-10.7-ii.ts index a2b14f8..2921f7c 100644 --- a/test/suite/registry/section-10.7-ii.ts +++ b/test/suite/registry/section-10.7-ii.ts @@ -13,7 +13,8 @@ // fully resolved in human and `--json` forms, exit 0, with no item in the // JSON payload, and the `--json` payload is self-contained — every scope, // context, and origin node under its current identity and presence, source -// ranges for present requirement nodes, the recorded `baseline` and `current` +// ranges for present nodes (requirement node and code location alike; an +// absent node carries none), the recorded `baseline` and `current` // hashes, and text per item kind. `show` reports the full item (the 10.2 // fields plus the same payload); `export` emits the whole session as one JSON // document, with or without `--json`, read-time invalidation applied. `split` @@ -35,7 +36,11 @@ // the pinned graph states (own/subtree text per SPEC 1.6, source ranges per // 1.7 — the same values T11-1 fixes for `query node`), with distinctness // premises asserted first so every discrimination (own vs subtree text, -// baseline vs create-time vs current values) is meaningful. Embedding +// baseline vs create-time vs current values) is meaningful. A code +// location is no `query node` operand, so the present code-impact scope's +// range — review payloads are one of the two range-presenting outputs for +// code locations (SPEC 1.7) — is asserted against precomputed byte offsets +// of the staged named unit's construct (SPEC 1.7, 4.6). Embedding // expansion is additionally pinned with byte literals: the asserted text // must contain the embedded target's authored text and must not contain the // unexpanded `text(` spelling (SPEC 1.6, 2.3). @@ -64,6 +69,7 @@ // edit is followed by an explicit `build` before any read, so no read // relies on the 13.3 refresh path (T13.3-*'s business). +import * as fsp from "node:fs/promises"; import type { ExportReport, ItemKind, @@ -597,8 +603,11 @@ interface PresentStateExpectation { readonly node: string; /** The exact expected text; `undefined` = the node must carry no text. */ readonly text: string | undefined; - /** The exact expected range; `undefined` = the node must carry no range. */ - readonly sourceRange: SourceRange | undefined; + /** + * The exact expected range — every present node carries its source range, + * requirement node and code location alike (SPEC 10.7, 1.7). + */ + readonly sourceRange: SourceRange; } /** Assert a payload node state presents a present node exactly. */ @@ -634,22 +643,13 @@ function assertPresentState( ` expected: ${JSON.stringify(expected.text)}`, ); } - if (expected.sourceRange === undefined) { - if (state.sourceRange !== undefined) { - fail( - `${context}: ${expected.node} must carry no source range — a code ` + - `location's identity already locates it (SPEC 10.7, 1.7); got ` + - JSON.stringify(state.sourceRange), - ); - } - } else { - assertSameJson( - state.sourceRange, - expected.sourceRange, - `${context}: ${expected.node}'s source range (SPEC 10.7, 1.7 — a ` + - `present requirement node enters the payload with its source range)`, - ); - } + assertSameJson( + state.sourceRange, + expected.sourceRange, + `${context}: ${expected.node}'s source range (SPEC 10.7, 1.7 — a ` + + `present node, requirement node and code location alike, enters the ` + + `payload with its source range)`, + ); } /** Assert a payload node state presents an absent node exactly. */ @@ -684,8 +684,9 @@ function assertAbsentState( if (expected.text === undefined) { if (state.text !== undefined) { fail( - `${context}: ${expected.node} is contained in no recorded state, so ` + - `it is presented with no text (SPEC 10.7); got ` + + `${context}: ${expected.node} must be presented with no text — a ` + + `node contained in no recorded state, or a code location, which ` + + `has no text value (SPEC 10.7); got ` + JSON.stringify(state.text), ); } @@ -2536,8 +2537,12 @@ const T10_7_11 = defineProductTest({ // dependency-consistency scope (own text). // wt { wt.c } own text edited v0→v1: dep's changed target. // emb the embedded target (unchanged). -// src/ref.ts (added at v1) references par.n and host.n at the top level: the -// impacted code location (whole-file identity, SPEC 4.6, 9.2). +// src/ref.ts (added at v1) references par.n and host.n inside the named unit +// `refUnit` (SPEC 4.6 attribution): the impacted code location that stays +// present (SPEC 9.2) — its item's scope enters the payload with the unit +// construct's byte range (SPEC 1.7). src/del.ts (added at v1, deleted at v2) +// references par.n at the top level: its whole-file location's code-impact +// item presents a deleted location — absent, no text, no source range. const M12_FILE = "specs/A.mdx"; const M12_ROOT = "specs/A.mdx"; const M12_PAR = "specs/A.mdx#par"; @@ -2550,7 +2555,9 @@ const M12_TOLD = "specs/A.mdx#told"; const M12_TNEW = "specs/A.mdx#tnew"; const M12_DEP = "specs/A.mdx#dep"; const M12_WT = "specs/A.mdx#wt"; -const M12_CODE = "src/ref.ts"; +const M12_CODE_FILE = "src/ref.ts"; +const M12_CODE = "src/ref.ts#refUnit"; +const M12_CODE_DEL = "src/del.ts"; const M12_EMBEDDED_TEXT = "Embedded target text."; @@ -2647,13 +2654,30 @@ const M12_V2: M12SpecState = { wt: "Wt own v1 line.", }; -const M12_CODE_SOURCE = [ - 'import A from "../specs/A.xspec";', - "", - "A.par.n;", - "A.host.n;", - "", -].join("\n"); +// The present location's source: both markers sit inside the function +// declaration `refUnit`, so each reference is attributed to the named unit +// (SPEC 4.6) and the impacted location is `src/ref.ts#refUnit`. The comment +// before the unit carries multi-byte UTF-8 bytes, so the precomputed byte +// offsets diverge from code-point and UTF-16 offsets: the range assertion is +// byte-precise (SPEC 1.7). +const M12_CODE_BEFORE_UNIT = + 'import A from "../specs/A.xspec";\n\n// Präzise UTF-8-Bytes vor der Einheit (multi-byte prefix).\n\n'; +const M12_CODE_UNIT_DECL = "function refUnit() {\n A.par.n;\n A.host.n;\n}"; +const M12_CODE_SOURCE = `${M12_CODE_BEFORE_UNIT}${M12_CODE_UNIT_DECL}\n`; + +// refUnit's construct range (SPEC 1.7, 4.6): the function declaration's own +// bytes, from the `function` keyword through the closing brace — +// start-inclusive, end-exclusive byte offsets into the file. +const M12_CODE_RANGE: SourceRange = { + start: Buffer.byteLength(M12_CODE_BEFORE_UNIT, "utf8"), + end: + Buffer.byteLength(M12_CODE_BEFORE_UNIT, "utf8") + + Buffer.byteLength(M12_CODE_UNIT_DECL, "utf8"), +}; + +// The deleted location's source: a top-level marker, so the location is the +// whole file `src/del.ts` (SPEC 4.6). +const M12_CODE_DEL_SOURCE = 'import A from "../specs/A.xspec";\n\nA.par.n;\n'; // Sub-fixture B: the absent-node provenance arms. px.x is edited between the // baseline and create, then deleted after create (its item's scope presents @@ -2718,7 +2742,7 @@ const U12_SOURCE = [ const T10_7_12 = defineProductTest({ id: "T10.7-12", title: - "payload text contract: a baseline fixture generating every built-in kind (a coverage session supplying `uncovered-requirement`), texts byte-asserted against `query node` captures in `export`, identically via `show` per item, and identically in `next --json` through a full walk of each session (one payload rule), with an embedding inside asserted texts to pin 1.6 expansion (the expanded target's bytes present, the unexpanded `text(` spelling absent); scope text by kind — the scope root's subtree text for `subtree-coherence`, the scope node's subtree text for `uncovered-requirement`, the scope node's own text (differing from its subtree text by fixture) for `parent-consistency`, `dependency-consistency`, and `metadata-consistency`, and a `code-impact` scope as identity and presence alone with no text and no source range; context text — own text for ancestor-chain contexts (`subtree-coherence`, `uncovered-requirement`), subtree text otherwise (`parent-consistency` branch children; `dependency-consistency`, `metadata-consistency`, and `code-impact` targets); origin text — a before/after pair of own text, before from the item's baseline and after from the current graph (an originating node re-edited after `create` differs on both sides from the create-time value), with the before side absent (no text) for a node added since the baseline and the after side absent for a since-deleted node; source ranges on present nodes byte-equal to `query node`'s and absent on absent nodes; absent-node provenance — a node edited between the baseline and `create`, recorded into an item's scope or context at `create`, then deleted, presents the create-time text (not the differing baseline value) and still does after an `updated` resolve re-derives the session without it, while a node deleted since the baseline and never seen by a mutating derivation with newer text presents its baseline value (SPEC 1.6, 1.7, 5.6, 9.2, 10.2, 10.4, 10.5, 10.7)", + "payload text contract: a baseline fixture generating every built-in kind (a coverage session supplying `uncovered-requirement`), texts byte-asserted against `query node` captures in `export`, identically via `show` per item, and identically in `next --json` through a full walk of each session (one payload rule), with an embedding inside asserted texts to pin 1.6 expansion (the expanded target's bytes present, the unexpanded `text(` spelling absent); scope text by kind — the scope root's subtree text for `subtree-coherence`, the scope node's subtree text for `uncovered-requirement`, the scope node's own text (differing from its subtree text by fixture) for `parent-consistency`, `dependency-consistency`, and `metadata-consistency`, and a `code-impact` scope as identity, presence, and — when present — its source range, with no text (review payloads are one of the two range-presenting outputs for code locations: the present location is a named unit whose construct range is byte-asserted against precomputed offsets, and a deleted location's entry carries none); context text — own text for ancestor-chain contexts (`subtree-coherence`, `uncovered-requirement`), subtree text otherwise (`parent-consistency` branch children; `dependency-consistency`, `metadata-consistency`, and `code-impact` targets); origin text — a before/after pair of own text, before from the item's baseline and after from the current graph (an originating node re-edited after `create` differs on both sides from the create-time value), with the before side absent (no text) for a node added since the baseline and the after side absent for a since-deleted node; source ranges on present requirement nodes byte-equal to `query node`'s and absent on absent nodes; absent-node provenance — a node edited between the baseline and `create`, recorded into an item's scope or context at `create`, then deleted, presents the create-time text (not the differing baseline value) and still does after an `updated` resolve re-derives the session without it, while a node deleted since the baseline and never seen by a mutating derivation with newer text presents its baseline value (SPEC 1.6, 1.7, 4.6, 5.6, 9.2, 10.2, 10.4, 10.5, 10.7)", timeoutMs: 600_000, run: async (product) => { // --- sub-fixture A: the per-kind matrix over a path-blocks session ------- @@ -2747,7 +2771,8 @@ const T10_7_12 = defineProductTest({ // v1 — the reviewed differences; then create. await workspace.file(M12_FILE, m12Spec(M12_V1)); - await workspace.file(M12_CODE, M12_CODE_SOURCE); + await workspace.file(M12_CODE_FILE, M12_CODE_SOURCE); + await workspace.file(M12_CODE_DEL, M12_CODE_DEL_SOURCE); await buildOk(product, workspace, `${prefix} \`build\` at v1`); const parN1 = await capture(M12_PARN, "v1"); const gone1 = await capture(M12_GONE, "v1"); @@ -2760,11 +2785,13 @@ const T10_7_12 = defineProductTest({ } await createBaseSession(product, workspace, base, "s", prefix); - // v2 — the post-create re-edit of the originating node par.n, and - // gone's deletion. No re-derivation runs (the walk resolves - // `no-change` only), so the item set is fixed at the create-time - // derivation. + // v2 — the post-create re-edit of the originating node par.n, gone's + // deletion, and the deletion of the impacted code file src/del.ts + // (its code-impact item's scope becomes a deleted location). No + // re-derivation runs (the walk resolves `no-change` only), so the + // item set is fixed at the create-time derivation. await workspace.file(M12_FILE, m12Spec(M12_V2)); + await fsp.rm(workspace.path(M12_CODE_DEL)); await buildOk(product, workspace, `${prefix} \`build\` at v2`); // Current-state captures (present nodes' texts and ranges). @@ -2830,13 +2857,14 @@ const T10_7_12 = defineProductTest({ `metadata-consistency ${M12_M}`, `dependency-consistency ${M12_DEP}`, `code-impact ${M12_CODE}`, + `code-impact ${M12_CODE_DEL}`, ].sort(), `${prefix}: the staged differences derive exactly one item per ` + `built-in path-blocks kind — the four changed nodes' ` + `subtree-coherence items, par's parent-consistency item, m's ` + `metadata-consistency item, dep's dependency-consistency item, ` + - `and the impacted location's code-impact item (SPEC 5.6, 9.2, ` + - `10.5)`, + `and one code-impact item per impacted location — the named ` + + `unit and the since-deleted file (SPEC 4.6, 5.6, 9.2, 10.5)`, ); // subtree-coherence par.n: scope subtree text (current), context = @@ -3143,10 +3171,15 @@ const T10_7_12 = defineProductTest({ ); } - // code-impact: the scope enters as identity and presence alone — no - // text, no source range (SPEC 10.7, 1.7); context = the targets that - // make it impacted (the added host.n included) with subtree texts; - // origin = those targets' originating nodes with their pairs. + // code-impact src/ref.ts#refUnit — the present location: the scope + // enters as identity, presence, and its source range — review + // payloads are one of the two range-presenting outputs for code + // locations (SPEC 1.7) — with no text (SPEC 10.7). The range is the + // named unit's construct (the function declaration binding + // `refUnit`, SPEC 4.6), asserted against precomputed byte offsets. + // Context = the targets that make it impacted (the added host.n + // included) with subtree texts; origin = those targets' originating + // nodes with their pairs. { const item = requireItem( exported.items, @@ -3154,12 +3187,14 @@ const T10_7_12 = defineProductTest({ M12_CODE, prefix, ); - const label = `${prefix} code-impact(src/ref.ts)`; + const label = `${prefix} code-impact(${M12_CODE})`; assertPresentState( item.scope, - { node: M12_CODE, text: undefined, sourceRange: undefined }, - `${label} scope — a code location has no text value and no ` + - `source range: identity and presence alone (SPEC 10.7, 1.7)`, + { node: M12_CODE, text: undefined, sourceRange: M12_CODE_RANGE }, + `${label} scope — a present code location enters the payload ` + + `with its source range, the construct binding the unit's ` + + `name, asserted against precomputed byte offsets, and with ` + + `no text value (SPEC 10.7, 1.7, 4.6)`, ); assertSameJson( identitySet(item.context), @@ -3210,6 +3245,56 @@ const T10_7_12 = defineProductTest({ ); } + // code-impact src/del.ts — the deleted location: the file was + // removed at v2, so its item's scope presents the code location + // absent — identity and absence alone, no text and no source range + // (SPEC 10.7, 1.7; reported under its baseline identity, SPEC 9.2). + { + const item = requireItem( + exported.items, + "code-impact", + M12_CODE_DEL, + prefix, + ); + const label = `${prefix} code-impact(${M12_CODE_DEL})`; + assertAbsentState( + item.scope, + { node: M12_CODE_DEL, text: undefined }, + `${label} scope — a deleted code location's entry carries no ` + + `source range and no text (SPEC 10.7, 1.7)`, + ); + assertSameJson( + identitySet(item.context), + [M12_PARN], + `${label}: context is the impact-edge target that makes the ` + + `location impacted (SPEC 9.2, 10.5)`, + ); + assertPresentState( + requireContextEntry(item, M12_PARN, label), + { + node: M12_PARN, + text: parN2.subtreeText, + sourceRange: parN2.sourceRange, + }, + `${label} context entry par.n — code-impact targets carry ` + + `subtree text`, + ); + assertSameJson( + identitySet(item.origin), + [M12_PARN], + `${label}: origin is the originating node of the target's ` + + `change (SPEC 5.6, 10.5)`, + ); + assertOriginPair( + requireOriginEntry(item, M12_PARN, label), + { + before: { present: true, text: parN0.ownText }, + after: { present: true, text: parN2.ownText }, + }, + `${label} origin pair for par.n`, + ); + } + // One payload rule (SPEC 10.7): `show` presents each item with the // identical payload, and a full `next` walk returns every item once // with the identical payload. diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 94f4778..6f9e4da 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -56,10 +56,13 @@ // - Other cross-section keys mirror TEST-SPEC's stated coverage: T1.2-3 // asserts the root exclusions of 8.1/8.2; T7.4-2 asserts the required-set // restrictions of 8.1 via coverage runs; T8-5's one-workspace sweep -// asserts 8.1's exclusion list; and section 10's body (the review -// mechanism/strategy split and the three built-in strategies) is exercised -// by T10.5-1, T10.6-1 (generation per strategy), T10.7-1 (strategy -// selection at `create`), and T10.7-4 (coverage sessions). +// asserts 8.1's exclusion list; T10.7-12 asserts 1.7's review-payload half +// of the two-range-presenting-outputs rule (the code-impact scope's +// named-unit construct range; TEST-SPEC 1.7 delegates it there from +// T1.7-1/T1.7-2); and section 10's body (the review mechanism/strategy +// split and the three built-in strategies) is exercised by T10.5-1, +// T10.6-1 (generation per strategy), T10.7-1 (strategy selection at +// `create`), and T10.7-4 (coverage sessions). // // A passage listed for a test is asserted by that test; the map lists each // test's primary passage(s), not every rule it touches in passing. S-1 fails @@ -273,7 +276,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T10.7-9": ["10.7"], "T10.7-10": ["10.7"], "T10.7-11": ["10.7"], - "T10.7-12": ["10.7"], + "T10.7-12": ["1.7", "10.7"], "T11-1": ["11", "11.1"], "T11-2": ["11", "11.1"], "T11-3": ["11", "11.1"], From fc91e6e8ee7895e9b4163007f1fbfe98ba5b5f09 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 10 Aug 2026 23:07:28 +0000 Subject: [PATCH 052/226] sdg(phase-9): restage the #-containing and non-UTF-8 move destinations as T6.5-5 exit-2 usage errors (FP-005) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 17 ++- test/self/s3-subprocess-driver.test.ts | 2 +- test/suite/registry/section-6.5.ts | 148 +++++++++++++++++-------- 3 files changed, 116 insertions(+), 51 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 9c8d439..d8548d8 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -164,7 +164,7 @@ A "new test T<x>" task always means, in one change: unchanged: 4 planned mid-loop reds (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G).] -- [ ] FP-005 — Re-stage the two `move` destination spellings that changed +- [x] FP-005 — Re-stage the two `move` destination spellings that changed exit class from refusal (exit 1) to usage error (exit 2). [R1 #25, non-UTF-8 half of R1 #22; TEST-SPEC T6.5-4 dead-letter note, T6.5-5] @@ -175,6 +175,21 @@ A "new test T<x>" task always means, in one change: T6.5-5 and assert exit 2 with the FP-002 protocol (single 12.7 error document under `--json`), workspace unmodified. Verify: red-as-diagnosed; T6.5-4 retains no exit-class contradiction. + [Done 2026-08-10: both arms now live in T6.5-5's valid-workspace block — + exit 2 via `expectMoveUsageError` (FP-002 protocol: single 12.7 error + document under `--json`, stderr message present), each wrapped in a + whole-root `assertLeavesUnchanged` compare; the helper widened to + raw-byte argv (`ArgvValue`) for the Linux-leg non-UTF-8 operand while + `expectRefusalModifiesNothing` narrowed back to strings; T6.5-4's + title/cases now carry the dead-letter note instead of the arms; the S-3 + driver self-test's staging citation updated T6.5-4 → T6.5-5. Verified: + T6.5-4 green against the current product (contradiction gone); T6.5-5 + red-as-diagnosed — direct probe shows the product still exits 1 with a + refusal document on both spellings (classifies the operand as a path) + where the arms demand exit 2; the suite test currently fails earlier at + its first arm (exit-2 stdout empty under `--json`, the FP-002-class + product gap). `npm run test:self`: unchanged 4 planned mid-loop reds + (certification-document ×3 → FP-091; S-1 unmapped keys → stages E/G).] ## Stage C — §§1–9 cross-cutting assertion sweeps (after FP-001) diff --git a/test/self/s3-subprocess-driver.test.ts b/test/self/s3-subprocess-driver.test.ts index 4d14d59..dfdacee 100644 --- a/test/self/s3-subprocess-driver.test.ts +++ b/test/self/s3-subprocess-driver.test.ts @@ -316,7 +316,7 @@ test("argv reaches the child verbatim — no shell interpretation, empty and met }); test.runIf(onPosix)( - "raw-byte (Uint8Array) argv elements reach the child byte-verbatim via the POSIX trampoline — non-UTF-8 argument staging (T6.5-4, T12.0-5)", + "raw-byte (Uint8Array) argv elements reach the child byte-verbatim via the POSIX trampoline — non-UTF-8 argument staging (T6.5-5, T12.0-5)", async () => { const { workspace } = await standin(); // `/bin/sh` itself is the known-behavior stand-in: `printf %s "$1"` diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index 2023155..9556aef 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -65,16 +65,25 @@ // without `--json`; "modifies nothing" is a whole-workspace-root byte // snapshot compare around each refused command with the pre-refusal // `build`'s derived files present (the T6.4-3 protocol). Because each arm -// proves it modified nothing, the arms share one staged workspace. The -// not-valid-UTF-8 destination is staged on the Linux leg only (mirroring -// T1.5-2's platform note): argv bytes exist as a channel there, carried by -// the subprocess driver's raw-byte argv support. +// proves it modified nothing, the arms share one staged workspace. The 6.5 +// destination clauses "containing `#`" and "not valid UTF-8" admit no +// refusal staging (T6.5-4's dead-letter note): every operand spelling that +// would present either is an exit-2 usage error before any refusal is +// evaluated — those stagings are T6.5-5's. // - T6.5-5 exit-2 arms run with `--json`: stdout exactly one 12.7 error // document (12.0: with JSON output in effect, an exit-2 invocation emits // the error document as its entire stdout — no report, no validation // findings: the 12.0-ordering discriminator) and the usage error message // on stderr (presence, not wording). The masking arm asserts exit 1 with -// exactly one 14.20 finding naming the unparseable origin file. +// exactly one 14.20 finding naming the unparseable origin file. The two +// stagings T6.5-4's dead-letter note sets aside are asserted here, each +// with a whole-root modifies-nothing snapshot compare around the command: +// a `#`-containing file-form destination — classified as a `<file>#<id>` +// pair by spelling alone, so the invocation mixes the two synopses' forms +// and matches neither (SPEC 6.5, 12.0) — and a non-UTF-8 destination +// operand, a usage-error argument value (SPEC 12.0), staged on the Linux +// leg only (mirroring T1.5-2's platform note): argv bytes exist as a +// channel there, carried by the subprocess driver's raw-byte argv support. // - T6.5-6's unstageable clauses are documented at the test, per TEST-SPEC: // the collision clause's after-the-removal qualifier admits no // discriminating fixture (structural IDs make the vacated set exactly the @@ -358,15 +367,14 @@ function renderArgv(argv: readonly ArgvValue[]): string { * existence checks refuses with exit 1): assert exit 1 exactly and that the * refusal modifies nothing — a whole-workspace-root byte snapshot compare * around the command (derived files, sources, and the journal all included). - * Accepts raw-byte argv elements for the Linux-leg non-UTF-8 destination arm. */ async function expectRefusalModifiesNothing( product: ProductBinding, workspace: TestWorkspace, - argv: readonly ArgvValue[], + argv: readonly string[], context: string, ): Promise<void> { - const command = renderArgv(argv); + const command = argv.join(" "); await assertLeavesUnchanged( workspace.root, async () => { @@ -386,26 +394,30 @@ async function expectRefusalModifiesNothing( } /** - * A move usage error (SPEC 6.5, 12.0: nonexistent origin file or origin ID): - * run with `--json`, assert exit 2 exactly, the single 12.7 error document - * as the entire stdout (12.0: no report and no validation findings — the - * 12.0-ordering discriminator; H-5), and a usage error message on stderr - * (presence, not wording). + * A move usage error (SPEC 6.5, 12.0): run with `--json`, assert exit 2 + * exactly, the single 12.7 error document as the entire stdout (12.0: no + * report and no validation findings — the 12.0-ordering discriminator; H-5), + * and a usage error message on stderr (presence, not wording). Accepts + * raw-byte argv elements for the Linux-leg non-UTF-8 destination arm + * (T6.5-5, T12.0-5: argv is a byte channel there, carried by the subprocess + * driver's raw-byte argv support). */ async function expectMoveUsageError( product: ProductBinding, workspace: TestWorkspace, - argv: readonly string[], + argv: readonly ArgvValue[], context: string, ): Promise<RunResult> { - const command = argv.join(" "); - const result = await expectExit( - product, - workspace, - [...argv, "--json"], + const command = renderArgv(argv); + const result = await runProduct(product, { + cwd: workspace.root, + argv: [...argv, "--json"], + }); + assertExitCode( + result, 2, - `${context}: \`${command} --json\` — a nonexistent origin file or origin ` + - `ID is a usage error (SPEC 6.5, 12.0)`, + `${context}: \`${command} --json\` — a usage error, exit 2 (SPEC 6.5, ` + + `12.0)`, ); expectErrorDocument( result, @@ -1396,18 +1408,10 @@ const V4_OTHER_INVALID = [ "", ].join("\n"); -// Destination path that is not valid UTF-8: `specs/<0xFF>.mdx` (Linux-leg -// staging — argv is a byte channel there; TEST-SPEC T6.5-4, T1.5-2's note). -const V4_NON_UTF8_DESTINATION: Uint8Array = Buffer.concat([ - Buffer.from("specs/", "utf8"), - Buffer.from([0xff]), - Buffer.from(".mdx", "utf8"), -]); - const T6_5_4 = defineProductTest({ id: "T6.5-4", title: - "refusals (exit 1, nothing modified): a move creating a spec import cycle or a dependency cycle; file form whose destination exists; section form with a 1.4-invalid `<new-id>` (forbidden name `then`; whitespace-bearing segment); the ordinary cross-file `<new-id>` collision; a missing target parent; a target parent within the moved subtree; and destination paths in no configured spec group, in a code group as well, containing `#`, not valid UTF-8 (Linux leg), or lacking `.mdx`; plus the valid-workspace precondition as T6.4-6 (SPEC 6.5, 5.3, 2.1, 1.4, 1.3, 14.14, 14.19, 12.0)", + "refusals (exit 1, nothing modified): a move creating a spec import cycle or a dependency cycle; file form whose destination exists; section form with a 1.4-invalid `<new-id>` (forbidden name `then`; whitespace-bearing segment); the ordinary cross-file `<new-id>` collision; a missing target parent; a target parent within the moved subtree; and destination paths in no configured spec group, in a code group as well, or lacking `.mdx` — the `#`-containing and non-UTF-8 destination clauses admit no refusal staging (the dead-letter note): every such operand spelling is an exit-2 usage error first, staged in T6.5-5; plus the valid-workspace precondition as T6.4-6 (SPEC 6.5, 5.3, 2.1, 1.4, 1.3, 14.14, 14.19, 12.0)", run: async (product) => { await withWorkspace( REFUSAL_CONFIG, @@ -1475,10 +1479,6 @@ const T6_5_4 = defineProductTest({ "destination path belonging to a code group as well (SPEC 6.5, " + "14.14)", ], - [ - ["move", "specs/A.mdx", "specs/Ha#sh.mdx"], - "destination path containing `#` (SPEC 6.5, 1.5, 14.19)", - ], [ ["move", "specs/A.mdx", "specs/plain/Out.md"], "destination path lacking the `.mdx` extension — it matches the " + @@ -1486,6 +1486,11 @@ const T6_5_4 = defineProductTest({ "(SPEC 6.5, 7.1, 14.19)", ], ]; + // No `#`-containing and no non-UTF-8 destination arm here: those 6.5 + // destination clauses are dead letters as refusals (T6.5-4's note) — + // a destination path exists only as an operand spelling, and every + // spelling that would present either is an exit-2 usage error before + // any refusal is evaluated. T6.5-5 stages both. for (const [argv, reason] of cases) { await expectRefusalModifiesNothing( product, @@ -1494,20 +1499,6 @@ const T6_5_4 = defineProductTest({ `T6.5-4 (${reason})`, ); } - - // Not valid UTF-8, staged on the Linux leg per T6.5-4's own text: - // Linux argv is a byte channel, so the destination is passed as raw - // bytes (driver trampoline); other platforms cannot carry the - // argument at all (the T1.5-2 platform note). - if (process.platform === "linux") { - await expectRefusalModifiesNothing( - product, - workspace, - ["move", V4_A, V4_NON_UTF8_DESTINATION], - "T6.5-4 (destination path not valid UTF-8 — Linux leg; " + - "SPEC 6.5, 14.19)", - ); - } }, ); @@ -1580,6 +1571,18 @@ const U5_BROKEN_SOURCE = [ "", ].join("\n"); +// Destination operand that is not valid UTF-8: `specs/<0xFF>.mdx` (Linux-leg +// staging — argv is a byte channel there; T6.5-5, T12.0-5, T1.5-2's note). +// It contains no `#`, so only the argument-value rule makes it exit 2: a +// non-UTF-8 argument value is a usage error (SPEC 12.0), and a valid operand +// therefore never denotes a non-UTF-8 destination path — the 6.5 refusal +// clause is unreachable (T6.5-4's dead-letter note). +const U5_NON_UTF8_DESTINATION: Uint8Array = Buffer.concat([ + Buffer.from("specs/", "utf8"), + Buffer.from([0xff]), + Buffer.from(".mdx", "utf8"), +]); + const U5_USAGE_CASES: readonly (readonly [readonly string[], string])[] = [ [ ["move", "specs/Missing.mdx", "specs/New.mdx"], @@ -1598,7 +1601,7 @@ const U5_USAGE_CASES: readonly (readonly [readonly string[], string])[] = [ const T6_5_5 = defineProductTest({ id: "T6.5-5", title: - "usage errors (exit 2): a nonexistent origin file (either form) and a nonexistent origin ID are usage errors checked before source validation — the same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering, as T6.4-4) — but an origin ID inside an unparseable origin file is masked: the validation findings are reported and the command exits 1 (SPEC 6.5, 12.0, 14, 14.20)", + "usage errors (exit 2): a nonexistent origin file (either form) and a nonexistent origin ID are usage errors checked before source validation — the same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering, as T6.4-4) — but an origin ID inside an unparseable origin file is masked: the validation findings are reported and the command exits 1; a `#`-containing file-form destination classifies as a `<file>#<id>` pair by spelling alone, the invocation matching neither synopsis (exit 2), and a non-UTF-8 destination operand (raw argv bytes, Linux leg) is a usage-error argument value (exit 2) — the stagings T6.5-4's dead-letter note sets aside, each modifying nothing (SPEC 6.5, 12.0, 14, 14.20)", run: async (product) => { // --- Base arm: a valid workspace --- await withWorkspace( @@ -1615,6 +1618,53 @@ const T6_5_5 = defineProductTest({ `${context}, ${label}`, ); } + + // The stagings T6.5-4's dead-letter note sets aside (SPEC 6.5's + // `#`-containing and non-UTF-8 destination clauses), each asserted + // with a whole-root modifies-nothing snapshot compare around the + // command. A move operand is classified by spelling alone: an + // operand containing `#` is a `<file>#<id>` pair under the 12.0 + // split, so `specs/Ha#sh.mdx` beside the bare-file origin mixes the + // two synopses' forms and matches neither — exit 2, never the 6.5 + // destination refusal (exit 1) it would be were the operand a path. + await assertLeavesUnchanged( + workspace.root, + async () => { + await expectMoveUsageError( + product, + workspace, + ["move", "specs/A.mdx", "specs/Ha#sh.mdx"], + `${context}, \`#\`-containing file-form destination — the ` + + `operand classifies as a \`<file>#<id>\` pair by spelling ` + + `alone, so the invocation mixes the two synopses' forms ` + + `and matches neither (SPEC 6.5, 12.0)`, + ); + }, + `${context}: \`move specs/A.mdx specs/Ha#sh.mdx\` — the usage ` + + `error modifies nothing (SPEC 6.5, 12.0)`, + ); + + // Non-UTF-8 destination operand, staged on the Linux leg only + // (mirroring T1.5-2's platform note): Linux argv is a byte channel, + // so the destination is passed as raw bytes (driver trampoline); + // other platforms cannot carry the argument at all. + if (process.platform === "linux") { + await assertLeavesUnchanged( + workspace.root, + async () => { + await expectMoveUsageError( + product, + workspace, + ["move", U5_A, U5_NON_UTF8_DESTINATION], + `${context}, non-UTF-8 destination operand (raw argv ` + + `bytes, Linux leg) — a non-UTF-8 argument value is a ` + + `usage error (SPEC 12.0)`, + ); + }, + `${context}: \`move ${U5_A} <non-UTF-8 bytes>\` — the usage ` + + `error modifies nothing (SPEC 6.5, 12.0)`, + ); + } }, ); From ba212a10f5ed77a5e77032ca9176a5c966fc1607 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 10 Aug 2026 23:19:35 +0000 Subject: [PATCH 053/226] =?UTF-8?q?sdg(phase-9):=20mark=20FP-006=20done=20?= =?UTF-8?q?=E2=80=94=20=C2=A7=C2=A71=E2=80=939=20condition=20assertions=20?= =?UTF-8?q?already=20token-exact=20and=2012.7-literal=20via=20FP-001's=20l?= =?UTF-8?q?ayer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research disposition, no code change: every §§1–9 numbered-condition assertion site routes through the shared helpers FP-001 rebuilt (buildFindings/assertConditionCounts/assertFindingLocated/ finding.condition/expectConfigurationError) on the form-exact 12.7 decode; the 14.N identity is the pinned-table image of the decode-validated token, so each assertion holds exactly for the one SPEC 14 code string, and expectConfigurationError asserts configuration-error directly. No stderr/human/ad-hoc-JSON condition assertions exist in §§1–9 (conditionMention is S-5-only). Verified: npm run typecheck clean; npm run test:self 4 failed / 231 passed — exactly the planned mid-loop reds (certification-document x3 until FP-091, s1-traceability until stages E/G), S-5 and certification green; FP-001-untouched section-2.4 probe red-as-diagnosed at the form-exact decode against the pre-12.7 product. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index d8548d8..bca1fc3 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -193,7 +193,7 @@ A "new test T<x>" task always means, in one change: ## Stage C — §§1–9 cross-cutting assertion sweeps (after FP-001) -- [ ] FP-006 — Sweep §§1–9 condition assertions to assert stable code +- [x] FP-006 — Sweep §§1–9 condition assertions to assert stable code tokens and the literal 12.7 finding form. [R1 #27, #28; TEST-SPEC §0 ("where §14 assigns the condition or refusal reason a stable code, assert that exact code string"), SPEC 12.7, 14] @@ -204,6 +204,31 @@ A "new test T<x>" task always means, in one change: `locations`, `path`, `identities`). Sweep the registry modules for sections 1–5, 7, 8, 9 (section 6 refusals are FP-007). Verify: affected files red-as-diagnosed only; S-5/S-1 unaffected. + [Done 2026-08-10 — already satisfied by FP-001's landing; no code change. + The sweep found every §§1–9 numbered-condition assertion site (1.3: + 14.1–14.4; 1.4: 14.4; 1.5: 14.19; 1.6-1.7: 14.20; 2.1: 14.15/14.9; 2.4: + 14.5–14.8; 2.5-2.6: 14.17/14.12; 2.7: 14.16/14.17/14.8; 4: 14.15; + 4.3-4.4: 14.8/14.11; 4.5: 14.8/14.18; 5.1-5.3: 14.9; 7-*: 14.14/14.12/ + 14.15/14.19; 8: 14.12; sections 1.1-1.2, 2.2-2.3, 3, 4.1-4.2, 4.6, + 5.4-5.6, 9, 9.3 assert none) routing through the shared helpers FP-001 + rebuilt — `buildFindings`/`assertConditionCounts`/`assertFindingLocated`/ + `finding.condition`/`expectConfigurationError` — all on the form-exact + 12.7 decode (`decodeFindingForm`: exact five members, path forms, orders, + collapse). The `14.N` identity exists only as the pinned-table image of + the decode-validated token (FP-001's stated design: condition-identity + assertions are expressed against tokens; unknown or missing codes fail + the decode loudly), so each `"14.N"` assertion holds exactly for the one + SPEC 14 code string; `expectConfigurationError` asserts + `"configuration-error"` directly. No §§1–9 site asserts a condition via + stderr text, human-report mentions (`conditionMention` is S-5-only), or + ad-hoc JSON access. Verified: typecheck clean; `npm run test:self` + unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; + S-1 unmapped keys → stages E/G) with S-5 and certification green + (CONF-VALID, emitting 12.7-form findings with stable tokens, passes its + in-scope T1.3-*/T1.4-* tests; violators fail as certified); probe run of + FP-001-untouched section-2.4 red-as-diagnosed at the form-exact decode + ("expected no member \"condition\"" against the pre-12.7 product), no + crashes.] - [ ] FP-007 — Make §6 refusal assertions assert stable refusal codes with their concerned file/range/identity. [R1 #27; TEST-SPEC §§6.4–6.5, SPEC From a0db85527f556ddf83b989f2985a2a46a27c011c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 10 Aug 2026 23:41:20 +0000 Subject: [PATCH 054/226] =?UTF-8?q?sdg(phase-9):=20assert=20stable=20refus?= =?UTF-8?q?al=20codes=20with=20their=20=C2=A714=20concerns=20across=20the?= =?UTF-8?q?=20=C2=A76=20refusal=20arms=20(FP-007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend both expectRefusalModifiesNothing helpers to run refusals under --json, decode the form-exact 12.7 findings-only report, and assert exactly one finding per arm carrying the exact stable refusal code plus its concerned identity, path, or located participant (SPEC 14, T14-7 staging record: T6.4-3, T6.5-4, T6.5-6). New support helpers assertFindingMentionsLocation / assertFindingNamesIdentity / assertFindingConcernsPath; invalid-workspace precondition arms assert the one located 14.5 finding alone; traceability adds "14" to the three staged-at tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 29 +++- test/suite/registry/section-6.4.ts | 163 ++++++++++++++++++++--- test/suite/registry/section-6.5.ts | 200 +++++++++++++++++++++++++--- test/suite/registry/support.ts | 102 +++++++++++++- test/suite/registry/traceability.ts | 20 +-- 5 files changed, 468 insertions(+), 46 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index bca1fc3..b1ced0f 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -230,7 +230,7 @@ A "new test T<x>" task always means, in one change: ("expected no member \"condition\"" against the pre-12.7 product), no crashes.] -- [ ] FP-007 — Make §6 refusal assertions assert stable refusal codes with +- [x] FP-007 — Make §6 refusal assertions assert stable refusal codes with their concerned file/range/identity. [R1 #27; TEST-SPEC §§6.4–6.5, SPEC 12.7, 14] `expectRefusalModifiesNothing` (defined in @@ -240,6 +240,33 @@ A "new test T<x>" task always means, in one change: asserts the exact stable refusal code and the concerned file/range/ identity of the refusal finding, most acutely T6.4-3, T6.5-4, T6.5-6. Verify: red-as-diagnosed only. + [Done 2026-08-10: both modules' `expectRefusalModifiesNothing` now take a + per-arm `RefusalExpectation` — run with `--json`, exit 1, stdout decoded + as the form-exact 12.7 findings-only report, exactly one finding under the + arm's exact stable code (each arm isolates one cause; one finding per + applicable reason, SPEC 14), plus the reason's §14 concern via new + support.ts helpers: `assertFindingNamesIdentity` (full 1.5 identity or + bare ID — §14 requires identification, not spelling; refused-invalid-id, + refused-identity-unchanged, refused-missing-target-parent, + refused-structural-parent), `assertFindingConcernsPath` (12.7 `path` + member equality; refused-destination-exists, refused-invalid-destination), + `assertFindingMentionsLocation` (SOME-quantified; refused-id-collision + locates the remaining bearer — byte windows over the staged `a.sib`/`y` + constructs; refused-cycle's dependency arm locates the participating + `d={"keep"}` spelling; T6.5-6's collision asserts file-only, B.mdx being + product-rewritten). The spec-import-cycle arm pins code+form alone (the + would-be cycle's participating import declarations exist in no + pre-operation source, so no concern window is derivable). Precondition + arms (T6.4-6, T6.5-4) assert the invalid-workspace refusal as exactly the + one located 14.5 finding, no refusal reason beside it. Traceability: "14" + added to T6.4-3/T6.5-4/T6.5-6 per TEST-SPEC 14's refusal-reason staging + record. Verified: those four tests turned falsely-green → + red-as-diagnosed at the form-exact decode (the stub emits `{"refused":…}` + for refusals and old-shape `condition` findings for the gate; suite files + 6.4+6.5 went 2 failed/11 passed → 6 failed/7 passed, the other four being + the pre-existing FP-002-class exit-2 gaps); `npm run test:self` unchanged + 4 planned mid-loop reds (certification-document ×3 → FP-091; S-1 unmapped + keys → stages E/G), S-5 and certification green.] ## Stage D — §§1–9 missing arms, with paired certification-fixture reworks diff --git a/test/suite/registry/section-6.4.ts b/test/suite/registry/section-6.4.ts index f092081..e70a28c 100644 --- a/test/suite/registry/section-6.4.ts +++ b/test/suite/registry/section-6.4.ts @@ -43,9 +43,20 @@ // - T6.4-3/T6.4-6 "modifies nothing" is a whole-workspace-root byte snapshot // compare around the refused command, with the pre-refusal `build`'s // derived files present — a product that rewrites before validating, or -// regenerates on refusal, fails the compare. Refusal report content is -// deliberately unasserted (12.0 classes refusals exit 1; TEST-SPEC pins no -// report content for them), so refusal arms run without `--json`. +// regenerates on refusal, fails the compare. Refusal arms run with +// `--json`: a refused operation's report is the form-exact 12.7 +// findings-only report (SPEC 12.7, H-3), and each arm — staged to isolate +// one refusal cause — asserts exactly one finding carrying the exact +// stable refusal code (SPEC 14: one finding per applicable reason, +// TEST-SPEC preamble: a code is contract) with the concerned identity or +// located bearer §14 assigns the reason (T14-7's staging record names +// T6.4-3). Identity concerns accept the full 1.5 identity or its bare ID +// (§14 requires identification, not spelling); the collision arm's window +// spans the remaining colliding bearer's whole construct, admitting any +// in-construct precision while rejecting wrong-construct attribution. +// T6.4-6's invalid-workspace refusal instead reports the workspace's +// numbered findings alone (SPEC 14, 6.4) — exactly its one 14.5 finding +// located in the offending file, no refusal reason beside it. // - T6.4-4 exit-2 arms run with `--json`: stdout exactly one 12.7 error // document (12.0: with JSON output in effect, an exit-2 invocation emits // the error document as its entire stdout — no report, no validation @@ -81,13 +92,17 @@ import { } from "../../helpers/snapshot.js"; import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { ConcernedIdentity, FindingSourceExpectation } from "./support.js"; import { assertConditionCounts, assertEdgeSetEqual, assertFindingLocated, + assertFindingMentionsLocation, + assertFindingNamesIdentity, assertSameJson, buildFindings, buildOk, + byteWindow, expectErrorDocument, expectExit, runJson, @@ -295,31 +310,84 @@ function assertRewriteHappened( } } +/** + * What a refused rename's report must hold (SPEC 14, 12.7): the arm's one + * finding — its exact stable code — plus whichever concern §14 assigns the + * reason: a located bearer/spelling, a concerned identity, or nothing further + * where the concern's rendering is the reason's message alone. + */ +interface RefusalExpectation { + /** + * The finding's counting key (`assertConditionCounts` vocabulary): a + * stable refusal code token (`refused-…`), or a `14.N` condition identity + * for the invalid-workspace refusal, which reports the workspace's + * numbered findings alone (SPEC 14, 6.4). + */ + readonly finding: string; + /** At least one location names this file (and byte window when given). */ + readonly locatedAt?: FindingSourceExpectation; + /** At least one identities entry names this concerned identity. */ + readonly identity?: ConcernedIdentity; +} + /** * A refused rename (SPEC 6.4: every validation failure beyond the argument - * existence checks refuses with exit 1): assert exit 1 exactly and that the - * refusal modifies nothing — a whole-workspace-root byte snapshot compare - * around the command (derived files, sources, and the journal's absence all - * included). + * existence checks refuses with exit 1): run with `--json`, assert exit 1 + * exactly, decode stdout as the form-exact 12.7 findings-only report of a + * refused operation (SPEC 12.7, H-3), assert the report holds exactly one + * finding bearing the arm's stable code with its concerned data (SPEC 14, + * T14-7), and assert the refusal modifies nothing — a whole-workspace-root + * byte snapshot compare around the command (derived files, sources, and the + * journal's absence all included). */ async function expectRefusalModifiesNothing( product: ProductBinding, workspace: TestWorkspace, argv: readonly string[], + expected: RefusalExpectation, context: string, ): Promise<void> { const command = argv.join(" "); await assertLeavesUnchanged( workspace.root, - async () => - await expectExit( + async () => { + const result = await expectExit( product, workspace, - argv, + [...argv, "--json"], 1, - `${context}: \`${command}\` — the refusal is a validation failure, ` + - `exit 1 (SPEC 6.4, 12.0)`, - ), + `${context}: \`${command} --json\` — the refusal is a validation ` + + `failure, exit 1 (SPEC 6.4, 12.0)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, `${context}: \`${command} --json\``), + `${context}: \`${command} --json\` — a refused operation's report ` + + `is the form-exact 12.7 findings-only report (SPEC 12.7, H-3)`, + ).findings; + assertConditionCounts( + findings, + { [expected.finding]: 1 }, + `${context}: the arm isolates one refusal cause, so the report ` + + `holds exactly one finding carrying its exact stable code — one ` + + `finding per applicable reason, a code is contract (SPEC 14, ` + + `12.7, T14-7)`, + ); + const finding = findings[0]!; + if (expected.locatedAt !== undefined) { + assertFindingMentionsLocation( + finding, + expected.locatedAt, + `${context}: the refusal's concerned construct`, + ); + } + if (expected.identity !== undefined) { + assertFindingNamesIdentity( + finding, + expected.identity, + `${context}: the refusal's concerned identity`, + ); + } + }, `${context}: \`${command}\` refused — modifies nothing (SPEC 6.4)`, ); } @@ -961,10 +1029,21 @@ const V3_SOURCE = [ "", ].join("\n"); +// The remaining colliding bearer's whole construct within V3_SOURCE — the +// refused-id-collision arm's location window (SPEC 14: the collision locates +// every colliding bearer, the remaining `a.sib` bearer included): any +// in-construct precision passes; a location attributed to another construct +// fails. +const V3_SIB_CONSTRUCT = '<S id="a.sib">\nSib text.\n</S>'; +const V3_SIB_WINDOW = byteWindow( + V3_SOURCE.slice(0, V3_SOURCE.indexOf(V3_SIB_CONSTRUCT)), + V3_SIB_CONSTRUCT, +); + const T6_4_3 = defineProductTest({ id: "T6.4-3", title: - "validation refusals (exit 1): a new ID that is invalid (1.4), equal to the old ID, colliding with an existing ID, or violating structural parent rules each refuses the rename and modifies nothing (workspace byte-compare) (SPEC 6.4, 1.4, 1.3, 12.0)", + "validation refusals (exit 1): a new ID that is invalid (1.4), equal to the old ID, colliding with an existing ID, or violating structural parent rules each refuses the rename and modifies nothing (workspace byte-compare) — each refusal reported as the form-exact 12.7 findings-only report holding exactly one finding with its exact stable refusal code (refused-invalid-id, refused-identity-unchanged, refused-id-collision, refused-structural-parent) and the concerned identity or located colliding bearer (SPEC 6.4, 1.4, 1.3, 12.0, 12.7, 14)", run: async (product) => { await withWorkspace( SPECS_ONLY_CONFIG, @@ -978,28 +1057,65 @@ const T6_4_3 = defineProductTest({ "T6.4-3 `build` over the staged workspace", ); - const cases: readonly (readonly [string, string])[] = [ + // Each arm's expected refusal finding (SPEC 14): the exact stable + // code, with the concerned identity (`refused-invalid-id` and + // `refused-structural-parent` concern the offending identity; + // `refused-identity-unchanged` concerns the unchanged one) or the + // located remaining colliding bearer (`refused-id-collision` + // locates every colliding bearer). + const cases: readonly (readonly [ + string, + RefusalExpectation, + string, + ])[] = [ [ "a.then", + { + finding: "refused-invalid-id", + identity: { file: V3_FILE, id: "a.then" }, + }, "new ID invalid per 1.4 — its segment is the forbidden name `then`", ], [ "a.mi d", + { + finding: "refused-invalid-id", + identity: { file: V3_FILE, id: "a.mi d" }, + }, "new ID invalid per 1.4 — its segment contains whitespace", ], - ["a.mid", "new ID equal to the old ID"], - ["a.sib", "new ID colliding with an existing ID in the file"], + [ + "a.mid", + { + finding: "refused-identity-unchanged", + identity: { file: V3_FILE, id: "a.mid" }, + }, + "new ID equal to the old ID", + ], + [ + "a.sib", + { + finding: "refused-id-collision", + locatedAt: { file: V3_FILE, window: V3_SIB_WINDOW }, + }, + "new ID colliding with an existing ID in the file", + ], [ "x.mid", + { + finding: "refused-structural-parent", + identity: { file: V3_FILE, id: "x.mid" }, + }, "new ID violating the structural parent rules — the node is nested " + "inside `a`, so its ID must be `a` plus one segment (1.3)", ], ]; - for (const [newId, reason] of cases) { + for (const [newId, expected, reason] of cases) { await expectRefusalModifiesNothing( product, workspace, ["rename", V3_FILE, "a.mid", newId], + expected, `T6.4-3 (${reason})`, ); } @@ -1009,6 +1125,10 @@ const T6_4_3 = defineProductTest({ product, workspace, ["rename", V3_FILE, "a", "b.c"], + { + finding: "refused-structural-parent", + identity: { file: V3_FILE, id: "b.c" }, + }, "T6.4-3 (new ID violating the structural parent rules — a " + "top-level section's ID has exactly one segment, 1.3)", ); @@ -1275,7 +1395,7 @@ const P6_OTHER_INVALID = [ const T6_4_6 = defineProductTest({ id: "T6.4-6", title: - "valid-workspace precondition: with a pre-existing validation error elsewhere, rename refuses (exit 1) before modifying anything — the rename's own arguments are valid, so the refusal is the 6.4 precondition that rename only ever rewrites a valid workspace (SPEC 6.4, 12.1)", + "valid-workspace precondition: with a pre-existing validation error elsewhere, rename refuses (exit 1) before modifying anything — the rename's own arguments are valid, so the refusal is the 6.4 precondition that rename only ever rewrites a valid workspace, and it reports the workspace's numbered findings alone: exactly the one located 14.5 finding, no refusal reason beside it (SPEC 6.4, 12.1, 14)", run: async (product) => { await withWorkspace( SPECS_ONLY_CONFIG, @@ -1289,10 +1409,15 @@ const T6_4_6 = defineProductTest({ // Introduce the pre-existing validation error elsewhere; the rename // subject and its file stay untouched and its arguments valid. await workspace.file(P6_OTHER_FILE, P6_OTHER_INVALID); + // The invalid-workspace refusal reports the workspace's findings + // themselves — exactly the one 14.5 finding located in the offending + // file, no refusal reason evaluated or reported beside it (SPEC 6.4, + // 14). await expectRefusalModifiesNothing( product, workspace, ["rename", P6_FILE, "a.mid", "a.hub"], + { finding: "14.5", locatedAt: { file: P6_OTHER_FILE } }, "T6.4-6 (the workspace fails the validations of `xspec build` — an " + "unresolved d reference in specs/Other.mdx, SPEC 14.5 — so the " + "rename refuses before modifying anything: no source rewrite, no " + diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index 9556aef..382eb15 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -60,12 +60,29 @@ // references become double-quoted string literals, kept forms keep their // quote style — are asserted as exact substrings (`d={"tm"}`, // `{text("tm.k1")}`, `d={"tm.k1"}`). -// - T6.5-4 refusal report content is deliberately unasserted (12.0 classes -// refusals exit 1; TEST-SPEC pins no report content), so refusal arms run -// without `--json`; "modifies nothing" is a whole-workspace-root byte -// snapshot compare around each refused command with the pre-refusal -// `build`'s derived files present (the T6.4-3 protocol). Because each arm -// proves it modified nothing, the arms share one staged workspace. The 6.5 +// - T6.5-4/T6.5-6 refusal arms run with `--json`: a refused operation's +// report is the form-exact 12.7 findings-only report (SPEC 12.7, H-3), and +// each arm — staged to isolate one refusal cause — asserts exactly one +// finding carrying the exact stable refusal code (SPEC 14: one finding per +// applicable reason; TEST-SPEC preamble: a code is contract) with the +// concern §14 assigns the reason: the concerned identity +// (refused-invalid-id, refused-identity-unchanged, +// refused-missing-target-parent; the full 1.5 identity or its bare ID — +// §14 requires identification, not spelling), the concerned path +// (refused-destination-exists, refused-invalid-destination), or a located +// participant (refused-id-collision locates every colliding bearer — the +// remaining bearer's construct is the window where the staged bytes are +// known; refused-cycle locates every reference spelling recording a +// participating dependency edge — the `d={"keep"}` spelling for the +// dependency-cycle arm, while the would-be spec-import cycle's +// participating import declarations exist in no pre-operation source, so +// that arm pins the code and form alone). "Modifies nothing" stays the +// whole-workspace-root byte snapshot compare around each refused command +// with the pre-refusal `build`'s derived files present (the T6.4-3 +// protocol); because each arm proves it modified nothing, the arms share +// one staged workspace. The precondition arm's invalid-workspace refusal +// instead reports the workspace's numbered findings alone (SPEC 14, 6.4): +// exactly its one 14.5 finding located in the offending file. The 6.5 // destination clauses "containing `#`" and "not valid UTF-8" admit no // refusal staging (T6.5-4's dead-letter note): every operand spelling that // would present either is an exit-2 usage error before any refusal is @@ -119,13 +136,18 @@ import type { RunResult, } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { ConcernedIdentity, FindingSourceExpectation } from "./support.js"; import { assertConditionCounts, assertEdgeSetEqual, + assertFindingConcernsPath, assertFindingLocated, + assertFindingMentionsLocation, + assertFindingNamesIdentity, assertSameJson, buildFindings, buildOk, + byteWindow, expectErrorDocument, expectExit, runJson, @@ -362,16 +384,43 @@ function renderArgv(argv: readonly ArgvValue[]): string { .join(" "); } +/** + * What a refused move's report must hold (SPEC 14, 12.7): the arm's one + * finding — its exact stable code — plus whichever concern §14 assigns the + * reason: a located participant, a concerned identity, a concerned path, or + * nothing further where no pre-operation construct renders the concern. + */ +interface RefusalExpectation { + /** + * The finding's counting key (`assertConditionCounts` vocabulary): a + * stable refusal code token (`refused-…`), or a `14.N` condition identity + * for the invalid-workspace refusal, which reports the workspace's + * numbered findings alone (SPEC 14, 6.4, 6.5). + */ + readonly finding: string; + /** At least one location names this file (and byte window when given). */ + readonly locatedAt?: FindingSourceExpectation; + /** At least one identities entry names this concerned identity. */ + readonly identity?: ConcernedIdentity; + /** The finding's 12.7 path member equals this workspace-relative path. */ + readonly path?: string; +} + /** * A refused move (SPEC 6.5: every validation failure beyond the argument - * existence checks refuses with exit 1): assert exit 1 exactly and that the - * refusal modifies nothing — a whole-workspace-root byte snapshot compare - * around the command (derived files, sources, and the journal all included). + * existence checks refuses with exit 1): run with `--json`, assert exit 1 + * exactly, decode stdout as the form-exact 12.7 findings-only report of a + * refused operation (SPEC 12.7, H-3), assert the report holds exactly one + * finding bearing the arm's stable code with its concerned data (SPEC 14, + * T14-7), and assert the refusal modifies nothing — a whole-workspace-root + * byte snapshot compare around the command (derived files, sources, and the + * journal all included). */ async function expectRefusalModifiesNothing( product: ProductBinding, workspace: TestWorkspace, argv: readonly string[], + expected: RefusalExpectation, context: string, ): Promise<void> { const command = argv.join(" "); @@ -380,14 +429,49 @@ async function expectRefusalModifiesNothing( async () => { const result = await runProduct(product, { cwd: workspace.root, - argv, + argv: [...argv, "--json"], }); assertExitCode( result, 1, - `${context}: \`${command}\` — the refusal is a validation failure, ` + - `exit 1 (SPEC 6.5, 12.0)`, + `${context}: \`${command} --json\` — the refusal is a validation ` + + `failure, exit 1 (SPEC 6.5, 12.0)`, ); + const findings = decodeFindingsReport( + parseJsonStdout(result, `${context}: \`${command} --json\``), + `${context}: \`${command} --json\` — a refused operation's report ` + + `is the form-exact 12.7 findings-only report (SPEC 12.7, H-3)`, + ).findings; + assertConditionCounts( + findings, + { [expected.finding]: 1 }, + `${context}: the arm isolates one refusal cause, so the report ` + + `holds exactly one finding carrying its exact stable code — one ` + + `finding per applicable reason, a code is contract (SPEC 14, ` + + `12.7, T14-7)`, + ); + const finding = findings[0]!; + if (expected.locatedAt !== undefined) { + assertFindingMentionsLocation( + finding, + expected.locatedAt, + `${context}: the refusal's concerned construct`, + ); + } + if (expected.identity !== undefined) { + assertFindingNamesIdentity( + finding, + expected.identity, + `${context}: the refusal's concerned identity`, + ); + } + if (expected.path !== undefined) { + assertFindingConcernsPath( + finding, + expected.path, + `${context}: the refusal's concerned path`, + ); + } }, `${context}: \`${command}\` refused — modifies nothing (SPEC 6.5)`, ); @@ -1396,6 +1480,22 @@ const V4_B_SOURCE = [ "", ].join("\n"); +// Location windows within the staged sources (SPEC 14): the dependency-cycle +// arm locates the reference spelling recording the participating dependency +// edge — the moved node's `d={"keep"}` — and the cross-file collision arm +// locates the remaining colliding bearer `y`'s construct in the target file +// (any in-window precision passes; wrong-construct attribution fails). +const V4_KEEP_SPELLING = 'd={"keep"}'; +const V4_KEEP_WINDOW = byteWindow( + V4_A_SOURCE.slice(0, V4_A_SOURCE.indexOf(V4_KEEP_SPELLING)), + V4_KEEP_SPELLING, +); +const V4_Y_CONSTRUCT = '<S id="y">\nY text.\n</S>'; +const V4_Y_WINDOW = byteWindow( + V4_B_SOURCE.slice(0, V4_B_SOURCE.indexOf(V4_Y_CONSTRUCT)), + V4_Y_CONSTRUCT, +); + // The precondition arm's other file: valid at staging (so the pre-refusal // `build` succeeds), then overwritten with an unresolved local `d` reference // (14.5) — the pre-existing validation error elsewhere (as T6.4-6). @@ -1411,7 +1511,7 @@ const V4_OTHER_INVALID = [ const T6_5_4 = defineProductTest({ id: "T6.5-4", title: - "refusals (exit 1, nothing modified): a move creating a spec import cycle or a dependency cycle; file form whose destination exists; section form with a 1.4-invalid `<new-id>` (forbidden name `then`; whitespace-bearing segment); the ordinary cross-file `<new-id>` collision; a missing target parent; a target parent within the moved subtree; and destination paths in no configured spec group, in a code group as well, or lacking `.mdx` — the `#`-containing and non-UTF-8 destination clauses admit no refusal staging (the dead-letter note): every such operand spelling is an exit-2 usage error first, staged in T6.5-5; plus the valid-workspace precondition as T6.4-6 (SPEC 6.5, 5.3, 2.1, 1.4, 1.3, 14.14, 14.19, 12.0)", + "refusals (exit 1, nothing modified): a move creating a spec import cycle or a dependency cycle (refused-cycle, the dependency arm locating the participating `d` spelling); file form whose destination exists (refused-destination-exists, concerning that path); section form with a 1.4-invalid `<new-id>` (forbidden name `then`; whitespace-bearing segment — refused-invalid-id, concerning that identity); the ordinary cross-file `<new-id>` collision (refused-id-collision, locating the remaining bearer); a missing target parent and a target parent within the moved subtree (refused-missing-target-parent, concerning the target-parent identity); and destination paths in no configured spec group, in a code group as well, or lacking `.mdx` (refused-invalid-destination, concerning the destination path) — each refusal the form-exact 12.7 findings-only report holding exactly one finding with its exact stable code; the `#`-containing and non-UTF-8 destination clauses admit no refusal staging (the dead-letter note): every such operand spelling is an exit-2 usage error first, staged in T6.5-5; plus the valid-workspace precondition as T6.4-6, reporting the workspace's numbered findings alone (SPEC 6.5, 5.3, 2.1, 1.4, 1.3, 14.14, 14.19, 12.0, 12.7, 14)", run: async (product) => { await withWorkspace( REFUSAL_CONFIG, @@ -1425,62 +1525,109 @@ const T6_5_4 = defineProductTest({ "T6.5-4 `build` over the staged workspace", ); - const cases: readonly (readonly [readonly string[], string])[] = [ + // Each arm's expected refusal finding (SPEC 14): the exact stable + // code with the concern §14 assigns the reason — identity, path, or + // located participant (the module header's T6.5-4 note walks the + // per-reason choices). + const cases: readonly (readonly [ + readonly string[], + RefusalExpectation, + string, + ])[] = [ [ ["move", "specs/A.mdx#mv", "specs/B.mdx#bmv"], + // The would-be spec import cycle's participating import + // declarations exist in no pre-operation source (the move would + // add both), so no concern window is assertable: the arm pins + // the exact code and the 12.7 form alone. + { finding: "refused-cycle" }, "spec import cycle — the moved node's local `d` on `keep` needs " + "B.mdx to import A.mdx while `user`'s reference to the moved " + "node needs A.mdx to import B.mdx (SPEC 6.5, 2.1)", ], [ ["move", "specs/A.mdx#mv", "specs/A.mdx#keep.mv"], + { + finding: "refused-cycle", + locatedAt: { file: V4_A, window: V4_KEEP_WINDOW }, + }, "dependency cycle — the moved node depends on `keep` and would " + "become its child, a dependency on its own ancestor (SPEC 6.5, " + "5.3)", ], [ ["move", "specs/A.mdx", "specs/B.mdx"], + { finding: "refused-destination-exists", path: V4_B }, "file form whose destination file already exists (SPEC 6.5)", ], [ ["move", "specs/A.mdx#keep", "specs/B.mdx#then"], + { + finding: "refused-invalid-id", + identity: { file: V4_B, id: "then" }, + }, "section form whose <new-id> is invalid per 1.4 — the forbidden " + "name `then` (the mirrored new-ID-is-valid check, SPEC 6.5)", ], [ ["move", "specs/A.mdx#keep", "specs/B.mdx#ha lf"], + { + finding: "refused-invalid-id", + identity: { file: V4_B, id: "ha lf" }, + }, "section form whose <new-id> is invalid per 1.4 — a " + "whitespace-bearing segment (SPEC 6.5)", ], [ ["move", "specs/A.mdx#x", "specs/B.mdx#y"], + { + finding: "refused-id-collision", + locatedAt: { file: V4_B, window: V4_Y_WINDOW }, + }, "the ordinary cross-file collision — <new-id> `y` collides with " + "the section `y` already present in the distinct target file " + "(SPEC 6.5)", ], [ ["move", "specs/A.mdx#keep", "specs/B.mdx#nope.k"], + { + finding: "refused-missing-target-parent", + identity: { file: V4_B, id: "nope" }, + }, "section form whose target parent (`nope`, the <new-id> minus " + "its final segment) is missing from the target file (SPEC 6.5)", ], [ ["move", "specs/A.mdx#x", "specs/A.mdx#x.sub.q"], + { + finding: "refused-missing-target-parent", + identity: { file: V4_A, id: "x.sub" }, + }, "section form whose target parent (`x.sub`) lies within the " + "moved subtree, leaving no insertion point after the removal " + "(SPEC 6.5)", ], [ ["move", "specs/A.mdx", "docs/Out.mdx"], + { finding: "refused-invalid-destination", path: "docs/Out.mdx" }, "destination path belonging to no configured spec group — a " + "move never takes a node out of the workspace (SPEC 6.5)", ], [ ["move", "specs/A.mdx", "specs/dual/Out.mdx"], + { + finding: "refused-invalid-destination", + path: "specs/dual/Out.mdx", + }, "destination path belonging to a code group as well (SPEC 6.5, " + "14.14)", ], [ ["move", "specs/A.mdx", "specs/plain/Out.md"], + { + finding: "refused-invalid-destination", + path: "specs/plain/Out.md", + }, "destination path lacking the `.mdx` extension — it matches the " + "`specs/plain/**` spec glob, isolating 14.19's extension rule " + "(SPEC 6.5, 7.1, 14.19)", @@ -1491,11 +1638,12 @@ const T6_5_4 = defineProductTest({ // a destination path exists only as an operand spelling, and every // spelling that would present either is an exit-2 usage error before // any refusal is evaluated. T6.5-5 stages both. - for (const [argv, reason] of cases) { + for (const [argv, expected, reason] of cases) { await expectRefusalModifiesNothing( product, workspace, argv, + expected, `T6.5-4 (${reason})`, ); } @@ -1519,10 +1667,15 @@ const T6_5_4 = defineProductTest({ "T6.5-4 precondition arm `build` over the staged workspace", ); await workspace.file(V4_OTHER, V4_OTHER_INVALID); + // The invalid-workspace refusal reports the workspace's findings + // themselves — exactly the one 14.5 finding located in the offending + // file, no refusal reason evaluated or reported beside it (SPEC 6.5, + // 6.4, 14). await expectRefusalModifiesNothing( product, workspace, ["move", "specs/A.mdx#keep", "specs/B.mdx#kp"], + { finding: "14.5", locatedAt: { file: V4_OTHER } }, "T6.5-4 (valid-workspace precondition as T6.4-6 — the workspace " + "fails the validations of `xspec build` through an unresolved d " + "reference in specs/Other.mdx, SPEC 14.5, so the move refuses " + @@ -1782,7 +1935,7 @@ const I6_B_SOURCE = ['<S id="b">', "Bee text.", "</S>", ""].join("\n"); const T6_5_6 = defineProductTest({ id: "T6.5-6", title: - "identity terms: a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid — the new identity differs in its file part; the exact self-move (`<target-file>#<new-id>` equal to `<file>#<id>`) is refused with exit 1, modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `<new-id>` collides with an ID remaining in the target file after the removal is refused (SPEC 6.5, 1.5, 6.1)", + "identity terms: a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid — the new identity differs in its file part; the exact self-move (`<target-file>#<new-id>` equal to `<file>#<id>`) is refused with exit 1 as exactly one refused-identity-unchanged finding concerning that identity (no collision reason beside it), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `<new-id>` collides with an ID remaining in the target file after the removal is refused as exactly one refused-id-collision finding locating the remaining bearer (SPEC 6.5, 1.5, 6.1, 12.7, 14)", run: async (product) => { await withWorkspace( SPECS_ONLY_CONFIG, @@ -1831,6 +1984,13 @@ const T6_5_6 = defineProductTest({ product, workspace, ["move", "specs/B.mdx#x", "specs/B.mdx#x"], + { + // Reported alone — no collision reason beside it: the + // after-removal check collides with nothing (SPEC 6.4, 14, + // T14-7) — concerning the unchanged identity. + finding: "refused-identity-unchanged", + identity: { file: I6_B, id: "x" }, + }, "T6.5-6 (the exact self-move — the new identity equals the old " + "one, SPEC 6.5)", ); @@ -1851,6 +2011,14 @@ const T6_5_6 = defineProductTest({ product, workspace, ["move", "specs/B.mdx#x", "specs/B.mdx#b"], + { + // The collision locates every colliding bearer (SPEC 14); the + // remaining bearer `b` lives in B.mdx, whose bytes the earlier + // successful move rewrote (product-written), so the arm asserts + // the bearer's file without a byte window. + finding: "refused-id-collision", + locatedAt: { file: I6_B }, + }, "T6.5-6 (same-file move whose <new-id> `b` collides with the ID " + "`b` remaining in the target file after the removal, SPEC 6.5)", ); diff --git a/test/suite/registry/support.ts b/test/suite/registry/support.ts index 07d25eb..b2e3156 100644 --- a/test/suite/registry/support.ts +++ b/test/suite/registry/support.ts @@ -8,10 +8,15 @@ // product only via diagnosed assertion failures (H-8). import { Buffer } from "node:buffer"; -import type { Finding, GraphEdge } from "../../helpers/adapters/index.js"; +import type { + Finding, + FindingLocation, + GraphEdge, +} from "../../helpers/adapters/index.js"; import { decodeErrorDocument, decodeFindingsReport, + renderPathValue, } from "../../helpers/adapters/index.js"; import { assertExitCode, @@ -297,6 +302,101 @@ export function assertFindingLocated( } } +/** + * Assert a finding's locations include the expected file — and, when a window + * is given, a range within it (SPEC.md 14's location-cardinality rule: a + * located concern such as a colliding bearer or a cycle-participating + * reference spelling renders as a `locations` entry in its containing file). + * SOME-quantified, unlike `assertFindingLocated`: the finding may locate + * further participants elsewhere — every-participant cardinality is T14-8's + * business. + */ +export function assertFindingMentionsLocation( + finding: Finding, + expected: FindingSourceExpectation, + context: string, +): void { + const matches = (location: FindingLocation): boolean => { + if (location.file !== expected.file) return false; + const { window } = expected; + return ( + window === undefined || + (location.range.start >= window.start && location.range.end <= window.end) + ); + }; + if (finding.locations.some(matches)) return; + const rendered = finding.locations.map( + (location) => + `${renderPathValue(location.file)} [${String(location.range.start)}, ` + + `${String(location.range.end)})`, + ); + fail( + `${context}: the finding must locate the concerned construct in ` + + `${JSON.stringify(expected.file)}` + + (expected.window === undefined + ? "" + : ` within the byte window [${String(expected.window.start)}, ` + + `${String(expected.window.end)}]`) + + ` (SPEC.md 14, 12.7); got locations [${rendered.join("; ")}] ` + + `(message: ${JSON.stringify(finding.message)})`, + ); +} + +/** A concerned identity, named by its containing file and its ID (SPEC.md 1.5). */ +export interface ConcernedIdentity { + /** The workspace-relative file whose `#`-form identity names the concern. */ + readonly file: string; + /** The concerned ID — possibly one no node bears (a refused new ID). */ + readonly id: string; +} + +/** + * Assert a finding names a concerned identity (SPEC.md 14: a refusal reason's + * concerned identity is contractual identity data on the finding, 12.7): at + * least one `identities` entry identifies it — as the full 1.5 identity + * `<file>#<id>` or as the ID alone, either spelling identifying it + * unambiguously within the staged fixture (§14 requires identification, not + * wording). Further informational entries are permitted (12.7). + */ +export function assertFindingNamesIdentity( + finding: Finding, + expected: ConcernedIdentity, + context: string, +): void { + const full = `${expected.file}#${expected.id}`; + if ( + finding.identities.some((entry) => entry === full || entry === expected.id) + ) { + return; + } + fail( + `${context}: the finding must name the concerned identity ` + + `${JSON.stringify(full)} (or its ID ${JSON.stringify(expected.id)}) in ` + + `its identities (SPEC.md 14, 12.7); got ` + + `${JSON.stringify(finding.identities)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); +} + +/** + * Assert a finding concerns exactly the expected workspace-relative path via + * its 12.7 `path` member (SPEC.md 14: conditions and refusal reasons without + * an in-source location carry the file or path they concern). + */ +export function assertFindingConcernsPath( + finding: Finding, + expected: string, + context: string, +): void { + if (finding.path === expected) return; + fail( + `${context}: the finding must carry the concerned path ` + + `${JSON.stringify(expected)} as its 12.7 path member (SPEC.md 14); ` + + `got ${renderPathValue(finding.path)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); +} + function renderJson(value: unknown): string { return value === undefined ? "undefined" : JSON.stringify(value); } diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 6f9e4da..9fdcc58 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -35,12 +35,14 @@ // `query`). // - Section 16's property tests (P-*) have no SPEC.md section 16; each maps // to the passages whose invariants it asserts per its TEST-SPEC entry. -// - "14": SPEC.md 14 defines the validation conditions, so a test asserting -// a numbered condition (14.x) covers passage "14" wherever it lives. -// TEST-SPEC 14's per-condition record ("the H-7 map is the complete -// record") is carried here at H-7's passage granularity, the T7-1..T7.5-1 -// range resolved to the entries that assert a condition (T7-5 asserts -// none). +// - "14": SPEC.md 14 defines the validation conditions and the refusal +// reasons, so a test asserting a numbered condition (14.x) or a stable +// refusal code covers passage "14" wherever it lives. TEST-SPEC 14's +// per-condition record ("the H-7 map is the complete record") is carried +// here at H-7's passage granularity, the T7-1..T7.5-1 range resolved to +// the entries that assert a condition (T7-5 asserts none) and the refusal +// reasons' staging record resolved to its implemented tests (T6.4-3, +// T6.5-4, T6.5-6; T6.6-3 joins when implemented). // - Alias entries: TEST-SPEC's pointer-only tests are not separately // implemented, so their coverage rides on the implementing tests — // T12.0-10's rename/move and baseline arms ride on T6.4-4/T6.5-5 and @@ -196,7 +198,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.3-4": ["6.3", "12.0"], "T6.4-1": ["6.4"], "T6.4-2": ["6.4"], - "T6.4-3": ["6.4"], + "T6.4-3": ["6.4", "14"], "T6.4-4": ["6.4", "12.0"], "T6.4-5": ["6.4"], "T6.4-6": ["6.4"], @@ -204,9 +206,9 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.5-1": ["6.5"], "T6.5-2": ["6.5"], "T6.5-3": ["6.5"], - "T6.5-4": ["6.5"], + "T6.5-4": ["6.5", "14"], "T6.5-5": ["6.5", "12.0"], - "T6.5-6": ["6.5"], + "T6.5-6": ["6.5", "14"], "T6.7-1": ["6.7"], "T7-1": ["7", "14"], "T7-2": ["7", "14"], From 2fbe39dd06961b3d4d8a0224fa5c47e0fc329816 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 10 Aug 2026 23:57:11 +0000 Subject: [PATCH 055/226] sdg(phase-9): add T1.3-6's invalid-id-form arms and CONF-VALID's 14.17 path (FP-008+FP-009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1.3-6 gains the two invalid-form arms (repeated `id`, braced `id={"x"}`): each bearer reports 14.17 and never 14.1, masks 14.2 for its immediate child, and the grandchild's structural check still reports — exact counts {14.17: 1, 14.2: 1} with byte-window location assertions behind a valid sibling. The CONF-VALID fixture's lexer now parses attribute occurrences and braced values (well-formed MDX, never 14.20): repeated props and non-quoted-static id/tags values report 14.17 (`invalid-prop`), an afflicted `id` spells no identity and masks like a missing one. Verified: CONF-VALID conformer 12/12 in-scope tests; both violators fail exactly their certified tests and pass T1.3-6; suite T1.3-* stay red-as-diagnosed against the stub; test:self keeps only the 4 planned mid-loop reds (certification-document x3 -> FP-091; S-1 unmapped keys -> stages E/G). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 37 +++++- test/fixtures/conf-valid/product.mjs | 164 ++++++++++++++++++++++----- test/suite/registry/section-1.3.ts | 99 +++++++++++++++- 3 files changed, 268 insertions(+), 32 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index b1ced0f..fbe5766 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -270,7 +270,7 @@ A "new test T<x>" task always means, in one change: ## Stage D — §§1–9 missing arms, with paired certification-fixture reworks -- [ ] FP-008 — T1.3-6: add the invalid-`id`-form arms. [R1 #13; TEST-SPEC +- [x] FP-008 — T1.3-6: add the invalid-`id`-form arms. [R1 #13; TEST-SPEC §1.3, SPEC 14.17] `test/suite/registry/section-1.3.ts` has no 14.17 arm. Add: a repeated-`id` bearer and a braced-`id` bearer (`id={"x"}`), each reporting @@ -278,8 +278,22 @@ A "new test T<x>" task always means, in one change: grandchildren's structural checks still report. Pair with FP-009 (the CONF-VALID conformer must pass this arm once both land — certification for the family may be red between the two commits; prefer one spawn). - -- [ ] FP-009 — Rework CONF-VALID to its refreshed CERTIFICATIONS.md scope. + [Done 2026-08-10, one spawn/commit with FP-009: two arms appended to + T1.3-6's body — `<S id="one" id="two">` and `<S id={"x"}>`, each behind a + valid sibling so the bearer's location window has teeth, each holding an + immediate child `a.b` (extends no candidate parent spelling and is + multi-segment against the empty prefix, so a non-masking or + value-adopting product reports an extra 14.2) with grandchild `zzz`. Each + arm asserts exact counts {14.17: 1, 14.2: 1} — hence no 14.1, no 14.20, + no immediate-child 14.2 — the 14.17 located within the bearer, the one + 14.2 within the grandchild. Title updated; traceability already + ["1.3", "14"]. Suite file red-as-diagnosed against the stub (all six + T1.3-* fail at the FP-001-class form-exact decode: the stub still emits + `condition`-member findings, so the new arms' first decode fails the same + way); arm soundness proven through the paired conformer: CONF-VALID + 12/12.] + +- [x] FP-009 — Rework CONF-VALID to its refreshed CERTIFICATIONS.md scope. [R3 gap 4; CERTIFICATIONS.md CONF-VALID, SPEC 12.7, 14.17] `test/fixtures/conf-valid/product.mjs` (+ `bin-ctrl.mjs`/`bin-wide.mjs` deviations unchanged): (a) add the condition-17 path — today it emits @@ -290,6 +304,23 @@ A "new test T<x>" task always means, in one change: `{"condition","file","location","message"}` with no token). After: FP-001, FP-008. Verify: `npm run test:self` — CONF-VALID conformer passes all in-scope tests, its violators still fail at least one certified test. + [Done 2026-08-10, same commit as FP-008. (d) had already landed with + FP-001 (findingsDoc emits the literal 12.7 form with stable tokens); + this task added the behavioral 14.17 path: the MDX-lite lexer now counts + attribute occurrences per element and scans braced values (`name={...}`, + balanced, string-aware) as well-formed MDX — never 14.20; a repeated + prop name (one finding per name) or an `id`/`tags` value not in + quoted-static form (braced or valueless) yields a 14.17 finding at the + bearing element (`"14.17": "invalid-prop"` in CODE_TOKENS); an afflicted + `id` spells no identity — never 14.1 (only a wholly absent `id` is + 14.1), own segment/structural/duplicate checks skipped, and the existing + parent-spells-no-identity masking covers repeated/braced parents + unchanged since `id` stays null. bin-ctrl/bin-wide untouched. Verified: + `npm run test:self` — CONF-VALID conformer 12/12 in-scope tests pass + (extended T1.3-6 included), VIOL-VALID-CTRL and VIOL-VALID-WIDE each + fail exactly their three certified tests and pass T1.3-6; the 4 planned + mid-loop reds unchanged (certification-document ×3 → FP-091; S-1's 9 + unmapped keys → stages E/G).] - [ ] FP-010 — T3-1: add the grammar-boundary arm. [R1 #15; TEST-SPEC §3] `test/suite/registry/section-3.ts` — the T3-1 fixture's fence (~line 92) diff --git a/test/fixtures/conf-valid/product.mjs b/test/fixtures/conf-valid/product.mjs index 771ebaf..0cab7a6 100644 --- a/test/fixtures/conf-valid/product.mjs +++ b/test/fixtures/conf-valid/product.mjs @@ -9,16 +9,19 @@ // whose sections carry `id` and `tags` props (multi-file included); no // imports, embeddings, `d` props, code groups, `markdown`, `coverage`, // `policy`, or git. -// - `build` with the error reporting of SPEC 14 for conditions 14.1–14.4: -// file, location, condition identity, 14.2's statement of the expected -// form, exit codes per SPEC 12.0. +// - `build` with the error reporting of SPEC 14 for conditions 14.1–14.4 — +// and 14.17 as T1.3-6's invalid-form arms stage it (a repeated `id` +// attribute and a braced `id={"x"}` value) — file, location, condition +// identity with its stable code, 14.2's statement of the expected form, +// exit codes per SPEC 12.0. // - `query node` / `query nodes` (with `--tag`) reporting identity, tags, // and metadataHash — the scoped query surface; source ranges ride along in // the natural SPEC 11 row shape. // - Contracts under certification: SPEC 1.3, SPEC 1.4 with its exact -// character classes, SPEC 2.6 tag splitting, and the masking rule of -// SPEC 14.2 (condition 1 masks condition 2 for the immediate children of a -// section lacking `id`; everything else reports normally). +// character classes, SPEC 2.6 tag splitting, and the masking rules of +// SPEC 14.1/14.17 over 14.2 (a section spelling no identity — `id` +// missing, repeated, or in invalid value form — masks condition 2 for its +// immediate children; everything else reports normally). // // Key mechanisms: // - Sources are scanned by a hand-rolled MDX-lite lexer: `<S>`/`<Spec>` tags @@ -28,6 +31,15 @@ // values, and those must reach segment/tag validation (14.4) — never // surface as parse errors (14.20). That mis-staging hazard is exactly what // §CONF-VALID certifies against. +// - The lexer parses attribute occurrences per element, braced values +// (`name={...}`, balanced with string awareness) included: a repeated prop +// name, or an `id`/`tags` value not in quoted static-string form (braced +// or valueless), is condition 17 (SPEC 2.4, 2.7, 14.17) — well-formed MDX, +// so never 14.20 — and an `id` so afflicted spells no identity: never +// condition 1 (SPEC 14.1), and it masks condition 2 for its immediate +// children exactly as a missing `id` does (SPEC 14.2; T1.3-6's +// invalid-form arms). Unknown prop *names* stay ignored: sections in the +// accepted workspace shapes carry `id`/`tags` props only (Scope). // - Validation (SPEC 1.3/1.4, conditions 14.1–14.4) walks sections in // document order. The structural rule compares segment sequences — a child // ID's segments are its parent ID's segments plus exactly one more — so an @@ -638,11 +650,49 @@ function byteOffsetMapper(text, byteLength) { /** Inter-attribute whitespace inside a tag (the SPEC 1.4 class). */ const TAG_WHITESPACE = new Set(["\t", "\n", "\v", "\f", "\r", " "]); +/** + * Scan a braced attribute value (`name={...}`) starting at its `{`: balanced + * braces with string-literal awareness (quotes and backslash escapes), enough + * for any static-expression spelling such as `{"x"}`. Returns the index just + * past the closing `}`, or -1 when unterminated. The braced form is + * well-formed MDX — its content is never inspected: whatever it holds, the + * value is not in quoted static-string form (condition 17, SPEC 2.4, 2.7). + */ +function scanBracedAttributeValue(text, start) { + let depth = 0; + let i = start; + while (i < text.length) { + const c = text[i]; + if (c === '"' || c === "'") { + i += 1; + while (i < text.length && text[i] !== c) { + i += text[i] === "\\" ? 2 : 1; + } + if (i >= text.length) return -1; + i += 1; + continue; + } + if (c === "{") depth += 1; + else if (c === "}") { + depth -= 1; + if (depth === 0) return i + 1; + } + i += 1; + } + return -1; +} + /** * Parse one source file into a section tree with exact string-index ranges. * Attribute values are the raw characters between their quotes — control * bytes, line terminators, and boundary code points included — so 1.4 - * validity, never parseability, is what their content decides. Returns + * validity, never parseability, is what their content decides. Per element, + * attribute occurrences are counted and value forms classified: a repeated + * prop name or a non-quoted-static `id`/`tags` value is recorded on the node + * as an `invalidProps` entry (condition 17, SPEC 2.7 — well-formed MDX, so + * never a parse failure), an `id` so afflicted spells no identity + * (`id` null, `idMissing` false — condition 17, never condition 1), and a + * wholly absent `id` is `idMissing` (condition 1). Returns * { root, sections, failure } where `failure` is null or { at, message } * (an unparseable source, SPEC 14.20 — masking the conditions inside). */ @@ -692,7 +742,10 @@ function parseMdx(text) { const node = { isRoot: false, id: null, + idMissing: false, tagsRaw: undefined, + /** @type {{ name: string, kind: "repeated" | "value-form" }[]} */ + invalidProps: [], parent: stack.at(-1), children: [], openStart: i, @@ -702,6 +755,10 @@ function parseMdx(text) { selfClosing: false, }; let j = i + open[0].length; + /** @type {Map<string, number>} */ + const occurrences = new Map(); + let idValue; + let tagsValue; for (;;) { while (j < text.length && TAG_WHITESPACE.has(text[j])) j += 1; if (j >= text.length) { @@ -724,29 +781,63 @@ function parseMdx(text) { } const name = attr[0]; j += name.length; + /** @type {"quoted" | "braced" | "valueless"} */ + let form = "valueless"; let value; if (text[j] === "=") { j += 1; const quote = text[j]; - if (quote !== '"' && quote !== "'") { - fail20(j, "section props in this scope are quoted string literals"); + if (quote === '"' || quote === "'") { + const valueStart = j + 1; + const end = text.indexOf(quote, valueStart); + if (end === -1) { + fail20(j, "unterminated attribute value"); + return { root, sections, failure }; + } + value = text.slice(valueStart, end); + form = "quoted"; + j = end + 1; + } else if (quote === "{") { + const end = scanBracedAttributeValue(text, j); + if (end === -1) { + fail20(j, "unterminated braced attribute value"); + return { root, sections, failure }; + } + form = "braced"; + j = end; + } else { + fail20(j, "malformed attribute value in a section tag"); return { root, sections, failure }; } - const valueStart = j + 1; - const end = text.indexOf(quote, valueStart); - if (end === -1) { - fail20(j, "unterminated attribute value"); - return { root, sections, failure }; - } - value = text.slice(valueStart, end); - j = end + 1; } - if (name === "id" && value !== undefined) { - node.id = value; - } else if (name === "tags" && value !== undefined) { - node.tagsRaw = value; + const count = (occurrences.get(name) ?? 0) + 1; + occurrences.set(name, count); + // A repeated prop, defined or unknown, is condition 17 (SPEC 2.7) — + // one violation per prop name, however many further repeats. + if (count === 2) { + node.invalidProps.push({ name, kind: "repeated" }); + } + // An `id`/`tags` value not in quoted static-string form — braced or + // valueless — is condition 17 (SPEC 2.4, 2.7). Unknown prop names stay + // ignored: out of the accepted workspace shapes (§CONF-VALID Scope). + if ((name === "id" || name === "tags") && count === 1) { + if (form === "quoted") { + if (name === "id") idValue = value; + else tagsValue = value; + } else { + node.invalidProps.push({ name, kind: "value-form" }); + } } } + // Spelled identity (SPEC 11.2): exactly one quoted-static `id` spells + // one; a repeated or invalid-form `id` spells none — condition 17, never + // condition 1 (SPEC 14.1) — and only a wholly absent `id` is condition 1. + const idInvalid = node.invalidProps.some((entry) => entry.name === "id"); + node.id = idInvalid ? null : (idValue ?? null); + node.idMissing = !idInvalid && idValue === undefined; + node.tagsRaw = node.invalidProps.some((entry) => entry.name === "tags") + ? undefined + : tagsValue; node.openEnd = j; if (node.selfClosing) { node.closeStart = node.openEnd; @@ -793,7 +884,24 @@ function validateSections(rel, sections, byteOf) { start: byteOf(node.openStart), end: byteOf(node.closeEnd), }; - if (node.id === null) { + // Condition 14.17 (SPEC 2.7): a repeated prop, or an `id`/`tags` value + // not in quoted static-string form — one finding per violation, located + // at the bearing element. An `id` so afflicted spells no identity: never + // condition 1 (SPEC 14.1), its own segment/structural/duplicate checks + // cannot run, and its immediate children's structural checks are masked + // below exactly as under a missing `id` (SPEC 14.2). + for (const invalid of node.invalidProps) { + findings.push({ + condition: "14.17", + message: + invalid.kind === "repeated" + ? `invalid prop: the ${JSON.stringify(invalid.name)} prop is repeated — no prop name may occur more than once on one element (SPEC 2.7)` + : `invalid prop: the ${JSON.stringify(invalid.name)} value must be a static string literal in quoted attribute form (SPEC 2.4, 2.7)`, + file: rel, + location, + }); + } + if (node.idMissing) { // Condition 14.1 (SPEC 1.3): a non-root section without `id`. Its own // structural and segment checks need an ID and cannot run; its // immediate children's structural checks are masked below (SPEC 14.2). @@ -804,7 +912,7 @@ function validateSections(rel, sections, byteOf) { file: rel, location, }); - } else { + } else if (node.id !== null) { const segments = segmentsOf(node.id); // Condition 14.4 (SPEC 1.4), one finding per invalid segment. for (const segment of segments) { @@ -822,9 +930,12 @@ function validateSections(rel, sections, byteOf) { // exactly one segment, compared as segment sequences (an empty segment // is a 1.4 matter, not a structural one). A top-level section is // checked against the empty prefix: exactly one segment. Masking - // (SPEC 14.2): for the immediate children of a section lacking `id`, - // condition 1 masks this condition — their other conditions, and this - // condition for their own children, report normally. + // (SPEC 14.2): for the immediate children of a section spelling no + // identity — `id` missing (condition 1), repeated, or in invalid value + // form (condition 17) — the parent's condition masks this one; their + // other conditions, and this condition for their own children, report + // normally. Every such parent has `id` null here, so one test covers + // all three cases. const parent = node.parent; if (parent.isRoot || parent.id !== null) { const parentSegments = parent.isRoot ? [] : segmentsOf(parent.id); @@ -1004,6 +1115,7 @@ const CODE_TOKENS = { 14.2: "invalid-structural-id", 14.3: "duplicate-id", 14.4: "invalid-segment-or-tag", + 14.17: "invalid-prop", "14.20": "unparseable-source", }; diff --git a/test/suite/registry/section-1.3.ts b/test/suite/registry/section-1.3.ts index 7512c1a..c40c1c6 100644 --- a/test/suite/registry/section-1.3.ts +++ b/test/suite/registry/section-1.3.ts @@ -12,8 +12,9 @@ // within that entry's scope — one configured spec group of `.mdx` sources // whose sections carry `id`/`tags` props only; no imports, embeddings, `d` // props, code groups, `markdown`, `coverage`, `policy`, or git; the command -// surface is `build` (error reporting of 14.1–14.4) plus `query nodes`. -// T1.3-5's cross-file duplicate-ID arm is the multi-file case. +// surface is `build` (error reporting of 14.1–14.4, plus 14.17 as T1.3-6's +// invalid-form arms stage it) plus `query nodes`. T1.3-5's cross-file +// duplicate-ID arm is the multi-file case. // // Location assertions: fixtures are staged as prefix + offending construct + // suffix, all pure ASCII (string indices are byte offsets), and each negative @@ -377,10 +378,96 @@ const MASK_BAD_CHILD = '<S id="bad name">\nImmediate child: its own non-structural condition still reports.\n</S>'; const MASK_SOURCE = `${MASK_PREFIX}${MASK_GRANDCHILD}${MASK_MID}${MASK_BAD_CHILD}\n</S>\n`; +// T1.3-6 invalid-form arms (SPEC 14.1: a repeated `id` attribute or a value +// not in quoted static-string form is condition 17, never condition 1, and +// each case spells no identity, masking condition 2 for the immediate +// children exactly as a missing `id` does — SPEC 2.7, 14.2, 14.17). Each arm +// stages one bearer with an immediate child whose ID the structural rule +// would otherwise judge — `a.b` extends none of the bearer's spelled value +// candidates (`one`, `two`, `x`) and is multi-segment against the empty +// prefix, so a product that fails to mask, or silently adopts one of the +// spelled values as the identity, reports an extra 14.2 — and a grandchild +// whose structural check runs normally against its parent's spelled id +// `a.b`. A valid sibling precedes the bearer so the bearer's construct is a +// proper sub-range of the file and its location assertion has teeth. +interface InvalidIdFormArm { + /** Which T1.3-6 invalid-form case this is (failure diagnostics). */ + readonly name: string; + /** The bearer's opening tag plus its own text, up to the child. */ + readonly bearerOpen: string; +} + +const FORM_SIBLING = '<S id="ok">\nA valid sibling section.\n</S>\n\n'; +const FORM_CHILD_OPEN = + '<S id="a.b">\nImmediate child: its structural check is masked by the bearer spelling no identity.\n\n'; +const FORM_GRANDCHILD = + '<S id="zzz">\nGrandchild: checked against its parent id normally.\n</S>'; +const FORM_TAIL = "\n</S>\n</S>"; + +const INVALID_ID_FORM_ARMS: readonly InvalidIdFormArm[] = [ + { + name: 'a repeated-`id` section (`<S id="one" id="two">`)', + bearerOpen: + '<S id="one" id="two">\nBearer: the id attribute is repeated.\n\n', + }, + { + name: 'a braced-`id` section (`<S id={"x"}>`)', + bearerOpen: + '<S id={"x"}>\nBearer: the id value is not a quoted static string literal.\n\n', + }, +]; + +/** + * Run one invalid-form arm: the bearer reports 14.17 and no 14.1, its + * immediate child reports no 14.2, and the grandchild's structural check + * still reports (SPEC 14.1, 14.2, 14.17). + */ +async function runInvalidIdFormArm( + product: ProductBinding, + arm: InvalidIdFormArm, +): Promise<void> { + const context = `T1.3-6 \`build --json\` over ${arm.name}`; + const bearerConstruct = + arm.bearerOpen + FORM_CHILD_OPEN + FORM_GRANDCHILD + FORM_TAIL; + const findings = await findingsOf( + product, + `${FORM_SIBLING}${bearerConstruct}\n`, + context, + ); + // Exactly one 14.17 and one 14.2 in the whole report: the bearer reports + // condition 17 — never 14.1 and never 14.20, the value form is a validity + // matter, not a parse failure — the immediate child's 14.2 is masked, and + // the grandchild's structural check still reports (the one 14.2). + assertConditionCounts(findings, { "14.17": 1, "14.2": 1 }, context); + const ofCondition = (condition: string): Finding => + findings.find((finding) => finding.condition === condition)!; + assertFindingLocated( + ofCondition("14.17"), + { + file: "specs/A.mdx", + window: byteWindow(FORM_SIBLING, bearerConstruct), + }, + `${context}: the bearer's 14.17 finding (an invalid id form is condition 17, ` + + "never condition 1 — located at the bearer, not the valid sibling)", + ); + assertFindingLocated( + ofCondition("14.2"), + { + file: "specs/A.mdx", + window: byteWindow( + FORM_SIBLING + arm.bearerOpen + FORM_CHILD_OPEN, + FORM_GRANDCHILD, + ), + }, + `${context}: the grandchild's 14.2 finding (its structural check runs against ` + + "its parent's spelled id `a.b` normally)", + ); +} + const T1_3_6 = defineProductTest({ id: "T1.3-6", title: - "missing-id masking: immediate children of an id-less section report no 14.2, while their other conditions and the grandchildren's structural checks still report (SPEC 1.3, 14.1, 14.2)", + "missing-id masking: immediate children of an id-less section report no 14.2, while their other conditions and the grandchildren's structural checks still report; a repeated-`id` or braced-`id` bearer reports 14.17 — never 14.1 — masking the same way (SPEC 1.3, 2.7, 14.1, 14.2, 14.17)", run: async (product) => { const context = "T1.3-6 `build --json` over an id-less section with children"; @@ -424,6 +511,12 @@ const T1_3_6 = defineProductTest({ }, `${context}: the immediate child's own 14.4 finding (other conditions are not masked)`, ); + + // Invalid-form arms: a repeated `id` and a braced `id={"x"}` each report + // condition 17 and mask 14.2 for the immediate children the same way. + for (const arm of INVALID_ID_FORM_ARMS) { + await runInvalidIdFormArm(product, arm); + } }, }); From 080ed86ee02ede0861467a189a5d7124cf624426 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 00:21:45 +0000 Subject: [PATCH 056/226] sdg(phase-9): stage T3-1's grammar-boundary arm and give CONF-MD fence/span literalness with check and query nodes/edges (FP-010+FP-011) T3-1 now stages construct-like bytes inside two fenced code blocks and an inline code span and asserts they create no node (exact query-nodes identity set via a new scoped identity-only decoder, S-5-guarded), no edge (exact contains+depends set), no finding (build and check exit 0), and byte-exact preservation. The CONF-MD conformer gains a Markdown literal-region pre-scan feeding its lexer, a check command, and honest whole-report query nodes/edges; a probe shows the pre-rework conformer failing the staging with the diagnosed spurious 14.20. Conformer 8/8 in scope; violators certify exactly T3-3+P-2 and T3-4+P-2; test:self keeps only the 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 40 +++- test/fixtures/conf-md/product.mjs | 300 ++++++++++++++++++++++++++- test/helpers/adapters/query.ts | 30 +++ test/self/certification-fixtures.ts | 12 +- test/self/s5-output-adapters.test.ts | 37 ++++ test/suite/registry/section-3.ts | 121 ++++++++++- 6 files changed, 518 insertions(+), 22 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index fbe5766..8fa2207 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -322,14 +322,29 @@ A "new test T<x>" task always means, in one change: mid-loop reds unchanged (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G).] -- [ ] FP-010 — T3-1: add the grammar-boundary arm. [R1 #15; TEST-SPEC §3] +- [x] FP-010 — T3-1: add the grammar-boundary arm. [R1 #15; TEST-SPEC §3] `test/suite/registry/section-3.ts` — the T3-1 fixture's fence (~line 92) contains only plain text. Add fences and an inline code span containing `<S id="x">`, `<div>`, `import X from "./X.xspec"`, `{text("a")}`, and assert: no node, no edge, no finding, bytes preserved byte-for-byte. Pair with FP-011 (same red-window note as FP-008/FP-009). - -- [ ] FP-011 — Rework CONF-MD to its refreshed CERTIFICATIONS.md scope. + [Done 2026-08-11, one spawn/commit with FP-011: the ```text fence gained a + `<div>` line; a second ```md fence in gamma carries `<S id="x">`, + `import X from "./X.xspec"`, `{text("a")}`; an inline code span in gamma + carries `<S id="x">{text("a")}`. Arm asserts: `build` and `check` exit 0 + (no finding of any kind — a pattern-parsing product instead hits 14.20/ + 14.16/14.15/14.6); `query nodes` identity set exactly the six staged + requirement nodes via a new scoped identity-only decoder + (`decodeNodeIdentityRowsReport`, adapters/query.ts, S-5-guarded — CONF-MD's + scope pins no tags/coverage/range semantics); `query edges` set exactly + the 4 `contains` + 3 `depends` edges (decodeEdgesReport + + assertEdgeSetEqual); compiled bytes byte-asserted with the fence/span + lines preserved verbatim. NOT red against this repo's product: the + post-phase-10 product already parses fences/spans as literal (T3-1 stays + green, a real pass — probe below proves the arm's teeth); traceability + unchanged (T3-1 → "3"; asserts no numbered condition).] + +- [x] FP-011 — Rework CONF-MD to its refreshed CERTIFICATIONS.md scope. [R3 gap 3; CERTIFICATIONS.md CONF-MD] `test/fixtures/conf-md/product.mjs` (+ `bin-class.mjs`/`bin-cr.mjs`): (a) support `check` with exit 0 on T3-1's grammar-boundary staging (today: @@ -339,6 +354,25 @@ A "new test T<x>" task always means, in one change: content — no node, no edge, no finding (today a spurious 14.20), bytes preserved. After: FP-010. Verify: CONF-MD conformer green on in-scope tests incl. T3-1's new arm; violators still certify. + [Done 2026-08-11, same commit as FP-010. `markdownLiteralRegions` pre-scan + (CommonMark-ish subset: >=3-backtick/tilde fences with up-to-3-space + indent, backtick info strings without backticks, unclosed-to-EOF; inline + spans close at an exactly-equal-length backtick run on the same line — + single-line spans are the staged scope) feeds parseMdx, which skips whole + regions into plain content; the scan uses plain Markdown line structure, + deliberately outside the CERT-13 deviation hook so each violator keeps its + single deviation. `check` = validate + cycle surface, write nothing, exit + 0/1; `query nodes`/`query edges` answer the honest whole reports (roots as + bare paths, contains/depends/embeds, set-collapsed, byte-ordered) gated on + validity per 13.3. No P-2 interference: the generator's prose alphabet + excludes backticks and `~`. bin-class/bin-cr untouched. Verified: + CONF-MD conformer 8/8 in-scope (new arm included); VIOL-MD-CLASS fails + exactly T3-3+P-2, VIOL-MD-CR exactly T3-4+P-2, both passing T3-1; + `npm run test:self` unchanged 4 planned mid-loop reds + (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G); + teeth probe: the pre-rework conformer on the new staging exits 1 with the + diagnosed spurious 14.20 ("unclosed section tag") — the arm fails any + parse-by-pattern product at `buildOk`.] - [ ] FP-012 — T1.7-1: add the bare-identity edge-endpoint arms. [R1 #14; TEST-SPEC §1.7] diff --git a/test/fixtures/conf-md/product.mjs b/test/fixtures/conf-md/product.mjs index 93d77a7..8879a3e 100644 --- a/test/fixtures/conf-md/product.mjs +++ b/test/fixtures/conf-md/product.mjs @@ -7,15 +7,25 @@ // Scope implemented (see CERTIFICATIONS.md §CONF-MD): // - Spec-group workspaces of `.mdx` sources with imports (SPEC 2.1, valid // forms as staged), same-file and cross-file `text(...)` embeddings (2.3), -// MDX comments, mixed line terminators, and sections carrying the full prop -// set of 2.7 — `id`, `d` (local or external form, resolving as staged), -// `coverage`, and `tags`; `markdown` absent, `{ emit: false }`, and -// `{ emit: true }` with default emission next to each source (13.2); no -// code groups, no `coverage` or `policy` configuration keys, no git. +// MDX comments, mixed line terminators, fenced code blocks and inline code +// spans carrying construct-like bytes (T3-1's grammar boundary), and +// sections carrying the full prop set of 2.7 — `id`, `d` (local or +// external form, resolving as staged), `coverage`, and `tags`; `markdown` +// absent, `{ emit: false }`, and `{ emit: true }` with default emission +// next to each source (13.2); no code groups, no `coverage` or `policy` +// configuration keys, no git. // - `build` with byte-exact Markdown output per SPEC 3, and `query node` // reporting own and subtree text (SPEC 1.6, defined through the rules of 3). +// - For T3-1's grammar-boundary arm: `check` exiting 0, and +// `query nodes`/`query edges` reporting no node and no edge for the +// construct-like bytes inside fences and code spans (constructs exist only +// where the MDX parse yields them) — implemented as the honest whole +// reports: every requirement node with its identity (roots as bare paths, +// SPEC 1.5) and every `contains`/`depends`/`embeds` edge of the parsed +// workspace, so an exact-set assertion observes the absence. // - Contracts under certification: SPEC 3 in full — removal, replacement, the -// line-drop rule, line terminators — and the emission scope of 7.3. +// line-drop rule, line terminators, the parse-not-pattern grammar boundary +// — and the emission scope of 7.3. // // Key mechanisms: // - Sources are scanned by a hand-rolled MDX-lite lexer recognizing exactly @@ -31,6 +41,22 @@ // bytes (boundary code points, lone-CR terminators) that tooling silently // normalizes. That mis-staging hazard is exactly what §CONF-MD certifies // against. +// - Grammar boundary (T3-1): before the lexer runs, `markdownLiteralRegions` +// marks fenced code blocks and inline code spans; the lexer treats every +// byte inside a marked region as plain content — no import, tag, comment, +// or embedding is recognized there — so construct-like bytes inside them +// yield no node, no edge, no finding, and are preserved byte-for-byte. +// Region scanning models exactly the staged shapes (a CommonMark-ish +// subset): fences open on a line holding up to three spaces of indent then +// a run of >= 3 backticks (info string without backticks) or >= 3 tildes, +// close on a same-character run at least as long with only blanks after, +// and run to end of file when unclosed; inline code spans open at a +// backtick run outside a fence and close at the next run of exactly equal +// length on the same line (spans never cross line terminators — single-line +// spans are the staged scope). The scan uses the plain Markdown line +// structure (LF, CRLF, lone CR), deliberately independent of the CERT-13 +// deviation hook: each violator carries exactly one deviation, in the +// compile's line model alone. // - Compilation is a port of the harness oracle's line model // (test/helpers/oracles/markdown.ts, S-6-vetted; the "may share HARNESS-08's // compilation logic" of the CERT-11 plan entry) extended with node @@ -659,6 +685,108 @@ const IMPORT_RE = const EMBED_OPEN_RE = /^\{[ \t]*text[ \t]*\(/; +/** + * The Markdown literal regions of a source — fenced code blocks and inline + * code spans — as sorted, disjoint `{ start, end }` string-index ranges + * (T3-1's grammar boundary; see the module header for the modeled subset). + * The lexer treats every byte inside a region as plain content: constructs + * exist only where the MDX parse yields them, and fences/code spans are + * literal text. + * + * Line structure here is the plain Markdown one (LF, CRLF, lone CR) — never + * the deviation-switchable compile line model: each violator's single + * deviation lives in the compile hooks alone, and the committed grammar- + * boundary staging is LF-only, where all models agree. + */ +function markdownLiteralRegions(text) { + /** @type {{ start: number, end: number }[]} */ + const regions = []; + /** @type {{ start: number, end: number }[]} */ + const outsideLines = []; + /** @type {{ char: string, len: number, start: number } | null} */ + let fence = null; + let lineStart = 0; + while (lineStart < text.length) { + let lineEnd = lineStart; + while (lineEnd < text.length) { + const code = text.charCodeAt(lineEnd); + if (code === 0x000a || code === 0x000d) break; + lineEnd += 1; + } + const nextStart = + lineEnd >= text.length + ? text.length + : text.charCodeAt(lineEnd) === 0x000d && + text.charCodeAt(lineEnd + 1) === 0x000a + ? lineEnd + 2 + : lineEnd + 1; + const line = text.slice(lineStart, lineEnd); + if (fence === null) { + const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if ( + open !== null && + !(open[1][0] === "`" && open[2].includes("`")) // backtick info strings hold no backtick + ) { + fence = { char: open[1][0], len: open[1].length, start: lineStart }; + } else { + outsideLines.push({ start: lineStart, end: lineEnd }); + } + } else { + const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line); + if ( + close !== null && + close[1][0] === fence.char && + close[1].length >= fence.len + ) { + regions.push({ start: fence.start, end: lineEnd }); + fence = null; + } + } + lineStart = nextStart; + } + if (fence !== null) { + regions.push({ start: fence.start, end: text.length }); // unclosed: to EOF + } + // Inline code spans on the lines outside fences: a backtick run opens a + // span closed by the next run of exactly equal length on the same line; a + // run with no equal-length closer is ordinary text. + for (const { start, end } of outsideLines) { + let i = start; + while (i < end) { + if (text[i] !== "`") { + i += 1; + continue; + } + let runEnd = i; + while (runEnd < end && text[runEnd] === "`") runEnd += 1; + const runLength = runEnd - i; + let closeEnd = -1; + let scan = runEnd; + while (scan < end) { + if (text[scan] !== "`") { + scan += 1; + continue; + } + let scanEnd = scan; + while (scanEnd < end && text[scanEnd] === "`") scanEnd += 1; + if (scanEnd - scan === runLength) { + closeEnd = scanEnd; + break; + } + scan = scanEnd; + } + if (closeEnd === -1) { + i = runEnd; + continue; + } + regions.push({ start: i, end: closeEnd }); + i = closeEnd; + } + } + regions.sort((a, b) => a.start - b.start); + return regions; +} + /** * Parse one source file into document-ordered pieces plus the section tree. * Pieces cover the whole file: @@ -693,6 +821,11 @@ function parseMdx(text) { let failure = null; let i = 0; let contentStart = 0; + // Fenced code blocks and inline code spans are literal text (T3-1's + // grammar boundary): the lexer skips whole regions, leaving their bytes in + // the pending content run — no construct is recognized inside them. + const literalRegions = markdownLiteralRegions(text); + let regionIndex = 0; const flushContent = (end) => { if (end > contentStart) { @@ -709,6 +842,19 @@ function parseMdx(text) { }; while (i < text.length) { + while ( + regionIndex < literalRegions.length && + literalRegions[regionIndex].end <= i + ) { + regionIndex += 1; + } + if ( + regionIndex < literalRegions.length && + i >= literalRegions[regionIndex].start + ) { + i = literalRegions[regionIndex].end; // literal bytes stay plain content + continue; + } const ch = text[i]; if ( ch === "i" && @@ -1623,18 +1769,154 @@ async function commandBuild(io, cwd, argv) { return 0; } +/** + * `xspec check` (SPEC 12.2, scoped): validate without writing anything. + * Findings are the exit-1 report exactly as `build` reports them; a valid + * workspace exits 0 — T3-1's grammar-boundary arm asserts exactly that over + * fenced/code-span construct-like bytes. (This scope records no graph data, + * so there is no staleness to check beyond validation.) + */ +async function commandCheck(io, cwd, argv) { + const { flags } = parseArgs(argv, READ_FLAGS, [0, 0]); + const ws = await loadWorkspace(cwd, flags["--config"]); + if (ws.findings.length > 0) { + throw new FindingsError(ws.findings); + } + compileWorkspace(ws); // surfaces an in-scope cycle exactly as `build` does + if (flags["--json"]) { + io.stdout(canonicalJson(findingsDoc([])) + "\n"); + } + return 0; +} + +/** A requirement node's identity (SPEC 1.5): bare path for a root. */ +function nodeIdentity(rel, node) { + return node.isRoot ? rel : `${rel}#${node.id}`; +} + +/** + * `xspec query nodes` (SPEC 11.1, scoped to T3-1's grammar-boundary arm): a + * single JSON document — with or without `--json` — listing every + * requirement node of the valid workspace, files in byte order of + * workspace-relative path, the root then sections in document order per + * file. Each row carries the node's identity (SPEC 1.5) with its construct + * byte range riding along; the scoped observation is that no node arises + * from construct-like bytes inside fences or code spans. + */ +async function commandQueryNodes(io, cwd, argv) { + const { flags } = parseArgs(argv, READ_FLAGS, [0, 0]); + const ws = await loadWorkspace(cwd, flags["--config"]); + if (ws.findings.length > 0) { + throw new FindingsError(ws.findings); // SPEC 13.3: reads gate on validity + } + compileWorkspace(ws); // an in-scope cycle refuses here too, never answers + const rows = []; + for (const record of ws.files.values()) { + rows.push({ + identity: record.rel, + sourceRange: { start: 0, end: record.byteOf(record.text.length) }, + }); + for (const section of record.sections) { + rows.push({ + identity: nodeIdentity(record.rel, section), + sourceRange: { + start: record.byteOf(section.openStart), + end: record.byteOf(section.closeEnd), + }, + }); + } + } + io.stdout(canonicalJson({ nodes: rows }) + "\n"); + return 0; +} + +/** + * `xspec query edges` (SPEC 11.1, scoped to T3-1's grammar-boundary arm): a + * single JSON document — with or without `--json` — listing every edge of + * the valid workspace's graph (SPEC 5.2): `contains` from each parent to + * each child section (the file root parenting top-level sections), `depends` + * from `d` props, `embeds` from `{text(...)}` embeddings; `references` never + * (no code groups in scope). Edges of each kind form a set — duplicates + * collapse — ordered deterministically by kind, source, then target (byte + * order). The scoped observation is that no edge arises from construct-like + * bytes inside fences or code spans. + */ +async function commandQueryEdges(io, cwd, argv) { + const { flags } = parseArgs(argv, READ_FLAGS, [0, 0]); + const ws = await loadWorkspace(cwd, flags["--config"]); + if (ws.findings.length > 0) { + throw new FindingsError(ws.findings); // SPEC 13.3: reads gate on validity + } + compileWorkspace(ws); // an in-scope cycle refuses here too, never answers + const edges = []; + const seen = new Set(); + const push = (kind, from, to) => { + const key = `${kind}\u0000${from}\u0000${to}`; + if (seen.has(key)) return; // edges of each kind form a set (SPEC 5.2) + seen.add(key); + edges.push({ from, kind, to }); + }; + for (const record of ws.files.values()) { + for (const section of record.sections) { + push( + "contains", + nodeIdentity(record.rel, section.parent), + nodeIdentity(record.rel, section), + ); + } + for (const section of record.sections) { + if (section.dRaw === undefined) continue; + const refs = parseDReferences(section.dRaw); + if (refs === null) continue; // unreachable: gated as 14.8 above + for (const ref of refs) { + const resolved = resolveRef(ws.files, record, ref); + if (resolved === null) continue; // unreachable: gated as 14.5 above + push( + "depends", + nodeIdentity(record.rel, section), + nodeIdentity(resolved.rel, resolved.node), + ); + } + } + for (const piece of record.pieces) { + if (piece.kind !== "embed") continue; + if (piece.target === null) continue; // unreachable: gated as 14.6 above + push( + "embeds", + nodeIdentity(record.rel, piece.owner), + nodeIdentity(piece.target.rel, piece.target.node), + ); + } + } + const byBytes = (a, b) => + Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); + edges.sort( + (a, b) => + byBytes(a.kind, b.kind) || byBytes(a.from, b.from) || byBytes(a.to, b.to), + ); + io.stdout(canonicalJson({ edges }) + "\n"); + return 0; +} + /** * `xspec query node <node>` (SPEC 11, scoped): a single JSON document — with * or without `--json` — reporting the node's own and subtree text (SPEC 1.6, * the §CONF-MD query surface); identity and source range ride along in the * natural SPEC 11 shape. `<node>` is `path#id`, or a bare `path` for a file's - * root node (SPEC 1.5). + * root node (SPEC 1.5). The `nodes` and `edges` subcommands (above) complete + * the scoped query surface; any other subcommand is out of scope. */ async function commandQuery(io, cwd, argv) { const sub = argv[0]; + if (sub === "nodes") { + return await commandQueryNodes(io, cwd, argv.slice(1)); + } + if (sub === "edges") { + return await commandQueryEdges(io, cwd, argv.slice(1)); + } if (sub !== "node") { throw new UsageError( - `unknown query subcommand ${String(sub)} (SPEC 11, 12.0; this fixture's scope is \`query node\`, CERTIFICATIONS.md §CONF-MD)`, + `unknown query subcommand ${String(sub)} (SPEC 11, 12.0; this fixture's scope is \`query node\`/\`nodes\`/\`edges\`, CERTIFICATIONS.md §CONF-MD)`, ); } const { flags, positionals } = parseArgs(argv.slice(1), READ_FLAGS, [1, 1]); @@ -1704,6 +1986,8 @@ async function dispatchCommand(io, cwd, argv) { switch (command) { case "build": return await commandBuild(io, cwd, rest); + case "check": + return await commandCheck(io, cwd, rest); case "query": return await commandQuery(io, cwd, rest); default: diff --git a/test/helpers/adapters/query.ts b/test/helpers/adapters/query.ts index a8ee6ad..344fc4c 100644 --- a/test/helpers/adapters/query.ts +++ b/test/helpers/adapters/query.ts @@ -276,6 +276,36 @@ export function decodeNodeSummaryRowsReport( ); } +/** + * `query nodes` rows decoded to identities alone (T3-1's grammar-boundary + * arm). That arm is in CERTIFICATIONS.md §CONF-MD's scope, which pins the + * fixture product's `query nodes` surface to the no-node observation for + * construct-like bytes inside fences and code spans: demanding tags, + * coverage, or source-range semantics would reject a document the scope + * permits (the row counterpart of {@link decodeNodeTextSummary}'s scoping). + * The `nodes` key and per-row `identity` are the `query nodes` shape's own + * (see the ASSUMED SHAPE above); other row members are ignored, not + * validated. Absent or malformed identities still fail loudly (H-3). + */ +export function decodeNodeIdentityRowsReport( + doc: unknown, + context?: string, +): string[] { + const site = rootSite("query nodes (identity-only rows)", context); + const obj = expectObject(doc, site); + const rowsSite = at(site, "nodes"); + return expectArray(requiredKey(obj, "nodes", site), rowsSite).map( + (element, index) => { + const rowSite = at(rowsSite, index); + const row = expectObject(element, rowSite); + return expectNonEmptyString( + requiredKey(row, "identity", rowSite), + at(rowSite, "identity"), + ); + }, + ); +} + function decodeNodeRow(value: unknown, site: DecodeSite): NodeRow { const obj = expectObject(value, site); return { diff --git a/test/self/certification-fixtures.ts b/test/self/certification-fixtures.ts index 3781859..b8afbb0 100644 --- a/test/self/certification-fixtures.ts +++ b/test/self/certification-fixtures.ts @@ -201,10 +201,14 @@ export const CERTIFICATION_FIXTURES: readonly CertificationConformer[] = [ ), // CONF-MD (§CONF-MD): Markdown compilation — `build` with byte-exact // Markdown output per SPEC 3 (removal, replacement, the line-drop rule, - // line terminators), `query node` reporting own and subtree text (SPEC - // 1.6), and the emission scope of SPEC 7.3, over spec-group workspaces - // with imports, embeddings, comments, mixed line terminators, and the - // full 2.7 prop set. + // line terminators, the parse-not-pattern grammar boundary), `query node` + // reporting own and subtree text (SPEC 1.6), `check` exiting 0 and + // `query nodes`/`query edges` reporting no node and no edge for + // construct-like bytes inside fences and code spans (T3-1's + // grammar-boundary arm), and the emission scope of SPEC 7.3, over + // spec-group workspaces with imports, embeddings, comments, mixed line + // terminators, fenced code blocks and inline code spans carrying + // construct-like bytes, and the full 2.7 prop set. conformer( "CONF-MD", "conf-md/bin.mjs", diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index 3b5e252..793186e 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -42,6 +42,7 @@ import { decodeNextReport, decodeNodeMetadataSummary, decodeNodeReport, + decodeNodeIdentityRowsReport, decodeNodeRowsReport, decodeNodeSummary, decodeNodeSummaryRowsReport, @@ -647,6 +648,42 @@ const DECODERS: readonly DecoderSpec[] = [ }, ], }, + { + name: "query nodes (identity-only rows)", + decode: decodeNodeIdentityRowsReport, + good: GOOD_ROWS, + verify: (decoded: ReturnType<typeof decodeNodeIdentityRowsReport>) => { + expect(decoded).toEqual(["specs/A.mdx#login", "specs/A.mdx"]); + }, + alsoGood: [ + { + // The point of this decoder: rows carrying only an identity decode — + // no tags, coverage, or source range is demanded of a fixture product + // scoped to the no-node observation (CERTIFICATIONS.md §CONF-MD; + // T3-1's grammar-boundary arm). + label: "rows carrying only identities", + doc: { nodes: [{ identity: "specs/A.mdx#alpha" }] }, + verify: ( + decoded: ReturnType<typeof decodeNodeIdentityRowsReport>, + ): void => { + expect(decoded).toEqual(["specs/A.mdx#alpha"]); + }, + }, + ], + bad: [ + { label: "missing nodes list", doc: {} }, + { label: "nodes not an array", doc: { nodes: {} } }, + { label: "row not an object", doc: { nodes: [7] } }, + { + label: "row missing identity", + doc: omit(GOOD_ROWS, "nodes", 0, "identity"), + }, + { + label: "row with an empty identity", + doc: put(GOOD_ROWS, "", "nodes", 1, "identity"), + }, + ], + }, { name: "query nodes/subtree/ancestors", decode: decodeNodeRowsReport, diff --git a/test/suite/registry/section-3.ts b/test/suite/registry/section-3.ts index d883c0b..23e40e8 100644 --- a/test/suite/registry/section-3.ts +++ b/test/suite/registry/section-3.ts @@ -18,13 +18,26 @@ // - A lone U+000D appears only in T3-4's fixtures; every other fixture uses // LF terminators exclusively (CRLF appears only in T3-4). // - T3-1 stages sections carrying the full prop set of 2.7 — `id`, `d` -// (external and local forms, resolving as staged), `coverage`, and `tags`. +// (external and local forms, resolving as staged), `coverage`, and `tags`, +// plus the grammar-boundary staging: its fenced code blocks and inline +// code span carry construct-like bytes that must stay literal content +// (constructs exist only where the MDX parse yields them). import { assertFileBytes, fail } from "../../helpers/assertions.js"; +import { + decodeEdgesReport, + decodeNodeIdentityRowsReport, +} from "../../helpers/adapters/index.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { defineProductTest } from "../../helpers/registry.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import { buildOk } from "./support.js"; +import { + assertEdgeSetEqual, + assertSameJson, + buildOk, + expectExit, + runJson, +} from "./support.js"; // Minimal declarative configuration (SPEC 7): one spec group. The spec-group // glob matches only `.mdx` files, so no glob matches a Markdown emit @@ -73,8 +86,19 @@ const REMOVALS_BASE_COMPILED = "Base text.\n"; // `<Spec>` opening/closing tags carrying the full prop set of 2.7 (`id`, `d` // in external and local forms, `coverage`, `tags`), and MDX comments (own-line // and in-line) — amid content that must survive byte-for-byte: a heading, a -// table, a code fence, trailing spaces, and blank lines. Dependencies are +// table, code fences, trailing spaces, and blank lines. Dependencies are // acyclic: alpha → {BASE.base, beta}, beta → BASE.base (SPEC 5.3). +// +// Grammar boundary (SPEC 2.7, 14.16, 14.20): constructs exist only where the +// MDX parse yields them — fenced code blocks and inline code spans are +// literal text — so the fences and an inline code span carry construct-like +// bytes: `<div>` (else 14.16), `<S id="x">` (else a node, or 14.20 for the +// unmatched tag), `import X from "./X.xspec"` (else 14.15: no X.mdx exists), +// and `{text("a")}` (else an edge, or 14.6: no id "a" exists). A product +// recognizing constructs by textual pattern rather than by parse trips at +// least one of the arm's assertions: a finding (build/check no longer exit +// 0), a phantom node or edge, or bytes missing from the compiled output. +const SPAN_LINE = 'Inline code span: `<S id="x">{text("a")}` stays literal.'; const REMOVALS_SOURCE = [ 'import BASE from "./BASE.xspec"', // removed; line drops (SPEC 3) "", @@ -90,6 +114,7 @@ const REMOVALS_SOURCE = [ "{/* an own-line comment, removed with its line */}", "```text", "fenced content with spaces ", + "<div>", // literal inside the fence: no 14.16, preserved (grammar boundary) "```", "", "Middle {/* in-line comment, removed in place */}word.", @@ -101,13 +126,20 @@ const REMOVALS_SOURCE = [ "", '<S id="gamma">Gamma keeps this line.', // tag deleted in place, content kept "More gamma prose.", + SPAN_LINE, // construct-like bytes inside an inline code span, literal + "```md", // a second fence: construct-like bytes on every line, literal + '<S id="x">', + 'import X from "./X.xspec"', + '{text("a")}', + "```", "</S>", "", ].join("\n"); // Hand-derived (SPEC 3): each construct is deleted exactly, in place; every // line left empty purely by removals drops with its terminator; every other -// line — author whitespace included — is preserved byte-for-byte. +// line — author whitespace and the fence/code-span bytes included — is +// preserved byte-for-byte. const REMOVALS_COMPILED = [ "", "# Removals fixture", @@ -120,6 +152,7 @@ const REMOVALS_COMPILED = [ "", "```text", "fenced content with spaces ", + "<div>", "```", "", "Middle word.", // "Middle " + "word." after exact in-place comment deletion @@ -128,13 +161,45 @@ const REMOVALS_COMPILED = [ "", "Gamma keeps this line.", // the in-place-deleted opening tag's line, kept "More gamma prose.", + SPAN_LINE, + "```md", + '<S id="x">', + 'import X from "./X.xspec"', + '{text("a")}', + "```", "", ].join("\n"); +// The exact requirement-node universe of the T3-1 workspace (SPEC 1.5: roots +// as bare paths, sections as `path#id`), sorted bytewise. `<S id="x">` inside +// a fence or code span contributes nothing — exact-set equality proves it. +const REMOVALS_NODE_IDENTITIES = [ + "specs/A.mdx", + "specs/A.mdx#alpha", + "specs/A.mdx#beta", + "specs/A.mdx#gamma", + "specs/BASE.mdx", + "specs/BASE.mdx#base", +] as const; + +// The exact edge universe (SPEC 5.2): `contains` from each file root to its +// top-level sections, `depends` from the staged `d` props. No `embeds` edge +// exists — the only `text(...)`-like bytes sit inside a fence and a code +// span — and the fenced import contributes no edge and no import resolution. +const REMOVALS_EDGES = [ + { from: "specs/A.mdx", to: "specs/A.mdx#alpha", kind: "contains" }, + { from: "specs/A.mdx", to: "specs/A.mdx#beta", kind: "contains" }, + { from: "specs/A.mdx", to: "specs/A.mdx#gamma", kind: "contains" }, + { from: "specs/BASE.mdx", to: "specs/BASE.mdx#base", kind: "contains" }, + { from: "specs/A.mdx#alpha", to: "specs/BASE.mdx#base", kind: "depends" }, + { from: "specs/A.mdx#alpha", to: "specs/A.mdx#beta", kind: "depends" }, + { from: "specs/A.mdx#beta", to: "specs/BASE.mdx#base", kind: "depends" }, +] as const; + const T3_1 = defineProductTest({ id: "T3-1", title: - "imports, `<S>`/`<Spec>` opening and closing tags with all their props (`id`, `d`, `coverage`, `tags`), and MDX comments are removed by exact textual deletion in place; tables, code fences, trailing spaces, and blank lines are preserved byte-for-byte (SPEC 3, 2.7)", + "imports, `<S>`/`<Spec>` opening and closing tags with all their props (`id`, `d`, `coverage`, `tags`), and MDX comments are removed by exact textual deletion in place; tables, code fences, trailing spaces, and blank lines are preserved byte-for-byte; grammar boundary: construct-like bytes inside fenced code blocks and an inline code span are literal text — no node, no edge, no finding, preserved byte-for-byte (SPEC 3, 2.7, 14.16, 14.20)", run: async (product) => { const workspace = await TestWorkspace.create({ files: { @@ -147,18 +212,60 @@ const T3_1 = defineProductTest({ await buildOk( product, workspace, - "T3-1 `build` with `markdown: { emit: true }`", + "T3-1 `build` with `markdown: { emit: true }` — the fenced and " + + "code-span construct-like bytes trigger no finding of any kind " + + "(SPEC 3, 2.7: constructs exist only where the MDX parse yields them)", ); await assertFileBytes( workspace.path("specs/A.md"), REMOVALS_COMPILED, - "T3-1 emitted specs/A.md — constructs deleted exactly in place, everything else preserved byte-for-byte (SPEC 3)", + "T3-1 emitted specs/A.md — constructs deleted exactly in place, everything else (fence and code-span bytes included) preserved byte-for-byte (SPEC 3)", ); await assertFileBytes( workspace.path("specs/BASE.md"), REMOVALS_BASE_COMPILED, "T3-1 emitted specs/BASE.md (SPEC 3)", ); + + // Grammar boundary: `check` reports no finding of any kind either. + await expectExit( + product, + workspace, + ["check"], + 0, + "T3-1 `check` — the fenced and code-span construct-like bytes " + + "trigger no finding of any kind (SPEC 2.7, 14.16, 14.20)", + ); + + // The construct-like bytes create no node: the reported requirement + // nodes are exactly the staged roots and sections (SPEC 1.5, 11.1) — + // in particular no node spells the fenced/code-span `id` "x". + const nodesLabel = + "T3-1 `query nodes` — fenced and code-span construct-like bytes create no node (SPEC 2.7, 11.1)"; + const identities = decodeNodeIdentityRowsReport( + await runJson(product, workspace, ["query", "nodes"], nodesLabel), + nodesLabel, + ); + assertSameJson( + [...identities].sort(), + [...REMOVALS_NODE_IDENTITIES], + `${nodesLabel}: the reported node-identity set`, + ); + + // …and no edge: the reported edges are exactly the staged `contains` + // and `depends` universe (SPEC 5.2) — the fenced import and the + // fenced/code-span `{text("a")}` bytes contribute none. + const edgesLabel = + "T3-1 `query edges` — fenced and code-span construct-like bytes create no edge (SPEC 2.7, 5.2)"; + const edges = decodeEdgesReport( + await runJson(product, workspace, ["query", "edges"], edgesLabel), + edgesLabel, + ); + assertEdgeSetEqual( + edges, + REMOVALS_EDGES, + `${edgesLabel}: the reported edge set`, + ); } finally { await workspace.dispose(); } From a95a25cc0dd64cc76ec400750ed702067d99f13d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 00:35:59 +0000 Subject: [PATCH 057/226] sdg(phase-9): assert bare-identity edge endpoints across edges rows, reachable paths, and query-node edge lists (FP-012) T1.7-1 gains SPEC 1.7's second half: everywhere a graph node appears as an edge endpoint it is a bare identity, requirement node and code location alike. A new workspace stages references/depends/embeds edges entering the graph at code locations; the arms pin exact edge sets and the witness path through the H-3 decoders, and a new adapter-layer walk (S-5-guarded) rejects any range datum accompanying an endpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 25 +++- test/helpers/adapters/query.ts | 80 ++++++++++- test/self/s5-output-adapters.test.ts | 67 +++++++++ test/suite/registry/section-1.6-1.7.ts | 192 ++++++++++++++++++++++++- 4 files changed, 360 insertions(+), 4 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 8fa2207..c5b019b 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -374,13 +374,36 @@ A "new test T<x>" task always means, in one change: diagnosed spurious 14.20 ("unclosed section tag") — the arm fails any parse-by-pattern product at `buildOk`.] -- [ ] FP-012 — T1.7-1: add the bare-identity edge-endpoint arms. [R1 #14; +- [x] FP-012 — T1.7-1: add the bare-identity edge-endpoint arms. [R1 #14; TEST-SPEC §1.7] `test/suite/registry/section-1.6-1.7.ts` asserts only `query node`/`show` ranges; no code location, no `reachable` anywhere in the file. Add arms asserting endpoints-as-identities-alone on (a) `edges` rows, (b) a `reachable` witness path, and (c) `query node`'s incoming/outgoing edge lists — each traversing a code location. + [Done 2026-08-11: T1.7-1 gains a second workspace (spec+code config; + `src/app.ts#entry` --references--> `alpha` --depends--> `omega`, + `src/app.ts#writer` --embeds--> `omega`) with the three arms: (a) + unfiltered `query edges` pinned to the exact five-edge set, (b) + `reachable --from src/app.ts#entry --to specs/E.mdx#omega` pinned to the + witness path [entry, alpha, omega], (c) `query node` on alpha and omega + pinned to exact incoming/outgoing lists — endpoint values via the H-3 + decoders (identity strings), the no-range-datum half via a new + adapter-layer 1.7 walk in `test/helpers/adapters/query.ts` + (`assertBareEdgeEndpoints` over whole edges/reachable documents; + `assertNodeEdgeListsBare` scoped to the node report's edge lists so the + node's own contractual sourceRange stays out of scope). The walk rejects + any member named `range`/`sourceRange` and any {start,end}-bearing object, + detector adapter-owned like the ASSUMED SHAPE. S-5 guards added + (accepts-bare + rejects-with-path synthetic cases, missing-edges-member + rejection). NOT red against this repo's product: it already reports bare + endpoints — probe confirmed the exact edge set and witness path above, so + the pass is genuine; teeth live in the S-5 rejections plus exact-value + pinning. Traceability unchanged (T1.7-1 → ["1.7"]; no numbered condition). + `npm run test:self`: unchanged 4 planned mid-loop reds + (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G); + suite file 1.6-1.7 shows only T1.6-5's pre-existing FP-001-class product + gap (form-exact findings decode), T1.7-1 green.] - [ ] FP-013 — T4.3-2: add the zero-argument and two-argument `text(...)` arms. [R1 #16; TEST-SPEC §4.3, SPEC 14.8] diff --git a/test/helpers/adapters/query.ts b/test/helpers/adapters/query.ts index 344fc4c..58bd5d7 100644 --- a/test/helpers/adapters/query.ts +++ b/test/helpers/adapters/query.ts @@ -1,6 +1,7 @@ // H-3 output adapters — query-surface commands: `query node`, `show`, // `query nodes`/`subtree`/`ancestors`, `query edges`, `query reachable`, and -// `ids` (TEST-SPEC §11, T12.3-1, T12.4-1). +// `ids` (TEST-SPEC §11, T12.3-1, T12.4-1) — plus the SPEC 1.7 bare +// edge-endpoint walk (T1.7-1). // // This module is shape-aware and value-blind: it maps the product's concrete // JSON output onto the information model in model.ts, failing loudly @@ -381,6 +382,83 @@ export function decodeReachableReport( return { reachable, path }; } +// --- the bare edge-endpoint walk (T1.7-1) ---------------------------------- + +/** + * Walk a query document and assert SPEC.md 1.7's bare-endpoint contract: a + * code location is presented with its source range in exactly two outputs — + * occurrence records (5.7, 11.3) and review payloads (10.7) — so everywhere + * a graph node appears as an edge endpoint (`edges` rows, a `reachable` + * witness path, `query node`'s incoming and outgoing edge lists) the + * reported endpoint is an identity alone, no range datum accompanying it, + * requirement node and code location alike. The walk fails loudly on any + * source-range-shaped datum anywhere in the given subtree: an object + * carrying a member named `range` or `sourceRange`, or carrying both `start` + * and `end` members — the range spellings of SPEC.md 1.7/12.7 and of the + * ASSUMED SHAPE above. Like the ASSUMED SHAPE, the detection is shape-aware + * and adapter-owned: if the real product legitimately spells ranges + * differently, adjust the detection with it — never to admit a range datum + * beside an edge endpoint. Callers pass whole `query edges` and + * `query reachable` documents; node reports go through + * {@link assertNodeEdgeListsBare}, which scopes the walk to the report's + * `edges` member (the queried node's own source range is contract, T11-1). + */ +export function assertBareEdgeEndpoints(doc: unknown, context?: string): void { + walkForRangeData(doc, rootSite("1.7 bare edge-endpoint walk", context)); +} + +/** + * {@link assertBareEdgeEndpoints} scoped to a `query node`/`show` report's + * incoming and outgoing edge lists: the report's own `sourceRange` (the + * queried node's, SPEC.md 11/12.4) lies outside the walk, while a range + * datum anywhere within the edge lists — beside an endpoint, or as an + * endpoint's member — fails loudly. + */ +export function assertNodeEdgeListsBare(doc: unknown, context?: string): void { + const site = rootSite( + "1.7 bare edge-endpoint walk (query node/show edge lists)", + context, + ); + const obj = expectObject(doc, site); + walkForRangeData(requiredKey(obj, "edges", site), at(site, "edges")); +} + +function walkForRangeData(value: unknown, site: DecodeSite): void { + if (Array.isArray(value)) { + value.forEach((element, index) => { + walkForRangeData(element, at(site, index)); + }); + return; + } + if (typeof value !== "object" || value === null) return; + const obj = value as Record<string, unknown>; + for (const name of ["range", "sourceRange"]) { + if (Object.hasOwn(obj, name)) { + decodeFail( + at(site, name), + "no range datum on an edge surface — everywhere a graph node " + + "appears as an edge endpoint it is a bare identity, requirement " + + "node and code location alike; a code location's source range is " + + "presented in exactly two outputs, occurrence records and review " + + "payloads (SPEC 1.7)", + obj[name], + ); + } + } + if (Object.hasOwn(obj, "start") && Object.hasOwn(obj, "end")) { + decodeFail( + site, + 'no range-shaped {"start", "end"} datum on an edge surface — edge ' + + "endpoints are bare identities with no range datum accompanying " + + "them (SPEC 1.7)", + value, + ); + } + for (const [key, member] of Object.entries(obj)) { + walkForRangeData(member, at(site, key)); + } +} + /** `ids` (T12.3-1): files in byte order, IDs within a file in document order. */ export function decodeIdsReport(doc: unknown, context?: string): IdsReport { const site = rootSite("ids", context); diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index 793186e..ee83984 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -23,7 +23,9 @@ import type { RunResult } from "../helpers/subprocess.js"; import type { Finding } from "../helpers/adapters/index.js"; import { ITEM_STATUSES, + assertBareEdgeEndpoints, assertJsonKeysByteSorted, + assertNodeEdgeListsBare, assertReportMentions, assertUnavailabilityMarkerForms, classifyIgnoredReasons, @@ -1841,6 +1843,71 @@ test("S-5: the marker walk rejects near-markers anywhere in the tree, naming the ); }); +// --- the bare edge-endpoint walk (T1.7-1) ------------------------------------ + +test("S-5: the bare edge-endpoint walk accepts edge surfaces carrying identities alone", () => { + assertBareEdgeEndpoints(GOOD_EDGES, "edges document"); + assertBareEdgeEndpoints(GOOD_REACHABLE, "reachable document"); + assertBareEdgeEndpoints({ reachable: false }, "unreachable document"); + // A node report's own sourceRange is contract (SPEC 11, T11-1): the walk + // scoped to the edge lists tolerates it while guarding the lists. + assertNodeEdgeListsBare(GOOD_NODE, "node report"); +}); + +test("S-5: the bare edge-endpoint walk rejects range data beside endpoints, naming the path", () => { + const rowRange = expectDiagnosed("edge row carrying a range member", () => + assertBareEdgeEndpoints( + put(GOOD_EDGES, { start: 0, end: 4 }, "edges", 0, "range"), + "row range", + ), + ); + expect(rowRange.message).toContain("$.edges[0].range"); + const endpointObject = expectDiagnosed( + "endpoint as an identity-plus-range object", + () => + assertBareEdgeEndpoints( + put( + GOOD_EDGES, + { + identity: "src/login.ts#handler", + sourceRange: { start: 0, end: 4 }, + }, + "edges", + 0, + "from", + ), + "endpoint object", + ), + ); + expect(endpointObject.message).toContain("$.edges[0].from.sourceRange"); + const pathEntry = expectDiagnosed( + "witness-path entry carrying start/end data", + () => + assertBareEdgeEndpoints( + put( + GOOD_REACHABLE, + { node: "specs/A.mdx#login", start: 0, end: 4 }, + "path", + 0, + ), + "path entry", + ), + ); + expect(pathEntry.message).toContain("$.path[0]"); + const nodeEdgeRange = expectDiagnosed("node edge list carrying a range", () => + assertNodeEdgeListsBare( + put(GOOD_NODE, { start: 1, end: 2 }, "edges", "incoming", 0, "range"), + "node edge range", + ), + ); + expect(nodeEdgeRange.message).toContain("$.edges.incoming[0].range"); + // The scoped walk still fails loudly when the edge lists are absent + // entirely (S-5: reject, never default). + expectDiagnosed("node report missing its edges member", () => + assertNodeEdgeListsBare(omit(GOOD_NODE, "edges"), "missing edges"), + ); +}); + // --- human-report matcher ---------------------------------------------------- function syntheticResult(stdout: string, stderr = ""): RunResult { diff --git a/test/suite/registry/section-1.6-1.7.ts b/test/suite/registry/section-1.6-1.7.ts index b7adf49..6c1a0b2 100644 --- a/test/suite/registry/section-1.6-1.7.ts +++ b/test/suite/registry/section-1.6-1.7.ts @@ -14,11 +14,19 @@ // arrangement described in section-1.1-1.2.ts. import { Buffer } from "node:buffer"; -import type { Finding, NodeReport } from "../../helpers/adapters/index.js"; +import type { + Finding, + GraphEdge, + NodeReport, +} from "../../helpers/adapters/index.js"; import { + assertBareEdgeEndpoints, + assertNodeEdgeListsBare, + decodeEdgesReport, decodeImpactReport, decodeNextReport, decodeNodeReport, + decodeReachableReport, decodeSessionStatusReport, } from "../../helpers/adapters/index.js"; import { @@ -39,6 +47,7 @@ import { import { TestWorkspace } from "../../helpers/workspace.js"; import { assertConditionCounts, + assertEdgeSetEqual, assertSameJson, buildFindings, buildOk, @@ -1092,10 +1101,55 @@ const EMPTY_RANGE = { }; const ROOT_RANGE = { start: 0, end: utf8Length(RANGE_SOURCE) }; +// Bare-endpoint fixture (SPEC 1.7's second half): a dependency chain entering +// the graph at code locations — `src/app.ts#entry` --references--> `alpha` +// --depends--> `omega`, and `src/app.ts#writer` --embeds--> `omega` — so +// `edges` rows, a `reachable` witness path, and `query node`'s incoming and +// outgoing edge lists each traverse a code location. +const ENDPOINT_SPEC_SOURCE = [ + '<S id="alpha" d={"omega"}>', + "Alpha text.", + "</S>", + "", + '<S id="omega">', + "Omega text.", + "</S>", + "", +].join("\n"); + +const ENDPOINT_CODE_SOURCE = [ + 'import SPEC, { text } from "../specs/E.xspec";', + "", + "export function entry(): void {", + " SPEC.alpha;", + "}", + "", + "export function writer(): string {", + " return text(SPEC.omega);", + "}", + "", +].join("\n"); + +const ENDPOINT_FILE = "specs/E.mdx"; +const ALPHA_ID = "specs/E.mdx#alpha"; +const OMEGA_ID = "specs/E.mdx#omega"; +const ENTRY_LOCATION = "src/app.ts#entry"; +const WRITER_LOCATION = "src/app.ts#writer"; + +// The endpoint workspace's complete edge set (SPEC 5.2), endpoints spelled as +// the bare identities 1.7 demands on every edge surface. +const ENDPOINT_ALL_EDGES: readonly GraphEdge[] = [ + { from: ENDPOINT_FILE, to: ALPHA_ID, kind: "contains" }, + { from: ENDPOINT_FILE, to: OMEGA_ID, kind: "contains" }, + { from: ALPHA_ID, to: OMEGA_ID, kind: "depends" }, + { from: ENTRY_LOCATION, to: ALPHA_ID, kind: "references" }, + { from: WRITER_LOCATION, to: OMEGA_ID, kind: "embeds" }, +]; + const T1_7_1 = defineProductTest({ id: "T1.7-1", title: - "source ranges are zero-based byte offsets, start-inclusive end-exclusive: opening through closing tag for a section, exactly the self-closing tag, the entire file for the root — equal via `query node` and `show` (SPEC 1.7, 11, 12.4)", + "source ranges are zero-based byte offsets, start-inclusive end-exclusive: opening through closing tag for a section, exactly the self-closing tag, the entire file for the root — equal via `query node` and `show`; everywhere a graph node appears as an edge endpoint — `edges` rows, a `reachable` witness path, `query node` edge lists, each traversing a code location — it is a bare identity, no range datum accompanying it (SPEC 1.7, 11, 12.4)", run: async (product) => { const workspace = await TestWorkspace.create({ files: { @@ -1161,6 +1215,140 @@ const T1_7_1 = defineProductTest({ } finally { await workspace.dispose(); } + + // Bare edge endpoints (SPEC 1.7): a code location is presented with its + // source range in exactly two outputs — occurrence records and review + // payloads — and everywhere a graph node appears as an edge endpoint it + // is a bare identity, requirement node and code location alike. Each arm + // asserts the endpoint values through the H-3 decoders (endpoints decode + // as identity strings and equal the staged identities) and the absence of + // any accompanying range datum through the adapter layer's 1.7 walk over + // the raw document. + const endpoints = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + "specs/E.mdx": ENDPOINT_SPEC_SOURCE, + "src/app.ts": ENDPOINT_CODE_SOURCE, + }, + }); + try { + await buildOk( + product, + endpoints, + "T1.7-1 `build` of the bare-endpoint fixture", + ); + + // (a) `edges` rows: the complete edge set, the code-location-sourced + // `references` and `embeds` rows included — endpoints identities alone. + const edgesContext = "T1.7-1 `query edges` (bare endpoints)"; + const edgesDoc = await runJson( + product, + endpoints, + ["query", "edges"], + edgesContext, + ); + assertEdgeSetEqual( + decodeEdgesReport(edgesDoc, edgesContext), + ENDPOINT_ALL_EDGES, + `${edgesContext}: the complete edge set, with the code locations ` + + `entering the \`references\` and \`embeds\` rows as identities ` + + `(SPEC 1.7, 5.2, 11)`, + ); + assertBareEdgeEndpoints(edgesDoc, edgesContext); + + // (b) a `reachable` witness path traversing the code location: the + // path is a node-identity sequence, identities alone. + const reachableContext = `T1.7-1 \`query reachable --from ${ENTRY_LOCATION} --to ${OMEGA_ID}\``; + const reachableDoc = await runJson( + product, + endpoints, + ["query", "reachable", "--from", ENTRY_LOCATION, "--to", OMEGA_ID], + reachableContext, + ); + const reachable = decodeReachableReport(reachableDoc, reachableContext); + if (!reachable.reachable) { + fail( + `${reachableContext}: a dependency path entry -> alpha -> omega ` + + `exists (\`references\`, then \`depends\`, both in the default ` + + `kinds), so the report must state one does (SPEC 11)`, + ); + } + assertSameJson( + reachable.path, + [ENTRY_LOCATION, ALPHA_ID, OMEGA_ID], + `${reachableContext}: the shortest witness path traverses the code ` + + `location as a bare identity in a node-identity sequence (SPEC 1.7, 11)`, + ); + assertBareEdgeEndpoints(reachableDoc, reachableContext); + + // (c) `query node`'s incoming and outgoing edge lists, both traversing + // code locations (`references` into alpha, `embeds` into omega). The + // node report's own `sourceRange` is contract (SPEC 11, T11-1); the + // walk is scoped to the edge lists, where no range datum may appear. + const alphaContext = `T1.7-1 \`query node ${ALPHA_ID}\` (bare edge-list endpoints)`; + const alphaDoc = await runJson( + product, + endpoints, + ["query", "node", ALPHA_ID], + alphaContext, + ); + const alpha = decodeNodeReport(alphaDoc, alphaContext); + if (alpha.identity !== ALPHA_ID) { + fail( + `${alphaContext}: expected the report to be about ${JSON.stringify(ALPHA_ID)} ` + + `(SPEC 1.5), got identity ${JSON.stringify(alpha.identity)}`, + ); + } + assertEdgeSetEqual( + alpha.incomingEdges, + [ + { from: ENDPOINT_FILE, to: ALPHA_ID, kind: "contains" }, + { from: ENTRY_LOCATION, to: ALPHA_ID, kind: "references" }, + ], + `${alphaContext}: incoming edges — the referencing code location ` + + `enters as a bare identity (SPEC 1.7, 11)`, + ); + assertEdgeSetEqual( + alpha.outgoingEdges, + [{ from: ALPHA_ID, to: OMEGA_ID, kind: "depends" }], + `${alphaContext}: outgoing edges (SPEC 11)`, + ); + assertNodeEdgeListsBare(alphaDoc, alphaContext); + + const omegaContext = `T1.7-1 \`query node ${OMEGA_ID}\` (bare edge-list endpoints)`; + const omegaDoc = await runJson( + product, + endpoints, + ["query", "node", OMEGA_ID], + omegaContext, + ); + const omega = decodeNodeReport(omegaDoc, omegaContext); + if (omega.identity !== OMEGA_ID) { + fail( + `${omegaContext}: expected the report to be about ${JSON.stringify(OMEGA_ID)} ` + + `(SPEC 1.5), got identity ${JSON.stringify(omega.identity)}`, + ); + } + assertEdgeSetEqual( + omega.incomingEdges, + [ + { from: ENDPOINT_FILE, to: OMEGA_ID, kind: "contains" }, + { from: ALPHA_ID, to: OMEGA_ID, kind: "depends" }, + { from: WRITER_LOCATION, to: OMEGA_ID, kind: "embeds" }, + ], + `${omegaContext}: incoming edges — the embedding code location ` + + `enters as a bare identity (SPEC 1.7, 11)`, + ); + assertEdgeSetEqual( + omega.outgoingEdges, + [], + `${omegaContext}: outgoing edges — none; no edge kind targets a ` + + `code location and omega declares no dependency (SPEC 5.2)`, + ); + assertNodeEdgeListsBare(omegaDoc, omegaContext); + } finally { + await endpoints.dispose(); + } }, }); From 5fe7e8ccfca160e117fe873ff543e47ace23f0fb Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 00:44:55 +0000 Subject: [PATCH 058/226] sdg(phase-9): add T4.3-2's zero- and two-argument text(...) arity arms (FP-013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two arms appended to T4_3_2_ARMS: text(); and text(SPEC.a, SPEC.a.b); — the two-argument arm passes two static resolvable node chains per T2.4-3's MDX precedent so arity is each arm's sole defect, asserting exactly one 14.8 at the call. Probe-verified against the built product (valid one-argument control exits 0; each arm one 14.8 in-window); suite test stays red-as-diagnosed at the FP-001-class form-exact decode gap. test:self unchanged: 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 21 +++++++++++++- test/suite/registry/section-4.3-4.4.ts | 40 +++++++++++++++++++++----- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index c5b019b..b1a7c03 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -405,11 +405,30 @@ A "new test T<x>" task always means, in one change: suite file 1.6-1.7 shows only T1.6-5's pre-existing FP-001-class product gap (form-exact findings decode), T1.7-1 green.] -- [ ] FP-013 — T4.3-2: add the zero-argument and two-argument `text(...)` +- [x] FP-013 — T4.3-2: add the zero-argument and two-argument `text(...)` arms. [R1 #16; TEST-SPEC §4.3, SPEC 14.8] `test/suite/registry/section-4.3-4.4.ts` (~line 294) has only the string/computed-index/optional-chaining arms. Add `text()` and `text("a","b")` calls in a TypeScript file, each → 14.8. + [Done 2026-08-11: two arms appended to T4_3_2_ARMS — `text();` and + `text(SPEC.a, SPEC.a.b);` — the two-argument arm passing two static + resolvable node chains per T2.4-3's MDX precedent (the language's valid + argument form; this task summary's literal `text("a","b")` would stage + two further string-form-in-TS 14.8 defects, making the finding count + ambiguous and letting an arity-tolerant product pass), so arity is each + arm's sole defect: exactly one 14.8 asserted at the call within the + offending statement's byte window, per TEST-SPEC §4.3 ("14.8's arity + clause holds in either language"). Title extended to the full TEST-SPEC + entry; traceability already ["4.3","14"], unchanged. Verified by direct + probe against the built product: a valid one-argument control of the + identical shape builds exit 0 (arity is the sole delta); each arm exits 1 + with exactly one 14.8 located within the computed window, no 14.18 + beside; the suite test itself stays red-as-diagnosed at the pre-existing + FP-001-class form-exact decode (the product still emits condition-member + findings — same first-arm failure as before, new arms unreached until + that product gap closes). `npm run test:self`: unchanged 4 planned + mid-loop reds (certification-document ×3 → FP-091; S-1's 9 unmapped keys + → stages E/G).] - [ ] FP-014 — T4.5-2: add the upstream (cross-file) impact arm. [R1 #17; TEST-SPEC §4.5] diff --git a/test/suite/registry/section-4.3-4.4.ts b/test/suite/registry/section-4.3-4.4.ts index 35c1d8e..21f48c5 100644 --- a/test/suite/registry/section-4.3-4.4.ts +++ b/test/suite/registry/section-4.3-4.4.ts @@ -20,12 +20,22 @@ // equals the hand-derived expansions, SPEC 1.6/3). "From the calling code // location": the calls sit at file top level, so the location is the file // (SPEC 4.6), asserted as the file's complete outgoing edge set. -// - T4.3-2 arms stage exactly one defect each — the string/dynamic form. The -// dynamic arms' chains would resolve to existing nodes if read statically -// (`SPEC[key]` with key = "a"; `SPEC.a?.b` with `a.b` staged), so a product -// cannot legitimately reclassify them as unresolved references (14.7): the -// sole present condition is 14.8 (SPEC 2.4, 4.3, 4.5). Each finding must -// fall within the offending statement's byte window (support.ts +// - T4.3-2 arms stage exactly one defect each — the string/dynamic/arity +// form. The dynamic arms' chains would resolve to existing nodes if read +// statically (`SPEC[key]` with key = "a"; `SPEC.a?.b` with `a.b` staged), +// so a product cannot legitimately reclassify them as unresolved +// references (14.7): the sole present condition is 14.8 (SPEC 2.4, 4.3, +// 4.5). The arity arms mirror T2.4-3's MDX staging in this language's +// valid argument form: the two-argument call passes two static, resolvable +// node chains (`SPEC.a`, `SPEC.a.b` — never strings, each themselves 14.8 +// in TypeScript, which would stage further defects), and the zero-argument +// call has nothing to resolve, so in each the arity is the sole defect — +// exactly one 14.8, at the call — and a product tolerating the arity +// builds clean, failing the exit-1 expectation. A node argument of the +// wrong-arity call is still a direct argument to its own module's `text` +// export, so no 14.18 is present (SPEC 14.18's entry sanctions direct +// `text` arguments; 2.4 assigns any other arity to 14.8). Each finding +// must fall within the offending statement's byte window (support.ts // byteWindow). // - T4.4-1 asserts the condition's three facets (SPEC 14.11: reported by // `build`/`check`, "additionally a TypeScript type error and a runtime @@ -320,12 +330,28 @@ const T4_3_2_ARMS: readonly InvalidTextArgumentArm[] = [ ], offending: "text(SPEC.a?.b);", }, + { + name: "a zero-argument `text()` call (arity, SPEC 2.4)", + lines: ['import { text } from "../specs/A.xspec";', "", "text();"], + offending: "text();", + }, + { + name: + "a two-argument `text(...)` call (arity, SPEC 2.4) — both arguments " + + "static resolvable node chains, so the arity is the sole defect", + lines: [ + 'import SPEC, { text } from "../specs/A.xspec";', + "", + "text(SPEC.a, SPEC.a.b);", + ], + offending: "text(SPEC.a, SPEC.a.b);", + }, ]; const T4_3_2 = defineProductTest({ id: "T4.3-2", title: - "a string argument to `text` in a TypeScript file fails with 14.8, and so does a dynamic node-form argument there — a computed index by variable and an optional-chaining chain, each as the `text` argument (SPEC 4.3, 2.4, 4.5)", + "a string argument to `text` in a TypeScript file fails with 14.8; so does a dynamic node-form argument there — a computed index by variable and an optional-chaining chain, each as the `text` argument — and so do a zero-argument and a two-argument `text(...)` call: 14.8's arity clause holds in either language, the MDX arms being T2.4-3 (SPEC 4.3, 2.4, 4.5)", run: async (product) => { for (const arm of T4_3_2_ARMS) { const at = arm.lines.indexOf(arm.offending); From a53c3432f3249712dc91250e3f17c362d1517cec Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 00:58:37 +0000 Subject: [PATCH 059/226] sdg(phase-9): add T4.5-2's upstream cross-file impact arm (FP-014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root-marker test gains its second workspace: MAIN.mdx bears a root-sourced {text(...)} embeds edge into OTHER.mdx (top-level, outside any section — the T8-5 shape), plus an untouched local section as control. An edit to the embedded target's text changes only the root's effectiveHash, so the marker's location is transitively — never directly — impacted, with the forced witness path root -> upstream, and the complete category table pins that no node of the marker's document is changed (MAIN root exactly upstream-changed). Reuses section-5.6's impactAgainst/assertRequirementCategories and section-9's assertImpactedCode. Genuine pass against this repo's product (probe-validated); suite and self-test states unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 32 +++++- test/suite/registry/section-4.5.ts | 170 ++++++++++++++++++++++++++++- 2 files changed, 199 insertions(+), 3 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index b1a7c03..d59946d 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -430,13 +430,43 @@ A "new test T<x>" task always means, in one change: mid-loop reds (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G).] -- [ ] FP-014 — T4.5-2: add the upstream (cross-file) impact arm. [R1 #17; +- [x] FP-014 — T4.5-2: add the upstream (cross-file) impact arm. [R1 #17; TEST-SPEC §4.5] `test/suite/registry/section-4.5.ts` stages only the same-document subtreeHash edit (direct impact). Add: the marker's document bears a root-sourced `{text(...)}` edge into another file; an edit THERE changing only that root's effectiveHash leaves the location transitively impacted while no node of the marker's own document is `changed`. + [Done 2026-08-11: T4.5-2 gains a second workspace — MAIN.mdx holds + `import OTHER from "./OTHER.xspec"` plus a top-level `{text(OTHER. + upstream)}` outside any section (the T8-5 shape: root-sourced `embeds` + edge, SPEC 2.3/1.2) and an untouched `local` section as in-document + control; the same root-marker `src/app.ts`. Staging integrity pins both + dependency edges as complete per-kind sets, so the marker's `references` + edge is the location's only impact edge and the root-sourced `embeds` + edge the root's only dependency edge. After baseline commit + edit of + the embedded target's text: `assertImpactedCode` (section-9's helper) — + direct EMPTY, transitive exactly [src/app.ts | references → + specs/MAIN.mdx | path specs/MAIN.mdx > specs/OTHER.mdx#upstream], the + witness forced (the `contains` step to `local` has unchanged + effectiveHash); `assertRequirementCategories` (section-5.6's helper, the + section-15 reuse precedent) with the complete table — `upstream` + `changed`, OTHER root `descendant-changed` exact [upstream], MAIN root + exactly `upstream-changed` exact [upstream], `local` uncategorized — + which realizes "no node of the marker's document `changed`" (a product + folding embedded text into the embedder's own content would flip MAIN + root to `changed`/the location to direct). NOT red against this repo's + product: impact semantics predate the patch — a direct probe of the + staging against the built product returned byte-for-byte the expected + edges, code groups, and category table, so the pass is genuine; teeth + live in the exact-value pinning over the forced fixture. Traceability + unchanged (T4.5-2 → ["4.5"]; the entry's 8/9.2 parentheticals are + context with home coverage at T8-*/T9.2-*, no numbered condition + asserted). Verified: section-4.5 unchanged 2 failed / 5 passed (T4.5-3/ + T4.5-5 red at the pre-existing FP-001-class form-exact product gap; + T4.5-2 green including the new arm); `npm run test:self` unchanged 4 + planned mid-loop reds (certification-document ×3 → FP-091; S-1's 9 + unmapped keys → stages E/G).] - [ ] FP-015 — T6.4-1: assert the rename command's own report — the applied mapping. [R1 #18; TEST-SPEC §6.4, SPEC 12.0, H-3] diff --git a/test/suite/registry/section-4.5.ts b/test/suite/registry/section-4.5.ts index 4a8d46f..a1e70d3 100644 --- a/test/suite/registry/section-4.5.ts +++ b/test/suite/registry/section-4.5.ts @@ -24,7 +24,15 @@ // impacted-code witness edge can only be the root-targeted `references` // edge, and with exactly one changed leaf there is exactly one qualifying // witness path (SPEC 9.3) — root → print → print.hello, every step -// `contains`, every node's subtreeHash changed. +// `contains`, every node's subtreeHash changed. Its upstream arm stages a +// second workspace with exactly two dependency edges, each forced into its +// role: the marker's `references` edge is the location's only impact edge, +// and the root-sourced `embeds` edge is the root's only dependency edge — +// after the cross-file edit the one qualifying witness path is root → +// embedded target (the `contains` step to the untouched `local` child does +// not qualify: its effectiveHash is unchanged), and the edge target's +// subtreeHash staying unchanged is what the direct-group emptiness +// asserts (SPEC 5.5, 9.2, 9.3). // - T4.5-3 arms stage exactly one defect each — the non-static form. Every // arm's chain would resolve to an existing node if read statically // (`SPEC[key]` with key = "a"; the `a.b` chains with `a.b` staged), so a @@ -67,6 +75,8 @@ import { runConsumer, } from "../../helpers/tooling.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import { assertRequirementCategories, impactAgainst } from "./section-5.6.js"; +import { assertImpactedCode } from "./section-9.js"; import { assertConditionCounts, assertEdgeSetEqual, @@ -410,6 +420,32 @@ const T4_5_2_EDITED_SPEC_SOURCE = PRINT_SPEC_SOURCE.replace( "Prints a much louder greeting.", ); +// Upstream arm (SPEC 4.5 "in the document or upstream of it"): the marker's +// document bears a root-sourced dependency edge into another file — a +// top-level `{text(...)}` outside any section records an `embeds` edge from +// the implicit root (SPEC 2.3, 1.2; the T8-5 shape). The `local` section is +// the untouched in-document control: it must stay uncategorized, and its +// `contains` step must not enter the witness path. +const T4_5_2_MAIN_ROOT = "specs/MAIN.mdx"; +const T4_5_2_LOCAL = "specs/MAIN.mdx#local"; +const T4_5_2_OTHER_ROOT = "specs/OTHER.mdx"; +const T4_5_2_UPSTREAM = "specs/OTHER.mdx#upstream"; + +const T4_5_2_UPSTREAM_MAIN_SOURCE = [ + 'import OTHER from "./OTHER.xspec"', + "", + "{text(OTHER.upstream)}", + "", + '<S id="local">', + "Local behavior.", + "</S>", + "", +].join("\n"); + +/** The other file: the embedded target's own text is the edited run. */ +const upstreamOtherSource = (text: string): string => + ['<S id="upstream">', text, "</S>", ""].join("\n"); + /** Resolve one named profile from a coverage report, diagnosed (H-8). */ function profileByName( report: CoverageReport, @@ -438,7 +474,7 @@ function renderImpactedCodeEntry(entry: ImpactedCodeEntry): string { const T4_5_2 = defineProductTest({ id: "T4.5-2", title: - "a bare reference to the default export records a `references` edge to the root; it grants no coverage in any profile — root-targeted edges never extend a covering path — but the code location is directly impacted by a text edit changing the root's subtreeHash, witnessed by the root-targeted edge (SPEC 4.5, 8, 9.2, 9.3)", + "a bare reference to the default export records a `references` edge to the root; it grants no coverage in any profile — root-targeted edges never extend a covering path — but the code location is directly impacted by a text edit changing the root's subtreeHash, witnessed by the root-targeted edge; upstream arm: with the marker's document bearing a root-sourced `{text(...)}` embeds edge into another file, an edit there changing only the root's effectiveHash leaves the location transitively impacted, no node of the marker's document `changed` (SPEC 4.5, 2.3, 5.5, 8, 9.2, 9.3)", run: async (product) => { const workspace = await TestWorkspace.create({ files: { @@ -546,6 +582,136 @@ const T4_5_2 = defineProductTest({ } finally { await workspace.dispose(); } + + // Upstream arm (SPEC 4.5: impacted by any change "in the document or + // upstream of it"): a second workspace whose MAIN.mdx bears a + // root-sourced `{text(...)}` embeds edge into OTHER.mdx. An edit THERE + // changes only the root's effectiveHash — an embedded target's text is + // no part of the embedder's own content (SPEC 5.5), so the root's + // ownHash and subtreeHash stay unchanged — leaving the marker's location + // transitively impacted (9.2) while no node of the marker's document is + // `changed`. + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { + "specs/MAIN.mdx": T4_5_2_UPSTREAM_MAIN_SOURCE, + "specs/OTHER.mdx": upstreamOtherSource("Upstream behavior, v1."), + "src/app.ts": T4_5_2_APP_SOURCE, + }, + async (workspace) => { + await workspace.gitInit(); + await buildOk( + product, + workspace, + "T4.5-2 `build` over the upstream-arm workspace", + ); + + // Staging integrity: the two dependency edges, each the complete set + // of its kind. The top-level `{text(...)}` outside any section is + // root-sourced (SPEC 2.3, 1.2), and the root marker's `references` + // edge is the location's only impact edge (SPEC 4.5, 9.2). + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "embeds", "T4.5-2"), + [ + { + from: T4_5_2_MAIN_ROOT, + to: T4_5_2_UPSTREAM, + kind: "embeds", + }, + ], + "T4.5-2 upstream arm: the marker's document bears the root-sourced " + + "`embeds` edge into the other file — a top-level `{text(...)}` " + + "outside any section embeds from the implicit root (SPEC 2.3, " + + "1.2)", + ); + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "references", "T4.5-2"), + [ + { + from: "src/app.ts", + to: T4_5_2_MAIN_ROOT, + kind: "references", + }, + ], + "T4.5-2 upstream arm: the root marker's `references` edge to the " + + "root is the location's only impact edge (SPEC 4.5, 1.5, 9.2)", + ); + + // Commit the baseline, then edit the embedded target's text in the + // OTHER file — the marker's document is not touched. + const baseline = await workspace.gitCommitAll("baseline"); + await workspace.file( + "specs/OTHER.mdx", + upstreamOtherSource("Upstream behavior, v2."), + ); + const label = + "T4.5-2 `impact --base <baseline> --json` after the upstream edit"; + const impact = await impactAgainst(product, workspace, baseline, label); + + // Transitively impacted, not directly (SPEC 9.2): the edit changes + // the root's effectiveHash through the root-sourced dependency pair + // (SPEC 5.5) but not its subtreeHash. The witness path is forced + // (SPEC 9.3): from the edge's target, the `contains` step to `local` + // does not qualify (its effectiveHash is unchanged), so the one + // qualifying path is the dependency step to the edited target — + // root → upstream, every node's effectiveHash changed, ending at + // the `changed` node. + assertImpactedCode( + impact, + { + direct: [], + transitive: [ + { + location: "src/app.ts", + edge: { + from: "src/app.ts", + to: T4_5_2_MAIN_ROOT, + kind: "references", + }, + path: [T4_5_2_MAIN_ROOT, T4_5_2_UPSTREAM], + }, + ], + }, + `${label}: the cross-file edit changes only the root's ` + + "effectiveHash, so the marker's location is transitively — " + + "never directly — impacted, witnessed by the root-targeted " + + "`references` edge and the dependency step to the edited " + + "target (SPEC 4.5, 5.5, 9.2, 9.3)", + ); + + // No node of the marker's document is `changed` (SPEC 5.5, 5.6): the + // complete category table. The edited target is `changed`; its file + // root `descendant-changed`; the marker document's root is exactly + // `upstream-changed` — its ownHash and subtreeHash unchanged, so + // never `changed` or `descendant-changed` — and the untouched + // `local` section receives no category at all. + assertRequirementCategories( + impact, + [ + { + identity: T4_5_2_UPSTREAM, + categories: [{ category: "changed", within: [T4_5_2_UPSTREAM] }], + }, + { + identity: T4_5_2_OTHER_ROOT, + categories: [ + { category: "descendant-changed", exact: [T4_5_2_UPSTREAM] }, + ], + }, + { + identity: T4_5_2_MAIN_ROOT, + categories: [ + { category: "upstream-changed", exact: [T4_5_2_UPSTREAM] }, + ], + }, + { identity: T4_5_2_LOCAL, categories: [] }, + ], + `${label}: editing an embedded target surfaces at the embedding ` + + "document as `upstream-changed`, never `changed` — no node of " + + "the marker's document is `changed` (SPEC 5.5, 5.6, 9.1)", + ); + }, + ); }, }); From c810d67c3ff0f699beb96767dd9db80953e86eb2 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 01:14:06 +0000 Subject: [PATCH 060/226] sdg(phase-9): assert rename's applied-mapping report through a new H-3 adapter (FP-015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T6.4-1 now runs the rename with --json and decodes the command's own report: a successful rename's report is the applied mapping — every identity pair the operation journaled, the information of the preview's mapping (SPEC 6.4, 6.6), carried in JSON per 12.0. The report shape is unpinned, so the decode lives in a new adjustable H-3 adapter (test/helpers/adapters/operations.ts, assumed shape mirroring the preview's pinned 12.7 mapping member), fail-loud on a mapping-less report; the pairs are asserted as a complete set (support.ts assertAppliedMapping) against the fixture's pinned mapping. S-5 gains the adapter's positive control and wrong-shape rejections. T6.4-1 turns falsely-green -> red-as-diagnosed at the mapping decode (the product reports {"findings":[]} on successful rename); the other section-6.4 failures are the unchanged pre-existing exit-2/refusal-form gaps. test:self keeps exactly the 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 30 ++++++++++++- test/helpers/adapters/index.ts | 3 ++ test/helpers/adapters/model.ts | 13 ++++++ test/helpers/adapters/operations.ts | 67 ++++++++++++++++++++++++++++ test/self/s5-output-adapters.test.ts | 64 ++++++++++++++++++++++++++ test/suite/registry/section-6.4.ts | 46 ++++++++++++++++--- test/suite/registry/support.ts | 19 ++++++++ 7 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 test/helpers/adapters/operations.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index d59946d..e082af7 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -468,12 +468,40 @@ A "new test T<x>" task always means, in one change: planned mid-loop reds (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G).] -- [ ] FP-015 — T6.4-1: assert the rename command's own report — the applied +- [x] FP-015 — T6.4-1: assert the rename command's own report — the applied mapping. [R1 #18; TEST-SPEC §6.4, SPEC 12.0, H-3] `test/suite/registry/section-6.4.ts` asserts journal append and rewrites only; rename stdout is never decoded. Add: decode rename's stdout (JSON per 12.0, H-3 adapter) and assert the report is the applied mapping — every journaled identity pair. + [Done 2026-08-11: T6.4-1's rename now runs with `--json` (runJson: exit 0, + single JSON document as the entire stdout, 12.0) and its report decodes + through a new adjustable H-3 adapter, `test/helpers/adapters/operations.ts` + `decodeAppliedMappingReport` (ASSUMED SHAPE `{"mapping":[{"from","to"}…]}`, + mirroring the preview's pinned 12.7 `mapping` member — the report shape + itself is unpinned, adapter adjustable to shape never values, fail-loud on + a mapping-less report; model type `AppliedMappingPair`). The test asserts + the pairs as a complete set via support.ts `assertAppliedMapping` (order is + shape, not information): exactly {core.mid→core.hub, core.mid.leaf→ + core.hub.leaf} in full 1.5 identity form — SPEC 6.4 pins the journaled + mapping as the renamed ID plus prefix-replaced descendants, nothing else, + and the fixture's post-rename identity assertions already pin those two as + the only new identities. S-5 gains the adapter's DECODERS entry (positive + control incl. an ignored `findings` sibling member; rejections: absent/ + null/non-array mapping — the current product's `{"findings":[]}` shape is + the labeled absent case — pair missing from/to, empty identity, non-object + pair). Verified: T6.4-1 turned falsely-green → red-as-diagnosed exactly at + the applied-mapping decode ("required key \"mapping\" … absent" — probe: + the product reports `{"findings":[]}` on successful rename and rejects + `--preview` as unknown, the whole 6.6 surface being patch-new); section-6.4 + went 3 failed/4 passed → 4 failed/3 passed, the other three being the + pre-existing FP-002/FP-007-class gaps (T6.4-3/-4/-6), downstream arms + unreached until the product reports the mapping. Typecheck clean; S-5 + 66/66; `npm run test:self` unchanged 4 planned mid-loop reds + (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G); + T6.4-1 in no certification scope. Traceability unchanged (["6.4"]; 12.0/ + 6.6 are carriage context with home coverage elsewhere). FP-017 (move's + applied-mapping report) reuses this adapter and helper.] - [ ] FP-016 — T6.4-4: add the wrong-kind and parse-local old-ID-existence arms. [R1 #19; TEST-SPEC §6.4] diff --git a/test/helpers/adapters/index.ts b/test/helpers/adapters/index.ts index 717b5e2..fc28509 100644 --- a/test/helpers/adapters/index.ts +++ b/test/helpers/adapters/index.ts @@ -11,6 +11,8 @@ // adjustable to a product's shape // query.ts query node/show, rows, edges, reachable, ids // reports.ts coverage, impact +// operations.ts the applied-mapping report of a successful +// rename/move (6.4, 6.5) // review.ts review list/status/next/show/export // human.ts robust required-information matching on human reports // session-staging.ts T10.1-4 corruption transformations (shape-aware, @@ -29,6 +31,7 @@ export * from "./decode.js"; export * from "./forms.js"; export * from "./query.js"; export * from "./reports.js"; +export * from "./operations.js"; export * from "./review.js"; export * from "./human.js"; export * from "./session-staging.js"; diff --git a/test/helpers/adapters/model.ts b/test/helpers/adapters/model.ts index dfccc74..426633c 100644 --- a/test/helpers/adapters/model.ts +++ b/test/helpers/adapters/model.ts @@ -357,6 +357,19 @@ export interface ImpactedCodeEntry { readonly path: readonly string[]; } +/** + * One identity pair of a successful `rename`/`move`'s applied-mapping report + * (SPEC.md 6.4, 6.5; T6.4-1, T6.5-1): the operation's report is the complete + * identity mapping it journaled — the information of the preview's `mapping` + * (6.6) — carried in JSON per 12.0. The successful operation's report shape + * is unpinned (H-3), so pair order is a shape choice: tests assert the pairs + * as a complete set (adapters/operations.ts). + */ +export interface AppliedMappingPair { + readonly from: string; + readonly to: string; +} + /** `review list` (T10.7-5): sessions in byte order of name. */ export interface SessionListReport { readonly sessions: readonly SessionListEntry[]; diff --git a/test/helpers/adapters/operations.ts b/test/helpers/adapters/operations.ts new file mode 100644 index 0000000..a40e7a0 --- /dev/null +++ b/test/helpers/adapters/operations.ts @@ -0,0 +1,67 @@ +// H-3 output adapters — the applied-mapping report of a successful +// `xspec rename` / `xspec move` (SPEC.md 6.4, 6.5, 12.0; T6.4-1, T6.5-1). +// +// Shape-aware, value-blind, fail-loud (H-3) — see query.ts for the layer's +// contract. SPEC.md 6.4 fixes the report's information — "the complete +// identity mapping the operation journaled — the information of the preview's +// `mapping` (6.6), carried in JSON per 12.0" — while leaving the successful +// operation's report SHAPE unpinned (H-3 lists the applied-mapping reports of +// 6.4/6.5 among the adapter-decoded surfaces). Adjust the ASSUMED SHAPE below +// when the real product's output shape legitimately differs; never adjust +// values, and never default: a report carrying no recognizable mapping fails +// loudly — a product that journals a mapping but reports none withholds +// required information (SPEC 6.4). +// +// NOT here: the refused operation's report (a form-exact 12.7 findings-only +// report) and the `--preview` document (the form-exact 12.7 preview form) — +// both are pinned surfaces belonging to forms.ts's discipline, never to an +// adjustable adapter. +// +// ASSUMED SHAPE: +// rename/move (success) → +// { "mapping": [ { "from": identity, "to": identity } ... ], ... } +// (the preview's pinned `mapping` member encoding, 12.7 — the natural +// spelling for the same information; members beside "mapping" are ignored) + +import type { AppliedMappingPair } from "./model.js"; +import { + at, + expectArray, + expectNonEmptyString, + expectObject, + requiredKey, + rootSite, +} from "./decode.js"; + +/** + * Decode a successful `rename`/`move` invocation's JSON report (T6.4-1, + * T6.5-1) into its applied mapping: every identity pair the operation + * journaled. Pair order is not part of the information model (the report + * shape is unpinned) — callers assert the pairs as a complete set + * (`assertAppliedMapping`, suite support). Missing or malformed mapping + * information rejects loudly (H-3), never defaulting to an empty mapping. + */ +export function decodeAppliedMappingReport( + doc: unknown, + context?: string, +): AppliedMappingPair[] { + const site = rootSite("applied-mapping", context); + const obj = expectObject(doc, site); + const mappingSite = at(site, "mapping"); + return expectArray(requiredKey(obj, "mapping", site), mappingSite).map( + (element, index) => { + const pairSite = at(mappingSite, index); + const pair = expectObject(element, pairSite); + return { + from: expectNonEmptyString( + requiredKey(pair, "from", pairSite), + at(pairSite, "from"), + ), + to: expectNonEmptyString( + requiredKey(pair, "to", pairSite), + at(pairSite, "to"), + ), + }; + }, + ); +} diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index ee83984..1927031 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -31,6 +31,7 @@ import { classifyIgnoredReasons, compareFindings, conditionMention, + decodeAppliedMappingReport, decodeCoverageReport, decodeDatum, decodeEdgesReport, @@ -324,6 +325,18 @@ const GOOD_IMPACT = { }, }; +// A successful rename/move's applied-mapping report (SPEC 6.4/6.5; T6.4-1, +// T6.5-1). The report shape is unpinned (H-3): the assumed shape mirrors the +// preview's pinned `mapping` member, and members beside it (here `findings`) +// are passed over by the decoder. +const GOOD_APPLIED_MAPPING = { + findings: [], + mapping: [ + { from: "specs/A.mdx#login", to: "specs/A.mdx#signin" }, + { from: "specs/A.mdx#login.form", to: "specs/A.mdx#signin.form" }, + ], +}; + const GOOD_SESSION_LIST = { sessions: [ { @@ -1383,6 +1396,57 @@ const DECODERS: readonly DecoderSpec[] = [ }, ], }, + { + name: "applied mapping (rename/move success report)", + decode: decodeAppliedMappingReport, + good: GOOD_APPLIED_MAPPING, + verify: (decoded: ReturnType<typeof decodeAppliedMappingReport>) => { + expect(decoded).toEqual([ + { from: "specs/A.mdx#login", to: "specs/A.mdx#signin" }, + { from: "specs/A.mdx#login.form", to: "specs/A.mdx#signin.form" }, + ]); + }, + bad: [ + { + label: + "mapping absent (a findings-only shape reports no applied mapping)", + doc: omit(GOOD_APPLIED_MAPPING, "mapping"), + }, + { + label: "null mapping (required information, never defaulted)", + doc: put(GOOD_APPLIED_MAPPING, null, "mapping"), + }, + { + label: "mapping not an array", + doc: put( + GOOD_APPLIED_MAPPING, + { "specs/A.mdx#login": "specs/A.mdx#signin" }, + "mapping", + ), + }, + { + label: "pair missing from", + doc: omit(GOOD_APPLIED_MAPPING, "mapping", 0, "from"), + }, + { + label: "pair missing to", + doc: omit(GOOD_APPLIED_MAPPING, "mapping", 1, "to"), + }, + { + label: "pair with empty identity", + doc: put(GOOD_APPLIED_MAPPING, "", "mapping", 0, "to"), + }, + { + label: "pair not an object", + doc: put( + GOOD_APPLIED_MAPPING, + "specs/A.mdx#login -> specs/A.mdx#signin", + "mapping", + 1, + ), + }, + ], + }, { name: "review list", decode: decodeSessionListReport, diff --git a/test/suite/registry/section-6.4.ts b/test/suite/registry/section-6.4.ts index e70a28c..596195e 100644 --- a/test/suite/registry/section-6.4.ts +++ b/test/suite/registry/section-6.4.ts @@ -34,6 +34,16 @@ // operation, SPEC 6.1) exists as a plain file holding exactly one // line-oriented entry after the one rename; entry content stays opaque // (H-4). +// - T6.4-1 "the command's own report is the applied mapping": the rename runs +// with `--json` (12.0: a single JSON document as the entire stdout) and its +// report is decoded through the H-3 applied-mapping adapter +// (adapters/operations.ts — the successful operation's report shape is +// unpinned, so the adapter owns the shape) and asserted to carry exactly +// the identity pairs the operation journaled, as a complete set: journal +// entry content being opaque (H-4), the expected pairs are the fixture's — +// the renamed node and its descendant, which SPEC 6.4 pins as the complete +// mapping (the renamed ID plus the prefix-replaced descendants, nothing +// else). Pair order is unasserted (shape, not information). // - T6.4-2 stages every *affected* reference part in dot access or // double-quoted form, so each expected byte is pinned whichever way 6.4's // preserve-then-default rule is read; single-quoted spellings appear only @@ -76,6 +86,7 @@ import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import type { GraphEdge, NodeReport } from "../../helpers/adapters/index.js"; import { + decodeAppliedMappingReport, decodeEdgesReport, decodeFindingsReport, decodeNodeReport, @@ -94,6 +105,7 @@ import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import type { ConcernedIdentity, FindingSourceExpectation } from "./support.js"; import { + assertAppliedMapping, assertConditionCounts, assertEdgeSetEqual, assertFindingLocated, @@ -548,7 +560,7 @@ async function assertDependencyEdges( const T6_4_1 = defineProductTest({ id: "T6.4-1", title: - "rewrites: renaming a mid-tree ID rewrites its `id`, all descendant `id`s by prefix replacement, local string references, external chain references in other files, `text(...)` targets in MDX and TS, and TS markers — the workspace builds, all edges retarget (query-asserted), and the mapping is appended to the journal (SPEC 6.4, 6.1)", + "rewrites: renaming a mid-tree ID rewrites its `id`, all descendant `id`s by prefix replacement, local string references, external chain references in other files, `text(...)` targets in MDX and TS, and TS markers — the workspace builds, all edges retarget (query-asserted), the mapping is appended to the journal, and the command's own report is the applied mapping — every journaled identity pair, the information of the preview's `mapping`, carried in JSON per 12.0 (SPEC 6.4, 6.6, 6.1, 12.0; H-3 adapter, report shape unpinned)", run: async (product) => { await withWorkspace( SPEC_AND_CODE_CONFIG, @@ -590,12 +602,36 @@ const T6_4_1 = defineProductTest({ "T6.4-1 pre-rename", ); - await expectExit( + // The command's own report is the applied mapping — every identity + // pair the operation journaled, the information of the preview's + // `mapping` (SPEC 6.4, 6.6) — carried in JSON per 12.0 and decoded + // through the H-3 adapter (the successful operation's report shape is + // unpinned). The fixture pins the journaled mapping completely: the + // renamed node and its one descendant re-identified by prefix + // replacement, and nothing else — every other identity is unchanged + // and unmapped. + const renameReport = await runJson( product, workspace, - ["rename", "specs/Core.mdx", "core.mid", "core.hub"], - 0, - "T6.4-1 `rename specs/Core.mdx core.mid core.hub`", + ["rename", "specs/Core.mdx", "core.mid", "core.hub", "--json"], + "T6.4-1 `rename specs/Core.mdx core.mid core.hub --json`", + ); + assertAppliedMapping( + decodeAppliedMappingReport(renameReport, "T6.4-1"), + [ + { + from: "specs/Core.mdx#core.mid", + to: "specs/Core.mdx#core.hub", + }, + { + from: "specs/Core.mdx#core.mid.leaf", + to: "specs/Core.mdx#core.hub.leaf", + }, + ], + "T6.4-1: the successful rename's report is the applied mapping — " + + "exactly the identity pairs the operation journaled: the renamed " + + "node and its descendant, old identity to new (SPEC 6.4, 6.6, " + + "12.0)", ); // The rewrites, per source surface: stale spellings gone, rewritten diff --git a/test/suite/registry/support.ts b/test/suite/registry/support.ts index b2e3156..485170d 100644 --- a/test/suite/registry/support.ts +++ b/test/suite/registry/support.ts @@ -9,6 +9,7 @@ import { Buffer } from "node:buffer"; import type { + AppliedMappingPair, Finding, FindingLocation, GraphEdge, @@ -443,3 +444,21 @@ export function assertEdgeSetEqual( edges.map((edge) => `${edge.kind}: ${edge.from} -> ${edge.to}`).sort(); assertSameJson(render(actual), render(expected), context); } + +/** + * Assert a successful `rename`/`move`'s applied-mapping report carries + * exactly the expected identity pairs — every identity pair the operation + * journaled, no more (SPEC.md 6.4, 6.5: the complete identity mapping, the + * information of the preview's `mapping`, 6.6; T6.4-1, T6.5-1). The report's + * shape is unpinned (H-3), so pair order is not asserted: both sides compare + * as complete sorted multisets (a duplicated or extra pair still fails). + */ +export function assertAppliedMapping( + actual: readonly AppliedMappingPair[], + expected: readonly AppliedMappingPair[], + context: string, +): void { + const render = (pairs: readonly AppliedMappingPair[]): string[] => + pairs.map((pair) => `${pair.from} -> ${pair.to}`).sort(); + assertSameJson(render(actual), render(expected), context); +} From 44069238412f1398d214e21ff26d08721a3d7cb8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 01:30:40 +0000 Subject: [PATCH 061/226] sdg(phase-9): add T6.4-4's wrong-kind and parse-local old-ID-existence arms (FP-016) Base and ordering arms gain the discovered-code-source <file> operand (exit 2, judged like existence before source validation) on a spec+code config; three new arms pin parse-local old-ID existence over spelled identities (SPEC 6.4, 11.2): duplicate spellings and an id-less ancestor's sole bearer refuse via the workspace's one numbered finding (14.3 / 14.1, T6.4-6 protocol, nothing modified), while a repeated-id sole would-be bearer spells no identity and exits 2 beside that file's premise-pinned 14.17 findings. Probed sound against the built product; suite red-as-diagnosed at the FP-002-class error-document gap; test:self unchanged (4 planned reds). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 32 ++++- test/suite/registry/section-6.4.ts | 216 ++++++++++++++++++++++++++--- 2 files changed, 229 insertions(+), 19 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index e082af7..0c42df6 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -503,13 +503,43 @@ A "new test T<x>" task always means, in one change: 6.6 are carriage context with home coverage elsewhere). FP-017 (move's applied-mapping report) reuses this adapter and helper.] -- [ ] FP-016 — T6.4-4: add the wrong-kind and parse-local old-ID-existence +- [x] FP-016 — T6.4-4: add the wrong-kind and parse-local old-ID-existence arms. [R1 #19; TEST-SPEC §6.4] `test/suite/registry/section-6.4.ts`, none present today: (a) discovered code source passed as `<file>` → exit 2; (b) duplicate-spelling bearers → exit 1 via the duplicate-ID finding; (c) bearer beneath an ancestor spelling no identity → exit 1; (d) sole would-be bearer spelling no identity (repeated `id` attribute) → exit 2 beside that file's findings. + [Done 2026-08-11: (a) base and ordering arms now run on + SPEC_AND_CODE_CONFIG with a discovered, reference-free `src/app.ts`, each + adding `rename src/app.ts a a2` → exit 2 via `expectRenameUsageError` + (FP-002 protocol: single 12.7 error document under `--json`, stderr + message present) beside the existing nonexistent-file/old-ID invocations — + TEST-SPEC's "checked before source validation" covers all three, so the + wrong-kind operand rides both arms; three parse-local arms follow the + masking arm: (b) two top-level sections both spelling `dup` → + `expectRefusalModifiesNothing` (the T6.4-6 protocol: exit 1 under + `--json`, form-exact 12.7 findings-only report, exactly one 14.3 located + in the file, whole-root snapshot compare); (c) sole bearer `kid` beneath + an id-less `<S>` ancestor → same protocol, exactly one 14.1 (the bearer's + 14.2 masked per condition 2's rule, so the ancestor's finding is the + workspace's only one); (d) `<S id="solo" id="solo">` sole would-be bearer + → staging premise pinned first (`build --json` reports exactly one 14.17 + — a repeated `id` is condition 17, never 14.1, and spells no identity) + then `rename … solo solo2` → exit 2 via the usage-error protocol. + Title/module comments extended; traceability unchanged (["6.4","12.0"]: + T6.4-4 appears in no TEST-SPEC 14 staging record — the masking arm's + 14.20 precedent); no certification scope. Soundness proven by direct + probes against the built product (suite arms downstream of the first are + unreached — T6.4-4 stays red-as-diagnosed at the base arm's exit-2 + error-document decode, the FP-002-class product gap; findings decodes the + FP-001 class): wrong-kind exits 2 on valid and failing workspaces; dup → + exit 1 with exactly one 14.3 in-file; anc → exit 1 with exactly one 14.1, + no unknown-ID error (existence established by the spelled bearer); solo → + build exactly one 14.17, rename exit 2 "unknown ID 'solo'"; both refusals + wrote nothing. Section-6.4 unchanged 4 failed / 3 passed; `npm run + test:self` unchanged 4 planned mid-loop reds (certification-document ×3 → + FP-091; S-1's 9 unmapped keys → stages E/G).] - [ ] FP-017 — T6.5-1: assert the applied-mapping report for the file-form move (both forms report as rename does). [R1 #20; TEST-SPEC §6.5] diff --git a/test/suite/registry/section-6.4.ts b/test/suite/registry/section-6.4.ts index 596195e..c74a7b8 100644 --- a/test/suite/registry/section-6.4.ts +++ b/test/suite/registry/section-6.4.ts @@ -15,11 +15,17 @@ // are valid TypeScript identifiers, double-quoted computed access for // segments that are not, and double-quoted string literals. Type-level // references record no edges and are not rewritten. A nonexistent `<file>` or -// old ID is a usage error (12.0) checked before source validation, but an old -// ID inside an unparseable origin file is masked (14.20, 14); every other -// validation failure refuses the rename (exit 1), the valid-workspace -// precondition included, before modifying anything. A successful rename -// finishes by regenerating derived files exactly as `xspec build` does. +// old ID is a usage error (12.0) checked before source validation, and so is +// a `<file>` naming a discovered code source — a wrong-kind operand, judged +// like existence before any content question (6.4); the old ID's existence is +// parse-local, judged over spelled identities (11.2): a bearer whose node +// identity is undefined (duplicate spellings; an undefined ancestor chain) +// still establishes existence, a section spelling no identity (its `id` +// attribute repeated) establishes none, and an old ID inside an unparseable +// origin file is masked (14.20, 14); every other validation failure refuses +// the rename (exit 1), the valid-workspace precondition included, before +// modifying anything. A successful rename finishes by regenerating derived +// files exactly as `xspec build` does. // // Conservative operationalizations (noted per H-4): // - T6.4-1 "all edges retarget (query-asserted)": the workspace-wide edge set @@ -73,7 +79,17 @@ // findings: the 12.0-ordering discriminator) and the usage error message // on stderr (12.0), asserted for presence, not wording. The masking arm // asserts exit 1 with a findings report of exactly one 14.20 naming the -// unparseable file with a location (SPEC 14, H-3). +// unparseable file with a location (SPEC 14, H-3). The parse-local +// existence arms (SPEC 6.4, 11.2) assert the invalid-workspace refusal +// through the T6.4-6 protocol — exit 1, the workspace's numbered findings +// alone (exactly one 14.3 for duplicate spellings; exactly one 14.1 for +// the identity-less ancestor), located in the staged file, nothing +// modified — never exit 2: each staged bearer spells the old ID, so +// existence holds whatever its node identity. The spells-no-identity arm +// pins its staging premise first (`build --json` reports exactly one +// 14.17 — a repeated `id` is condition 17, never 14.1, and spells no +// identity, SPEC 14, 11.2) so its exit-2 assertion demonstrably runs +// beside that file's findings. // - T6.4-7 "byte-identical to a fresh build of the rewritten sources" is the // H-6 two-directory protocol: a second workspace is seeded with the // post-rename configuration, sources, and journal (derived files are @@ -405,11 +421,12 @@ async function expectRefusalModifiesNothing( } /** - * A rename usage error (SPEC 6.4, 12.0: nonexistent `<file>` or old ID): run - * with `--json`, assert exit 2 exactly, the single 12.7 error document as - * the entire stdout (12.0: no report and no validation findings — the - * 12.0-ordering discriminator; H-5), and a usage error message on stderr - * (12.0: standard-error content; presence, not wording). + * A rename usage error (SPEC 6.4, 12.0: a nonexistent or wrong-kind + * code-source `<file>`, or a nonexistent old ID): run with `--json`, assert + * exit 2 exactly, the single 12.7 error document as the entire stdout (12.0: + * no report and no validation findings — the 12.0-ordering discriminator; + * H-5), and a usage error message on stderr (12.0: standard-error content; + * presence, not wording). */ async function expectRenameUsageError( product: ProductBinding, @@ -423,8 +440,8 @@ async function expectRenameUsageError( workspace, [...argv, "--json"], 2, - `${context}: \`${command} --json\` — a nonexistent <file> or old ID is a ` + - `usage error (SPEC 6.4, 12.0)`, + `${context}: \`${command} --json\` — a nonexistent or wrong-kind ` + + `<file>, or a nonexistent old ID, is a usage error (SPEC 6.4, 12.0)`, ); expectErrorDocument( result, @@ -1207,15 +1224,67 @@ const U4_BROKEN_SOURCE = [ "", ].join("\n"); +// The wrong-kind arm's discovered code source (SPEC 7.2): valid TypeScript +// with no spec references, so the base arm's workspace still builds clean — +// a code source bears no requirement IDs, making it a wrong-kind `<file>` +// operand (SPEC 6.4). +const U4_CODE_FILE = "src/app.ts"; +const U4_CODE_SOURCE = "export function noop(): void {}\n"; + +// Parse-local existence fixtures (SPEC 6.4, 11.2). Two sections both +// spelling the same ID: every bearer's node identity is undefined (11.2, +// duplicate spellings), yet each spells `dup`, so the old ID exists and the +// duplicate-ID finding (14.3) refuses instead of any usage error. +const U4_DUP_FILE = "specs/Dup.mdx"; +const U4_DUP_SOURCE = [ + '<S id="dup">', + "First bearer text.", + "</S>", + "", + '<S id="dup">', + "Second bearer text.", + "</S>", + "", +].join("\n"); + +// A sole bearer spelling its ID beneath an ancestor spelling no identity — +// no `id` attribute at all (14.1): the bearer's node identity is undefined +// through the ancestor chain (11.2), yet it spells `kid`, so the old ID +// exists and the ancestor's finding refuses. The bearer's own structural +// check (14.2) is masked by the parent's condition (SPEC 14 condition 2), so +// the workspace's findings are exactly the one 14.1. +const U4_ANC_FILE = "specs/Anc.mdx"; +const U4_ANC_SOURCE = [ + "<S>", + "Ancestor text spelling no identity.", + "", + '<S id="kid">', + "Kid text.", + "</S>", + "</S>", + "", +].join("\n"); + +// The old ID's only would-be bearer spells no identity — its `id` attribute +// repeated on the tag (11.2; condition 17, never 14.1) — so the old ID is +// nonexistent: exit 2 even beside that file's findings. +const U4_SOLO_FILE = "specs/Solo.mdx"; +const U4_SOLO_SOURCE = [ + '<S id="solo" id="solo">', + "Sole would-be bearer text.", + "</S>", + "", +].join("\n"); + const T6_4_4 = defineProductTest({ id: "T6.4-4", title: - "usage errors (exit 2): a nonexistent `<file>` and a nonexistent old ID are usage errors checked before source validation — the same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering) — but an old ID inside an unparseable origin file is masked: the validation findings are reported and the command exits 1 (SPEC 6.4, 12.0, 14, 14.20)", + "usage errors (exit 2): a nonexistent `<file>`, a nonexistent old ID, and a discovered code source as `<file>` — a wrong-kind operand, judged like existence before any content question — are usage errors checked before source validation, the same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering); an old ID inside an unparseable origin file is masked — the validation findings are reported and the command exits 1; and old-ID existence is parse-local over spelled identities: an ID two sections both spell, or one whose sole bearer spells it beneath an ancestor spelling no identity, exists — the duplicate-ID or ancestor finding refuses instead (exit 1, never exit 2) — while an old ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent, exit 2 even beside that file's findings (SPEC 6.4, 11.2, 12.0, 14, 14.20)", run: async (product) => { // --- Base arm: a valid workspace --- await withWorkspace( - SPECS_ONLY_CONFIG, - { [U4_FILE]: U4_SOURCE }, + SPEC_AND_CODE_CONFIG, + { [U4_FILE]: U4_SOURCE, [U4_CODE_FILE]: U4_CODE_SOURCE }, async (workspace) => { const context = "T6.4-4 valid-workspace arm"; await buildOk(product, workspace, `${context}: \`build\``); @@ -1231,13 +1300,26 @@ const T6_4_4 = defineProductTest({ ["rename", U4_FILE, "nope", "nope2"], `${context}, nonexistent old ID`, ); + await expectRenameUsageError( + product, + workspace, + ["rename", U4_CODE_FILE, "a", "a2"], + `${context}, discovered code source as <file> — a code source ` + + `bears no requirement IDs, so a code-source origin is a ` + + `wrong-kind operand, judged like existence before any content ` + + `question (SPEC 6.4, 12.0)`, + ); }, ); // --- Ordering arm: the workspace also fails build validation --- await withWorkspace( - SPECS_ONLY_CONFIG, - { [U4_FILE]: U4_SOURCE, [U4_BAD_FILE]: U4_BAD_SOURCE }, + SPEC_AND_CODE_CONFIG, + { + [U4_FILE]: U4_SOURCE, + [U4_BAD_FILE]: U4_BAD_SOURCE, + [U4_CODE_FILE]: U4_CODE_SOURCE, + }, async (workspace) => { const context = "T6.4-4 ordering arm"; // Staging premise: the workspace really fails build validation, so @@ -1270,6 +1352,14 @@ const T6_4_4 = defineProductTest({ `${context}, nonexistent old ID with unrelated validation errors ` + `present — the existence checks precede source validation (12.0)`, ); + await expectRenameUsageError( + product, + workspace, + ["rename", U4_CODE_FILE, "a", "a2"], + `${context}, wrong-kind (code-source) <file> with unrelated ` + + `validation errors present — the wrong-kind operand is judged ` + + `like existence, before source validation (SPEC 6.4, 12.0)`, + ); }, ); @@ -1308,6 +1398,96 @@ const T6_4_4 = defineProductTest({ ); }, ); + + // --- Parse-local existence: duplicate spellings still establish it --- + await withWorkspace( + SPECS_ONLY_CONFIG, + { [U4_DUP_FILE]: U4_DUP_SOURCE }, + async (workspace) => { + // Renaming an ID two sections both spell is no usage error: the + // bearers establish existence, their undefined node identities + // notwithstanding (SPEC 6.4, 11.2), and the duplicate-ID finding + // refuses instead — the invalid-workspace refusal, exit 1, + // reporting the workspace's numbered findings alone: exactly one + // 14.3 finding (duplicate identities are one finding locating every + // bearer, SPEC 14), nothing modified. + await expectRefusalModifiesNothing( + product, + workspace, + ["rename", U4_DUP_FILE, "dup", "dup2"], + { finding: "14.3", locatedAt: { file: U4_DUP_FILE } }, + "T6.4-4 parse-local existence, duplicate spellings (renaming an " + + "ID two sections both spell is no usage error — the " + + "duplicate-ID finding refuses instead: exit 1, never exit 2; " + + "SPEC 6.4, 11.2, 14)", + ); + }, + ); + + // --- Parse-local existence: an undefined ancestor chain still + // establishes it --- + await withWorkspace( + SPECS_ONLY_CONFIG, + { [U4_ANC_FILE]: U4_ANC_SOURCE }, + async (workspace) => { + // The sole bearer spells `kid` beneath an ancestor spelling no + // identity (no `id` attribute): the bearer establishes existence — + // its undefined ancestor chain notwithstanding (SPEC 6.4, 11.2) — + // and the ancestor's finding refuses: exit 1 with exactly the one + // 14.1 finding (the bearer's structural check is masked by the + // parent's condition, SPEC 14 condition 2), never exit 2. + await expectRefusalModifiesNothing( + product, + workspace, + ["rename", U4_ANC_FILE, "kid", "kid2"], + { finding: "14.1", locatedAt: { file: U4_ANC_FILE } }, + "T6.4-4 parse-local existence, sole bearer beneath an ancestor " + + "spelling no identity (the bearer establishes existence and " + + "the ancestor's missing-id finding refuses: exit 1, never " + + "exit 2; SPEC 6.4, 11.2, 14)", + ); + }, + ); + + // --- Parse-local nonexistence: a would-be bearer spelling no + // identity --- + await withWorkspace( + SPECS_ONLY_CONFIG, + { [U4_SOLO_FILE]: U4_SOLO_SOURCE }, + async (workspace) => { + const context = "T6.4-4 spells-no-identity arm"; + // Staging premise: the repeated-`id` bearer leaves the file with + // exactly one 14.17 finding — a repeated prop is condition 17, + // never 14.1, spells no identity, and has no children whose masked + // 14.2 could add findings (SPEC 11.2, 14). Pinning the premise + // makes the exit-2 assertion below demonstrably run beside that + // file's findings: a product that takes a repeated-`id` value as + // spelled, or that reports the file's findings in the old ID's + // place, exits 1 here instead. + const findings = await buildFindings( + product, + workspace, + `${context}: \`build --json\` premise — the staged workspace ` + + `fails build validation (repeated \`id\` attribute, SPEC 14.17)`, + ); + assertConditionCounts( + findings, + { "14.17": 1 }, + `${context}: staging premise — the repeated-\`id\` bearer is the ` + + `file's one finding (SPEC 14: a repeated prop is condition 17, ` + + `never condition 1)`, + ); + await expectRenameUsageError( + product, + workspace, + ["rename", U4_SOLO_FILE, "solo", "solo2"], + `${context}: an old ID whose only would-be bearer spells no ` + + `identity (its \`id\` attribute repeated on the tag) is ` + + `nonexistent — exit 2 even beside that file's findings ` + + `(SPEC 6.4, 11.2, 12.0)`, + ); + }, + ); }, }); From c8f717fe97a00e208b1cd779da61f05a314330d6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 01:43:07 +0000 Subject: [PATCH 062/226] sdg(phase-9): assert move's applied-mapping report in both forms (FP-017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T6.5-1 decodes the file-form move's own report through the H-3 applied-mapping adapter and asserts every journaled identity pair — the moved file's four nodes, implicit root included; T6.5-3 does the same for the section form: exactly the moved subtree's prefix-replaced pairs (SPEC 6.5: both forms report as rename does; T6.4-1's protocol, FP-015's adapter and helper). Both tests turn red-as-diagnosed at the decode against the current product, which reports {"findings":[]} with no mapping member. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 27 ++++++++- test/suite/registry/section-6.5.ts | 91 +++++++++++++++++++++++++++--- 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 0c42df6..13084d9 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -541,11 +541,36 @@ A "new test T<x>" task always means, in one change: test:self` unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G).] -- [ ] FP-017 — T6.5-1: assert the applied-mapping report for the file-form +- [x] FP-017 — T6.5-1: assert the applied-mapping report for the file-form move (both forms report as rename does). [R1 #20; TEST-SPEC §6.5] `test/suite/registry/section-6.5.ts`: decode the move command's stdout in both file form and section form and assert the applied mapping, exactly as FP-015 does for rename. + [Done 2026-08-11: both forms decode through FP-015's layer + (`decodeAppliedMappingReport` + `assertAppliedMapping`), split as the + journal clause already is (TEST-SPEC T6.5-1 carries the clause; module + header documents the split). T6.5-1's file-form move now runs via + `runJson` (`--json`: exit 0, single JSON document as the entire stdout, + 12.0) and asserts exactly four pairs — every node of the moved file, the + implicit root included (SPEC 1.2/1.5: its identity is the path alone; + its pair is journaled, else 6.3 replay could not unify the root across + the move, T6.2-2's purity). T6.5-3's section-form move gained `--json` + in R3_MOVE_ARGV (identical argv in both H-6 determinism directories, so + that compare is unaffected) and asserts exactly the three + prefix-replaced subtree pairs (`org.mv{,.k1,.k2}` → `tm{,.k1,.k2}`), + no other identity mapped. Titles extended; traceability unchanged + (both ["6.5"]; 12.0/6.4/6.6 carriage context per FP-015 precedent); + neither test in any certification scope; no new adapter, so S-5's + existing operations-adapter guards cover the decode. Verified: both + tests turned falsely-green → red-as-diagnosed exactly at the + applied-mapping decode ("required key \"mapping\" … absent" — probe: + the product reports `{"findings":[]}` on successful move in both + forms), downstream arms unreached until the product reports the + mapping; section-6.5 went 3 failed/3 passed → 5 failed/1 passed + (T6.5-2 stays green; T6.5-4/-5/-6 keep their pre-existing + FP-002/FP-007-class reds). Typecheck clean; `npm run test:self` + unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; + S-1's 9 unmapped keys → stages E/G).] - [ ] FP-018 — T6.5-4: add the missing destination-refusal arms. [R1 #21; TEST-SPEC §6.5] diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index 382eb15..a9bd3c3 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -39,6 +39,19 @@ // plain file holding exactly one line-oriented entry after the one move; // entry content stays opaque (H-4). T6.5-1 asserts it for the file form, // T6.5-3 for the section form — "the full mapping … (6.5: both forms)". +// - The applied-mapping report — "a successful move … reports its applied +// mapping, as rename does" (SPEC 6.5, 6.4) — is asserted with T6.4-1's +// protocol: the move runs with `--json` (a single JSON document as the +// entire stdout, 12.0), its report decodes through the H-3 +// `decodeAppliedMappingReport` adapter (the successful operation's report +// shape is unpinned), and the decoded pairs are asserted as a complete set +// (`assertAppliedMapping`) — every identity pair the operation journaled, +// the information of the preview's `mapping` (SPEC 6.4, 6.6). Both forms +// report as rename does, split as the journal clause is: T6.5-1 decodes +// the file form's report — every node of the moved file mapped, the +// implicit root included (its identity is the path alone, 1.2, 1.5), IDs +// kept and file parts changed — and T6.5-3 the section form's: exactly +// the moved subtree's prefix-replaced pairs, no other identity mapped. // - T6.5-1/T6.5-3 "finishing regeneration as T6.4-7" is the H-6 two-directory // protocol: a second workspace is seeded with the post-move configuration, // sources, and journal (derived files are reproducible from those, @@ -111,6 +124,7 @@ import { Buffer } from "node:buffer"; import type { GraphEdge } from "../../helpers/adapters/index.js"; import { + decodeAppliedMappingReport, decodeEdgesReport, decodeFindingsReport, decodeNodeRowsReport, @@ -138,6 +152,7 @@ import type { import { TestWorkspace } from "../../helpers/workspace.js"; import type { ConcernedIdentity, FindingSourceExpectation } from "./support.js"; import { + assertAppliedMapping, assertConditionCounts, assertEdgeSetEqual, assertFindingConcernsPath, @@ -669,7 +684,7 @@ const F1_SEED_FILES = [ const T6_5_1 = defineProductTest({ id: "T6.5-1", title: - "file form: `xspec move old.mdx new.mdx` keeps IDs unchanged and changes identities only in their file part; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; the mapping is appended to the journal; finishing regeneration as T6.4-7 — byte-identical to a fresh `build`, `check` clean (SPEC 6.5, 6.1, 12.1, 14.10)", + "file form: `xspec move old.mdx new.mdx` keeps IDs unchanged and changes identities only in their file part; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; the mapping is appended to the journal; finishing regeneration as T6.4-7 — byte-identical to a fresh `build`, `check` clean; and the command's own report is the applied mapping as T6.4-1 — every journaled identity pair, carried in JSON per 12.0 (SPEC 6.5, 6.4, 6.1, 12.0, 12.1, 14.10; H-3 adapter, report shape unpinned; 6.5: both forms report as rename does — the section form's report is T6.5-3's assertion)", run: async (product) => { await withWorkspace( FULL_CONFIG, @@ -707,12 +722,40 @@ const T6_5_1 = defineProductTest({ ); await assertF1Edges(product, workspace, F1_CORE, "T6.5-1 pre-move"); - await expectExit( + // The command's own report is the applied mapping — every identity + // pair the operation journaled, the information of the preview's + // `mapping` (SPEC 6.5: both forms report as rename does; 6.4, 6.6) — + // carried in JSON per 12.0 and decoded through the H-3 adapter (the + // successful operation's report shape is unpinned; T6.4-1's + // protocol). The fixture pins the journaled mapping completely: the + // file form changes every moved-file identity in its file part alone + // — the implicit root included, its identity being the path alone + // (SPEC 1.2, 1.5), and its pair journaled like every other, else a + // pre-move baseline could not unify the root across the move (6.3, + // T6.2-2) — while the premise enumeration above pins the moved + // file's nodes as exactly these four, so no other identity is + // mapped. + const moveReport = await runJson( product, workspace, - ["move", F1_CORE, F1_MOVED], - 0, - "T6.5-1 file-form `move specs/Core.mdx specs/sub/Moved.mdx`", + ["move", F1_CORE, F1_MOVED, "--json"], + "T6.5-1 file-form `move specs/Core.mdx specs/sub/Moved.mdx --json`", + ); + assertAppliedMapping( + decodeAppliedMappingReport(moveReport, "T6.5-1"), + [ + { from: F1_CORE, to: F1_MOVED }, + { from: `${F1_CORE}#core`, to: `${F1_MOVED}#core` }, + { from: `${F1_CORE}#core.mid`, to: `${F1_MOVED}#core.mid` }, + { + from: `${F1_CORE}#core.mid.leaf`, + to: `${F1_MOVED}#core.mid.leaf`, + }, + ], + "T6.5-1: the successful file-form move's report is the applied " + + "mapping — exactly the identity pairs the operation journaled: " + + "every node of the moved file, the implicit root included, its " + + "ID kept and its file part changed (SPEC 6.5, 6.4, 6.6, 12.0)", ); // The file was relocated. @@ -1154,10 +1197,14 @@ const R3_FILES: Readonly<Record<string, string>> = { [R3_TARGET]: R3_TARGET_SOURCE, }; +// `--json` carries the command's own report — the applied mapping — as a +// single JSON document (SPEC 12.0; the report assertion below); identical +// argv in both determinism directories, so H-6's compare is unaffected. const R3_MOVE_ARGV = [ "move", "specs/Origin.mdx#org.mv", "specs/Target.mdx#tm", + "--json", ] as const; // Subtree re-identified by prefix replacement: org.mv → tm, descendants too. @@ -1188,7 +1235,7 @@ const R3_SEED_FILES = [ const T6_5_3 = defineProductTest({ id: "T6.5-3", title: - "re-identification and reference conversion: the moved subtree is re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed exactly (an import unreferenced before the move stays); rewritten content is byte-deterministic across two identical fixtures; the full mapping is appended to the journal; finishing regeneration as T6.4-7 (SPEC 6.5, 2.1, 6.1, 6.4, 12.1, 14.10)", + "re-identification and reference conversion: the moved subtree is re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed exactly (an import unreferenced before the move stays); rewritten content is byte-deterministic across two identical fixtures; the full mapping is appended to the journal and reported as the command's own applied-mapping report — the section form reports as rename does, T6.4-1's protocol (SPEC 6.5, 2.1, 6.1, 6.4, 12.0, 12.1, 14.10; H-3 adapter, report shape unpinned)", run: async (product) => { const created: TestWorkspace[] = []; try { @@ -1215,10 +1262,40 @@ const T6_5_3 = defineProductTest({ assertExitCode( first, 0, - "T6.5-3 `move specs/Origin.mdx#org.mv specs/Target.mdx#tm`", + "T6.5-3 `move specs/Origin.mdx#org.mv specs/Target.mdx#tm --json`", ); const workspace = firstWorkspace; + // The command's own report is the applied mapping — the section form + // reports as rename does (SPEC 6.5, 6.4; the file form is T6.5-1's + // assertion) — carried in JSON per 12.0 and decoded through the H-3 + // adapter (report shape unpinned; T6.4-1's protocol). The fixture pins + // the journaled mapping completely: the section form maps exactly the + // moved subtree, `org.mv` and its two descendants re-identified by + // prefix replacement of `org.mv` with `tm` (SPEC 6.5), while every + // identity outside the subtree — both files' roots, `org`, + // `org.usemv`, `tgt`, `keep`, `sp` — is unchanged and unmapped + // (R3_POST_IDENTITIES pins that below). + assertAppliedMapping( + decodeAppliedMappingReport( + parseJsonStdout( + first, + "T6.5-3 the section-form move's report — a single JSON document " + + "as the entire stdout (SPEC 12.0)", + ), + "T6.5-3", + ), + [ + { from: `${R3_ORIGIN}#org.mv`, to: `${R3_TARGET}#tm` }, + { from: `${R3_ORIGIN}#org.mv.k1`, to: `${R3_TARGET}#tm.k1` }, + { from: `${R3_ORIGIN}#org.mv.k2`, to: `${R3_TARGET}#tm.k2` }, + ], + "T6.5-3: the successful section-form move's report is the applied " + + "mapping — exactly the identity pairs the operation journaled: " + + "the moved subtree's prefix-replaced identities, nothing else " + + "(SPEC 6.5, 6.4, 6.6, 12.0)", + ); + // Conversion and import-rewrite observables (module header, H-4). const originText = await readSourceText( workspace, From 6d837611be14ce774bb07b2f8cb786b2859576c6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 17:06:14 +0000 Subject: [PATCH 063/226] sdg(phase-9): add T6.5-4's destination-occupant, empty-id, and derived-path refusal arms (FP-018) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 41 ++++- test/suite/registry/section-6.5.ts | 279 ++++++++++++++++++++++++----- 2 files changed, 276 insertions(+), 44 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 13084d9..e0b298e 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -572,7 +572,7 @@ A "new test T<x>" task always means, in one change: unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G).] -- [ ] FP-018 — T6.5-4: add the missing destination-refusal arms. [R1 #21; +- [x] FP-018 — T6.5-4: add the missing destination-refusal arms. [R1 #21; TEST-SPEC §6.5] `test/suite/registry/section-6.5.ts` (plain-file arm at ~line 1435 is the only occupancy arm): add (a) file-form destination occupied by a symbolic @@ -584,6 +584,45 @@ A "new test T<x>" task always means, in one change: arm of `refused-invalid-destination`: emission under `markdown.outDir` with emit-destination component `<outDir>/new` occupied by a plain file. Assert stable refusal codes per FP-007. + [Done 2026-08-11: all seven arms landed in T6.5-4. + `expectRefusalModifiesNothing` widened to take one `RefusalExpectation` + per applicable reason (counts assert the complete multiset — no reason + beside the staged ones; per-reason concern lookup by counting key, total + since a refusal report holds one finding per reason, SPEC 14). (a)+(b) + ride the shared refusal workspace — occupants staged before the premise + `build` (directory `specs/DirTarget.mdx`; symlinks `specs/SymDest.mdx`/ + `specs/LinkTarget.mdx` → B.mdx, broken `specs/GoneDest.mdx`; plain + out-of-group `docs/Occ.mdx` in the files map), each arm + `refused-destination-exists` concerning the occupied path, the Occ arm + additionally `refused-invalid-destination`, both concerning + `docs/Occ.mdx`. (c) `move specs/A.mdx#keep specs/B.mdx#` → + `refused-invalid-id` concerning `specs/B.mdx#` (zero-segment id; exit 1, + never the exit-2 generalization of 11.3's `--to` rule). (d) its own + workspace (`V4_OUTDIR_CONFIG`: glob admits `new/**/*.mdx`, + `markdown.outDir: "mdout"`; plain file at `mdout/new`; premise `build` + exit 0 — the occupant lies under no current source's write path) → + `move specs/Solo.mdx new/b.mdx` refused `refused-invalid-destination` + concerning the destination path `new/b.mdx`, never 14.22, the count map + excluding a 14.22 beside. Verified by direct probes against the built + product (suite arms past the first are unreached — T6.5-4 stays + red-as-diagnosed at the first arm's FP-001-class form-exact decode, the + product still emitting `{"refused":…}`/`condition`-member shapes; + section-6.5 unchanged 5 failed / 1 passed): both premise builds exit 0 + with every occupant present; the five occupant arms and the empty-id arm + each exit 1 modifying nothing on exactly the staged ground (the product's + old-shape message names the symlink/directory/broken-link occupant, the + no-spec-group cause, the empty-segment ID); the two-reason Occ arm's + count assertion has teeth (the current product reports only one reason); + a control twin proves (d)'s staging — without the occupant the identical + move succeeds and writes `mdout/new/b.md`, pinning the 13.2/7.3 emit + shape — while with it the product exits 70 (internal error) and modifies + the workspace: exactly the diagnosed vets-only-own-components gap the arm + discriminates. Typecheck clean; `npm run test:self` unchanged 4 planned + mid-loop reds (certification-document ×3 → FP-091; S-1's 9 unmapped keys + → stages E/G), S-5 and certification green. Traceability unchanged + (["6.5","14"] — T14-7's refusal staging record already rides "14"; 7/ + 7.3/13.1/13.2/13.4 are context with home coverage elsewhere); T6.5-4 is + in no certification scope (Exclusions-shared machinery only).] - [ ] FP-019 — T6.5-5: add the missing usage-error arms. [R1 #22; TEST-SPEC §6.5] diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index a9bd3c3..1ffd2a0 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -75,10 +75,12 @@ // `{text("tm.k1")}`, `d={"tm.k1"}`). // - T6.5-4/T6.5-6 refusal arms run with `--json`: a refused operation's // report is the form-exact 12.7 findings-only report (SPEC 12.7, H-3), and -// each arm — staged to isolate one refusal cause — asserts exactly one -// finding carrying the exact stable refusal code (SPEC 14: one finding per -// applicable reason; TEST-SPEC preamble: a code is contract) with the -// concern §14 assigns the reason: the concerned identity +// each arm asserts exactly one finding per applicable refusal reason, +// carrying its exact stable code (SPEC 14: one finding per applicable +// reason, every applicable reason reported together; TEST-SPEC preamble: a +// code is contract) — most arms stage a single cause; T6.5-4's +// out-of-group `.mdx` occupant stages two applicable reasons at once — +// with the concern §14 assigns the reason: the concerned identity // (refused-invalid-id, refused-identity-unchanged, // refused-missing-target-parent; the full 1.5 identity or its bare ID — // §14 requires identification, not spelling), the concerned path @@ -93,7 +95,10 @@ // whole-workspace-root byte snapshot compare around each refused command // with the pre-refusal `build`'s derived files present (the T6.4-3 // protocol); because each arm proves it modified nothing, the arms share -// one staged workspace. The precondition arm's invalid-workspace refusal +// one staged workspace — except the derived-path arm, which stages its +// own: it needs `markdown.outDir` emission and a spec glob admitting the +// destination `new/b.mdx` (SPEC 7.3, 13.2). The precondition arm's +// invalid-workspace refusal // instead reports the workspace's numbered findings alone (SPEC 14, 6.4): // exactly its one 14.5 finding located in the offending file. The 6.5 // destination clauses "containing `#`" and "not valid UTF-8" admit no @@ -400,10 +405,12 @@ function renderArgv(argv: readonly ArgvValue[]): string { } /** - * What a refused move's report must hold (SPEC 14, 12.7): the arm's one - * finding — its exact stable code — plus whichever concern §14 assigns the - * reason: a located participant, a concerned identity, a concerned path, or - * nothing further where no pre-operation construct renders the concern. + * What one finding of a refused move's report must hold (SPEC 14, 12.7): + * its exact stable code plus whichever concern §14 assigns the reason: a + * located participant, a concerned identity, a concerned path, or nothing + * further where no pre-operation construct renders the concern. An arm + * staging several applicable reasons passes one expectation per reason + * (SPEC 14: every applicable reason reports together, one finding each). */ interface RefusalExpectation { /** @@ -426,18 +433,24 @@ interface RefusalExpectation { * existence checks refuses with exit 1): run with `--json`, assert exit 1 * exactly, decode stdout as the form-exact 12.7 findings-only report of a * refused operation (SPEC 12.7, H-3), assert the report holds exactly one - * finding bearing the arm's stable code with its concerned data (SPEC 14, - * T14-7), and assert the refusal modifies nothing — a whole-workspace-root - * byte snapshot compare around the command (derived files, sources, and the - * journal all included). + * finding per expected refusal reason — its stable code with its concerned + * data (SPEC 14, T14-7: every applicable reason together, one finding each, + * and none beside) — and assert the refusal modifies nothing — a + * whole-workspace-root byte snapshot compare around the command (derived + * files, sources, and the journal all included). Per-reason concern lookup + * is by counting key, total because a refusal report never carries two + * findings of one reason (SPEC 14: one finding per reason). */ async function expectRefusalModifiesNothing( product: ProductBinding, workspace: TestWorkspace, argv: readonly string[], - expected: RefusalExpectation, + expected: RefusalExpectation | readonly RefusalExpectation[], context: string, ): Promise<void> { + const expectations: readonly RefusalExpectation[] = Array.isArray(expected) + ? expected + : [expected]; const command = argv.join(" "); await assertLeavesUnchanged( workspace.root, @@ -457,35 +470,54 @@ async function expectRefusalModifiesNothing( `${context}: \`${command} --json\` — a refused operation's report ` + `is the form-exact 12.7 findings-only report (SPEC 12.7, H-3)`, ).findings; + const counts: Record<string, number> = {}; + for (const expectation of expectations) { + counts[expectation.finding] = (counts[expectation.finding] ?? 0) + 1; + } assertConditionCounts( findings, - { [expected.finding]: 1 }, - `${context}: the arm isolates one refusal cause, so the report ` + - `holds exactly one finding carrying its exact stable code — one ` + - `finding per applicable reason, a code is contract (SPEC 14, ` + + counts, + `${context}: the report holds exactly one finding per applicable ` + + `refusal reason, each carrying its exact stable code, and no ` + + `reason beside the staged one(s) — a code is contract (SPEC 14, ` + `12.7, T14-7)`, ); - const finding = findings[0]!; - if (expected.locatedAt !== undefined) { - assertFindingMentionsLocation( - finding, - expected.locatedAt, - `${context}: the refusal's concerned construct`, - ); - } - if (expected.identity !== undefined) { - assertFindingNamesIdentity( - finding, - expected.identity, - `${context}: the refusal's concerned identity`, - ); - } - if (expected.path !== undefined) { - assertFindingConcernsPath( - finding, - expected.path, - `${context}: the refusal's concerned path`, + for (const expectation of expectations) { + const finding = findings.find( + (candidate) => + (candidate.condition ?? candidate.code ?? "(code-less)") === + expectation.finding, ); + if (finding === undefined) { + fail( + `${context}: no reported finding carries ` + + `${JSON.stringify(expectation.finding)} (SPEC 14, 12.7)`, + ); + } + if (expectation.locatedAt !== undefined) { + assertFindingMentionsLocation( + finding, + expectation.locatedAt, + `${context}: the ${expectation.finding} refusal's concerned ` + + `construct`, + ); + } + if (expectation.identity !== undefined) { + assertFindingNamesIdentity( + finding, + expectation.identity, + `${context}: the ${expectation.finding} refusal's concerned ` + + `identity`, + ); + } + if (expectation.path !== undefined) { + assertFindingConcernsPath( + finding, + expectation.path, + `${context}: the ${expectation.finding} refusal's concerned ` + + `path`, + ); + } } }, `${context}: \`${command}\` refused — modifies nothing (SPEC 6.5)`, @@ -1515,12 +1547,28 @@ const T6_5_3 = defineProductTest({ // `mv` into B.mdx forces imports in both directions (A ↔ B): the spec // import cycle. Moving `mv` *under* `keep` in the same file makes it depend // on its own ancestor: the dependency cycle (5.3) — no imports involved. -// - `x`/`x.sub` carry no references: the collision and target-parent arms -// refuse on exactly their stated grounds. +// - `x`/`x.sub` carry no references: the collision, target-parent, and +// section-form occupant arms refuse on exactly their stated grounds. // - B.mdx exists (file-form destination), holds `y` (cross-file collision), // and has no `nope` (missing target parent). // - The destination-path arms use the file form of the reference-free A.mdx, // each violating exactly one destination rule under REFUSAL_CONFIG. +// - Destination occupants (SPEC 6.5): the file form refuses on ANY occupant +// — a plain file (B.mdx), a symbolic link, a broken symbolic link (target +// absent; a product probing existence through link-following stat sees +// that path absent and proceeds to relocate) — and the section form on +// any occupant that is not a discovered spec source: a directory, a +// symbolic link resolving to the discovered B.mdx (discovery never yields +// a symlink, SPEC 7 — a product resolving the target path through the +// filesystem finds a spec source there and inserts through the link), or +// the out-of-group plain `.mdx` file docs/Occ.mdx (present, right +// extension, still no discovered spec source), the latter refusing under +// both applicable reasons at once — refused-destination-exists beside +// refused-invalid-destination, one finding per reason (SPEC 14, T14-7). +// The non-file occupants stage at in-group `specs/*.mdx` paths discovery +// ignores (no source file, so no discovery, no derived paths), so the +// pre-refusal `build` stays valid and every arm refuses on exactly its +// staged ground rather than the invalid-workspace precondition. const V4_A = "specs/A.mdx"; const V4_A_SOURCE = [ '<S id="keep">', @@ -1557,6 +1605,16 @@ const V4_B_SOURCE = [ "", ].join("\n"); +// Destination-occupant paths (the staging note above): non-file occupants at +// in-group `.mdx` paths, staged in the test body before the pre-refusal +// `build`, plus the out-of-group plain `.mdx` file (in the files map). +const V4_SYM_DEST = "specs/SymDest.mdx"; // file form: symlink → B.mdx +const V4_GONE_DEST = "specs/GoneDest.mdx"; // file form: broken symlink +const V4_DIR_TARGET = "specs/DirTarget.mdx"; // section form: directory +const V4_LINK_TARGET = "specs/LinkTarget.mdx"; // section form: symlink → B.mdx +const V4_OCC = "docs/Occ.mdx"; // section form: out-of-group `.mdx` file +const V4_OCC_SOURCE = ['<S id="occ">', "Occupant text.", "</S>", ""].join("\n"); + // Location windows within the staged sources (SPEC 14): the dependency-cycle // arm locates the reference spelling recording the participating dependency // edge — the moved node's `d={"keep"}` — and the cross-file collision arm @@ -1585,15 +1643,55 @@ const V4_OTHER_INVALID = [ "", ].join("\n"); +// The derived-path arm of refused-invalid-destination (SPEC 6.5: a +// workspace-relative directory component of a derived path the destination +// would generate — 13.1, 13.2, 7.3 — occupied by a non-directory), on its +// own workspace: Markdown emission redirected under `markdown.outDir`, and a +// second spec glob admitting the file-form destination `new/b.mdx`. The +// destination is otherwise valid — in-group, `.mdx`, unoccupied, its own +// directory component `new/` absent (a nonexistent component is never a +// refusal cause, SPEC 13.4) and the destination's generated module and +// companions sharing that same absent directory (13.1) — but the destination +// would emit `mdout/new/b.md` (13.2, 7.3: outDir preserves +// workspace-relative paths), and that derived path's directory component +// `mdout/new` is occupied by a plain file. The occupant lies under no +// current source's write path (specs/Solo.mdx writes specs/Solo.xspec.ts +// with its companions and mdout/specs/Solo.md), so the staged workspace +// passes `build`'s validations, and the refusal is the move's own: +// refused-invalid-destination concerning the destination path, never 14.22 +// (SPEC 14, T14-7) — discriminating a product that vets only the +// destination path's own components (it sees `new/` absent and proceeds). +const V4_OUTDIR_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx", "new/**/*.mdx"] + }, + markdown: { emit: true, outDir: "mdout" } +}) +`; +const V4_SOLO = "specs/Solo.mdx"; +const V4_SOLO_SOURCE = ['<S id="solo">', "Solo text.", "</S>", ""].join("\n"); +const V4_MDOUT_OCCUPANT = "mdout/new"; + const T6_5_4 = defineProductTest({ id: "T6.5-4", title: - "refusals (exit 1, nothing modified): a move creating a spec import cycle or a dependency cycle (refused-cycle, the dependency arm locating the participating `d` spelling); file form whose destination exists (refused-destination-exists, concerning that path); section form with a 1.4-invalid `<new-id>` (forbidden name `then`; whitespace-bearing segment — refused-invalid-id, concerning that identity); the ordinary cross-file `<new-id>` collision (refused-id-collision, locating the remaining bearer); a missing target parent and a target parent within the moved subtree (refused-missing-target-parent, concerning the target-parent identity); and destination paths in no configured spec group, in a code group as well, or lacking `.mdx` (refused-invalid-destination, concerning the destination path) — each refusal the form-exact 12.7 findings-only report holding exactly one finding with its exact stable code; the `#`-containing and non-UTF-8 destination clauses admit no refusal staging (the dead-letter note): every such operand spelling is an exit-2 usage error first, staged in T6.5-5; plus the valid-workspace precondition as T6.4-6, reporting the workspace's numbered findings alone (SPEC 6.5, 5.3, 2.1, 1.4, 1.3, 14.14, 14.19, 12.0, 12.7, 14)", + "refusals (exit 1, nothing modified): a move creating a spec import cycle or a dependency cycle (refused-cycle, the dependency arm locating the participating `d` spelling); file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link with its target absent, one arm each, the broken-link arm discriminating a product probing existence through link-following stat (refused-destination-exists, concerning that path); section form whose target path is occupied by anything other than a discovered spec source — a directory; a symbolic link resolving to a discovered spec source (discovery never yields a symlink); and an existing `.mdx` file outside every configured spec group, the latter refusing under refused-destination-exists and refused-invalid-destination together, one finding per applicable reason; section form with a 1.4-invalid `<new-id>` (forbidden name `then`; whitespace-bearing segment; the empty `<new-id>` of destination operand `specs/B.mdx#`, a well-formed 12.0 split with zero id segments, never the exit-2 generalization of 11.3's `--to` spelling rule — refused-invalid-id, concerning that identity); the ordinary cross-file `<new-id>` collision (refused-id-collision, locating the remaining bearer); a missing target parent and a target parent within the moved subtree (refused-missing-target-parent, concerning the target-parent identity); destination paths in no configured spec group, in a code group as well, or lacking `.mdx`, and the derived-path arm — emission enabled under `markdown.outDir`, the otherwise-valid destination's emit-destination directory component `mdout/new` occupied by a plain file lying under no current source's write path, refused never 14.22 (refused-invalid-destination, concerning the destination path) — each refusal the form-exact 12.7 findings-only report holding exactly one finding per applicable reason with its exact stable code; the `#`-containing and non-UTF-8 destination clauses admit no refusal staging (the dead-letter note): every such operand spelling is an exit-2 usage error first, staged in T6.5-5; plus the valid-workspace precondition as T6.4-6, reporting the workspace's numbered findings alone (SPEC 6.5, 7, 7.3, 5.3, 2.1, 1.4, 1.3, 13.1, 13.2, 13.4, 14.14, 14.19, 14.22, 12.0, 12.7, 14)", run: async (product) => { await withWorkspace( REFUSAL_CONFIG, - { [V4_A]: V4_A_SOURCE, [V4_B]: V4_B_SOURCE }, + { [V4_A]: V4_A_SOURCE, [V4_B]: V4_B_SOURCE, [V4_OCC]: V4_OCC_SOURCE }, async (workspace) => { + // Destination occupants (the staging note above): staged before the + // pre-refusal `build`, which must still pass — a directory is no + // source file and discovery never yields a symbolic link (SPEC 7), + // so each occupant arm refuses on exactly its staged ground, not + // the invalid-workspace precondition. + await workspace.dir(V4_DIR_TARGET); + await workspace.symlink(V4_SYM_DEST, "B.mdx"); + await workspace.symlink(V4_LINK_TARGET, "B.mdx"); + await workspace.symlink(V4_GONE_DEST, "missing-target.mdx"); // Build first, so the modifies-nothing compares include intact // derived files (the T6.4-3 protocol). await buildOk( @@ -1608,7 +1706,7 @@ const T6_5_4 = defineProductTest({ // per-reason choices). const cases: readonly (readonly [ readonly string[], - RefusalExpectation, + RefusalExpectation | readonly RefusalExpectation[], string, ])[] = [ [ @@ -1637,6 +1735,49 @@ const T6_5_4 = defineProductTest({ { finding: "refused-destination-exists", path: V4_B }, "file form whose destination file already exists (SPEC 6.5)", ], + [ + ["move", "specs/A.mdx", V4_SYM_DEST], + { finding: "refused-destination-exists", path: V4_SYM_DEST }, + "file form whose destination path is occupied by a symbolic " + + "link — whatever kind of filesystem object occupies it, a " + + "symbolic link included (SPEC 6.5)", + ], + [ + ["move", "specs/A.mdx", V4_GONE_DEST], + { finding: "refused-destination-exists", path: V4_GONE_DEST }, + "file form whose destination path is occupied by a broken " + + "symbolic link, target absent — a product probing existence " + + "through link-following stat sees the path absent and " + + "proceeds (SPEC 6.5)", + ], + [ + ["move", "specs/A.mdx#x", `${V4_DIR_TARGET}#tdir`], + { finding: "refused-destination-exists", path: V4_DIR_TARGET }, + "section form whose target path is occupied by a directory — " + + "not a discovered spec source: neither an insertion target " + + "nor an absent path to create (SPEC 6.5)", + ], + [ + ["move", "specs/A.mdx#x", `${V4_LINK_TARGET}#tlink`], + { finding: "refused-destination-exists", path: V4_LINK_TARGET }, + "section form whose target path is occupied by a symbolic link " + + "resolving to a discovered spec source — discovery never " + + "yields a symlink (SPEC 6.5, 7): a product resolving the " + + "target path through the filesystem finds a spec source " + + "there and inserts through the link into B.mdx", + ], + [ + ["move", "specs/A.mdx#x", `${V4_OCC}#tocc`], + [ + { finding: "refused-destination-exists", path: V4_OCC }, + { finding: "refused-invalid-destination", path: V4_OCC }, + ], + "section form whose target path is occupied by an existing " + + "`.mdx` file outside every configured spec group — present, " + + "right extension, still no discovered spec source — refusing " + + "under both applicable reasons, one finding per reason " + + "(SPEC 6.5, 14)", + ], [ ["move", "specs/A.mdx#keep", "specs/B.mdx#then"], { @@ -1655,6 +1796,20 @@ const T6_5_4 = defineProductTest({ "section form whose <new-id> is invalid per 1.4 — a " + "whitespace-bearing segment (SPEC 6.5)", ], + [ + ["move", "specs/A.mdx#keep", "specs/B.mdx#"], + { + finding: "refused-invalid-id", + identity: { file: V4_B, id: "" }, + }, + "section form whose <new-id> is empty — the destination " + + "operand `specs/B.mdx#` holds one `#`, a well-formed 12.0 " + + "split whose id part has zero segments, refused as an " + + "invalid intrinsic ID (one or more segments, SPEC 14) — " + + "never the exit-2 malformed-value treatment a product gets " + + "by generalizing 11.3's `--to` spelling rule to move " + + "operands (SPEC 6.5, 12.0)", + ], [ ["move", "specs/A.mdx#x", "specs/B.mdx#y"], { @@ -1727,6 +1882,44 @@ const T6_5_4 = defineProductTest({ }, ); + // The derived-path arm of refused-invalid-destination, on its own + // workspace (V4_OUTDIR_CONFIG's note): the destination `new/b.mdx` is + // otherwise valid and its own directory components unobstructed (`new/` + // absent — a nonexistent component is never a refusal cause, SPEC + // 13.4), but the emit destination `mdout/new/b.md` it would generate + // (SPEC 13.2, 7.3) has its directory component `mdout/new` occupied by + // a plain file. + await withWorkspace( + V4_OUTDIR_CONFIG, + { + [V4_SOLO]: V4_SOLO_SOURCE, + [V4_MDOUT_OCCUPANT]: "not a directory\n", + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T6.5-4 derived-path arm `build` over the staged workspace — the " + + "plain file mdout/new lies under no current source's write " + + "path (SPEC 13.4), so the workspace passes `build`'s " + + "validations and the refusal below is the move's own", + ); + await expectRefusalModifiesNothing( + product, + workspace, + ["move", V4_SOLO, "new/b.mdx"], + { finding: "refused-invalid-destination", path: "new/b.mdx" }, + "T6.5-4 (derived-path arm — a workspace-relative directory " + + "component of a derived path the destination would generate, " + + "the emit destination mdout/new/b.md under markdown.outDir, is " + + "occupied by a plain file: refused refused-invalid-destination " + + "concerning the destination path, never 14.22 — a product " + + "vetting only the destination path's own components sees new/ " + + "absent and proceeds; SPEC 6.5, 7.3, 13.1, 13.2, 14)", + ); + }, + ); + // Valid-workspace precondition, as T6.4-6: with a pre-existing // validation error elsewhere, the move's own arguments being valid, the // move refuses (exit 1) before modifying anything. From 118b164397e73811688df4be3b5c4819bdde60f3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 17:21:40 +0000 Subject: [PATCH 064/226] sdg(phase-9): add T6.5-5's wrong-kind, mixed-synopsis, and parse-local arms (FP-019) Wrong-kind (code-source) origins in each form ride the base and ordering arms on a spec+code config, exit 2 inside modifies-nothing compares; the two missing mixed-synopsis invocations land beside the dead-letter third (classification by spelling alone, exit 2, nothing modified); parse-local origin-ID existence mirrors T6.4-4: dup/anc refuse exit 1 with exactly the one 14.3/14.1 finding, solo (repeated id, 14.17 premise) exits 2 beside its file's findings. Probed sound against the built product; suite red-point and self-test reds unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 39 +++- test/suite/registry/section-6.5.ts | 339 ++++++++++++++++++++++++++--- 2 files changed, 348 insertions(+), 30 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index e0b298e..c3c1ab1 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -624,7 +624,7 @@ A "new test T<x>" task always means, in one change: 7.3/13.1/13.2/13.4 are context with home coverage elsewhere); T6.5-4 is in no certification scope (Exclusions-shared machinery only).] -- [ ] FP-019 — T6.5-5: add the missing usage-error arms. [R1 #22; TEST-SPEC +- [x] FP-019 — T6.5-5: add the missing usage-error arms. [R1 #22; TEST-SPEC §6.5] `test/suite/registry/section-6.5.ts` (~line 1580 area has only nonexistent origin file/ID, ordering, masking): add (a) wrong-kind @@ -633,6 +633,43 @@ A "new test T<x>" task always means, in one change: (c) parse-local ID existence arms mirroring T6.4-4 (FP-016). (The non-UTF-8 destination operand arm arrives via FP-005's restage.) Exit-2 protocol per FP-002. + [Done 2026-08-11: (a) base and ordering arms now run on a new + SPEC_AND_CODE_CONFIG (6.4's mirror) with a discovered, reference-free + `src/app.ts`; U5_WRONG_KIND_CASES adds `move src/app.ts specs/New.mdx` + (file form) and `move src/app.ts#noop specs/B.mdx#z` (section form — the + id part names the file's real exported unit, discriminating a product + that resolves code units in move origins), each exit 2 via + `expectMoveUsageError` (FP-002 protocol), riding both arms per + TEST-SPEC's "checked before source validation", the base-arm pair inside + whole-root `assertLeavesUnchanged` compares (an accepting product would + relocate the file). (b) the two missing mixed-synopsis arms `move + specs/A.mdx specs/B.mdx#y` and `move specs/A.mdx#a specs/B.mdx` landed + beside the FP-005-restaged `specs/Ha#sh.mdx` arm (the trio's third, kept + as the dead-letter staging), every operand naming staged content, each in + a whole-root modifies-nothing compare — exit 2, matches-neither by + spelling alone. (c) three parse-local arms mirror FP-016 with identical + sources: dup (two bearers of `dup`) → `expectRefusalModifiesNothing`, + exactly one 14.3 located in the file, target file not created; anc (sole + bearer `kid` beneath an id-less ancestor) → same protocol, exactly one + 14.1; solo (`<S id="solo" id="solo">`) → build premise exactly one 14.17, + then move exit 2 beside that file's findings. Title and module header + extended; traceability unchanged (["6.5","12.0"], the T6.4-4/FP-016 + precedent — T6.5-5 in no TEST-SPEC 14 staging record and no certification + scope). Soundness proven by direct probes against the built product + (suite arms past the first are unreached — T6.5-5 stays red-as-diagnosed + at the first arm's FP-002-class exit-2 error-document gap): base build + exit 0 with the code file; both wrong-kind forms exit 2 ("unknown file") + modifying nothing; mixed-2 exits 2 ("names no target section"); + mixed-1 exposes a real classification gap — the product treats + `specs/B.mdx#y` as a `#`-containing file-form destination path and + REFUSES exit 1 where the arm demands exit 2 (matches-neither), modifying + nothing; dup/anc exit 1 with exactly the one old-shape 14.3/14.1 finding + (the FP-001-class form gap), creating nothing (no New.mdx, no `.xspec/`); + solo: build reports exactly one 14.17, move exits 2 "unknown ID 'solo'". + Typecheck clean; section-6.5 unchanged 5 failed / 1 passed (T6.5-2 + green); `npm run test:self` unchanged 4 planned mid-loop reds + (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages + E/G), S-5 and certification green.] - [ ] FP-020 — T7-2: add the string-literal group-name keys arm. [R1 #23; TEST-SPEC §7] diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index 1ffd2a0..a81f860 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -109,16 +109,38 @@ // document (12.0: with JSON output in effect, an exit-2 invocation emits // the error document as its entire stdout — no report, no validation // findings: the 12.0-ordering discriminator) and the usage error message -// on stderr (presence, not wording). The masking arm asserts exit 1 with -// exactly one 14.20 finding naming the unparseable origin file. The two -// stagings T6.5-4's dead-letter note sets aside are asserted here, each -// with a whole-root modifies-nothing snapshot compare around the command: -// a `#`-containing file-form destination — classified as a `<file>#<id>` -// pair by spelling alone, so the invocation mixes the two synopses' forms -// and matches neither (SPEC 6.5, 12.0) — and a non-UTF-8 destination -// operand, a usage-error argument value (SPEC 12.0), staged on the Linux -// leg only (mirroring T1.5-2's platform note): argv bytes exist as a -// channel there, carried by the subprocess driver's raw-byte argv support. +// on stderr (presence, not wording). The existence and kind checks ride +// both a valid workspace and the ordering arm's failing one (12.0: +// checked before source validation, as T6.4-4): a nonexistent origin +// file or origin ID, and a discovered code source as the origin in each +// form — both forms' origin operands name discovered spec sources +// (SPEC 6.5), so a code-source origin is a wrong-kind operand, judged +// like existence before any content question — the wrong-kind arms on +// the valid workspace inside whole-root modifies-nothing snapshot +// compares (a product accepting a code origin would relocate the file or +// act on its named unit). The masking arm asserts exit 1 with exactly +// one 14.20 finding naming the unparseable origin file, and origin-ID +// existence is parse-local over spelled identities, as T6.4-4 +// (SPEC 6.5, 6.4, 11.2): an origin ID two sections both spell, or one +// whose sole bearer spells it beneath an ancestor spelling no identity, +// exists — the invalid-workspace refusal reports the workspace's one +// 14.3 or 14.1 finding instead (exit 1, never exit 2, nothing modified, +// the target file not created) — while an origin ID whose only would-be +// bearer spells no identity (its `id` attribute repeated on the tag, a +// 14.17 premise pinned via `build`) is nonexistent: exit 2 even beside +// that file's findings. Operand classification is by spelling alone +// (SPEC 6.5: an operand containing `#` is a `<file>#<id>` pair under the +// 12.0 split, one without is a file): the three mixed-synopsis +// invocations — bare-file origin with pair destination, pair origin with +// bare-file destination, and the `#`-containing file-form destination +// classified as a pair (T6.5-4's dead-letter note) — match neither +// synopsis and exit 2, each inside a whole-root modifies-nothing +// snapshot compare (every operand names staged content, so a product +// accepting a mixed form would perform a move); and a non-UTF-8 +// destination operand, a usage-error argument value (SPEC 12.0), is +// staged on the Linux leg only (mirroring T1.5-2's platform note): argv +// bytes exist as a channel there, carried by the subprocess driver's +// raw-byte argv support. // - T6.5-6's unstageable clauses are documented at the test, per TEST-SPEC: // the collision clause's after-the-removal qualifier admits no // discriminating fixture (structural IDs make the vacated set exactly the @@ -185,6 +207,21 @@ export default defineConfig({ }) `; +// One spec group plus one code group (SPEC 7.2), for T6.5-5's wrong-kind +// origin arms: the staged code source is discovered, so a code-source origin +// operand is a wrong-kind usage error in either form (SPEC 6.5, 6.4, 12.0). +const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + } +}) +`; + // One spec group plus Markdown emission (SPEC 7.3), so T6.5-3's fresh-build // compare covers generated modules, Markdown output, and graph data alike. const SPECS_MD_CONFIG = `import { defineConfig } from "xspec" @@ -1994,6 +2031,61 @@ const U5_BROKEN_SOURCE = [ "", ].join("\n"); +// The wrong-kind arms' discovered code source (SPEC 7.2): valid TypeScript +// with no spec references, so the base arm's workspace still builds clean — +// a code source bears no requirement IDs, and both forms' origin operands +// name discovered spec sources (SPEC 6.5), making a code-source origin a +// wrong-kind operand in either form, judged like existence before any +// content question (SPEC 6.4, 12.0). +const U5_CODE = "src/app.ts"; +const U5_CODE_SOURCE = "export function noop(): void {}\n"; + +// Parse-local existence fixtures, mirroring T6.4-4 (SPEC 6.5, 6.4, 11.2). +// Two sections both spelling the same ID: every bearer's node identity is +// undefined (11.2, duplicate spellings), yet each spells `dup`, so the +// origin ID exists and the duplicate-ID finding (14.3) refuses instead of +// any usage error. +const U5_DUP = "specs/Dup.mdx"; +const U5_DUP_SOURCE = [ + '<S id="dup">', + "First bearer text.", + "</S>", + "", + '<S id="dup">', + "Second bearer text.", + "</S>", + "", +].join("\n"); + +// A sole bearer spelling its ID beneath an ancestor spelling no identity — +// no `id` attribute at all (14.1): the bearer's node identity is undefined +// through the ancestor chain (11.2), yet it spells `kid`, so the origin ID +// exists and the ancestor's finding refuses. The bearer's own structural +// check (14.2) is masked by the parent's condition (SPEC 14 condition 2), so +// the workspace's findings are exactly the one 14.1. +const U5_ANC = "specs/Anc.mdx"; +const U5_ANC_SOURCE = [ + "<S>", + "Ancestor text spelling no identity.", + "", + '<S id="kid">', + "Kid text.", + "</S>", + "</S>", + "", +].join("\n"); + +// The origin ID's only would-be bearer spells no identity — its `id` +// attribute repeated on the tag (11.2; condition 17, never 14.1) — so the +// origin ID is nonexistent: exit 2 even beside that file's findings. +const U5_SOLO = "specs/Solo.mdx"; +const U5_SOLO_SOURCE = [ + '<S id="solo" id="solo">', + "Sole would-be bearer text.", + "</S>", + "", +].join("\n"); + // Destination operand that is not valid UTF-8: `specs/<0xFF>.mdx` (Linux-leg // staging — argv is a byte channel there; T6.5-5, T12.0-5, T1.5-2's note). // It contains no `#`, so only the argument-value rule makes it exit 2: a @@ -2021,15 +2113,38 @@ const U5_USAGE_CASES: readonly (readonly [readonly string[], string])[] = [ ], ]; +// Wrong-kind origins (SPEC 6.5: both forms' origin operands name discovered +// spec sources; a code source bears no requirement IDs, so a code-source +// origin is a wrong-kind operand, judged like existence before any content +// question, SPEC 6.4, 12.0). The section form's id part names the code +// file's real exported unit (`noop`), so a product that resolves code units +// in move origins is discriminated. These cases ride the base arm (inside +// modifies-nothing compares) and the ordering arm (the wrong-kind check +// precedes source validation, as T6.4-4). +const U5_WRONG_KIND_CASES: readonly (readonly [readonly string[], string])[] = [ + [ + ["move", U5_CODE, "specs/New.mdx"], + "file form, discovered code source as origin", + ], + [ + ["move", `${U5_CODE}#noop`, "specs/B.mdx#z"], + "section form, discovered code source as origin file", + ], +]; + const T6_5_5 = defineProductTest({ id: "T6.5-5", title: - "usage errors (exit 2): a nonexistent origin file (either form) and a nonexistent origin ID are usage errors checked before source validation — the same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering, as T6.4-4) — but an origin ID inside an unparseable origin file is masked: the validation findings are reported and the command exits 1; a `#`-containing file-form destination classifies as a `<file>#<id>` pair by spelling alone, the invocation matching neither synopsis (exit 2), and a non-UTF-8 destination operand (raw argv bytes, Linux leg) is a usage-error argument value (exit 2) — the stagings T6.5-4's dead-letter note sets aside, each modifying nothing (SPEC 6.5, 12.0, 14, 14.20)", + "usage errors (exit 2): a nonexistent origin file (either form), a nonexistent origin ID, and a discovered code source as the origin in each form — both forms' origin operands name discovered spec sources, so a code-source origin is a wrong-kind operand, judged like existence before any content question — are usage errors checked before source validation, the same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering, as T6.4-4); an origin ID inside an unparseable origin file is masked: the validation findings are reported and the command exits 1; origin-ID existence is parse-local over spelled identities: an ID two sections both spell, or one whose sole bearer spells it beneath an ancestor spelling no identity, exists — the duplicate-ID or ancestor finding refuses instead (exit 1, never exit 2, nothing modified) — while an ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent, exit 2 even beside that file's findings; operand classification is by spelling alone: the three mixed-synopsis invocations — bare-file origin with pair destination, pair origin with bare-file destination, and a `#`-containing file-form destination classified as a pair — match neither synopsis (exit 2), and a non-UTF-8 destination operand (raw argv bytes, Linux leg) is a usage-error argument value (exit 2) — the latter two the stagings T6.5-4's dead-letter note sets aside — the wrong-kind, mixed-synopsis, dead-letter, and refusal arms each proving nothing modified (SPEC 6.5, 6.4, 11.2, 12.0, 14, 14.20)", run: async (product) => { // --- Base arm: a valid workspace --- await withWorkspace( - SPECS_ONLY_CONFIG, - { [U5_A]: U5_A_SOURCE, [U5_B]: U5_B_SOURCE }, + SPEC_AND_CODE_CONFIG, + { + [U5_A]: U5_A_SOURCE, + [U5_B]: U5_B_SOURCE, + [U5_CODE]: U5_CODE_SOURCE, + }, async (workspace) => { const context = "T6.5-5 valid-workspace arm"; await buildOk(product, workspace, `${context}: \`build\``); @@ -2042,14 +2157,72 @@ const T6_5_5 = defineProductTest({ ); } - // The stagings T6.5-4's dead-letter note sets aside (SPEC 6.5's - // `#`-containing and non-UTF-8 destination clauses), each asserted - // with a whole-root modifies-nothing snapshot compare around the - // command. A move operand is classified by spelling alone: an - // operand containing `#` is a `<file>#<id>` pair under the 12.0 - // split, so `specs/Ha#sh.mdx` beside the bare-file origin mixes the - // two synopses' forms and matches neither — exit 2, never the 6.5 + // Wrong-kind origins (SPEC 6.5: both forms' origin operands name + // discovered spec sources), each inside a whole-root + // modifies-nothing snapshot compare: the operands name a real + // discovered code file and its real exported unit, so a product + // accepting a code-source origin would relocate the file (file + // form) or act on the named unit (section form). + for (const [argv, label] of U5_WRONG_KIND_CASES) { + await assertLeavesUnchanged( + workspace.root, + async () => { + await expectMoveUsageError( + product, + workspace, + argv, + `${context}, ${label} — a code source bears no requirement ` + + `IDs, so a code-source origin is a wrong-kind operand, ` + + `judged like existence before any content question ` + + `(SPEC 6.5, 6.4, 12.0)`, + ); + }, + `${context}, ${label}: the usage error modifies nothing ` + + `(SPEC 6.5, 12.0)`, + ); + } + + // The three mixed-synopsis invocations (SPEC 6.5: a move operand is + // classified by spelling alone — an operand containing `#` is a + // `<file>#<id>` pair under the 12.0 split, one without is a file — + // so an invocation mixing the two synopses' forms matches neither): + // each asserted with a whole-root modifies-nothing snapshot compare + // around the command, because every operand names staged content + // (`specs/A.mdx`, its section `a`, `specs/B.mdx`) — a product + // accepting a mixed form would perform a move. The third, the + // `#`-containing file-form destination, is also the staging + // T6.5-4's dead-letter note sets aside: exit 2, never the 6.5 // destination refusal (exit 1) it would be were the operand a path. + await assertLeavesUnchanged( + workspace.root, + async () => { + await expectMoveUsageError( + product, + workspace, + ["move", U5_A, "specs/B.mdx#y"], + `${context}, mixed synopsis \`a.mdx b.mdx#y\` — a bare-file ` + + `origin with a pair destination matches neither form ` + + `(SPEC 6.5, 12.0)`, + ); + }, + `${context}: \`move ${U5_A} specs/B.mdx#y\` — the usage error ` + + `modifies nothing (SPEC 6.5, 12.0)`, + ); + await assertLeavesUnchanged( + workspace.root, + async () => { + await expectMoveUsageError( + product, + workspace, + ["move", `${U5_A}#a`, U5_B], + `${context}, mixed synopsis \`a.mdx#x b.mdx\` — a pair ` + + `origin with a bare-file destination matches neither form ` + + `(SPEC 6.5, 12.0)`, + ); + }, + `${context}: \`move ${U5_A}#a ${U5_B}\` — the usage error ` + + `modifies nothing (SPEC 6.5, 12.0)`, + ); await assertLeavesUnchanged( workspace.root, async () => { @@ -2057,20 +2230,24 @@ const T6_5_5 = defineProductTest({ product, workspace, ["move", "specs/A.mdx", "specs/Ha#sh.mdx"], - `${context}, \`#\`-containing file-form destination — the ` + - `operand classifies as a \`<file>#<id>\` pair by spelling ` + - `alone, so the invocation mixes the two synopses' forms ` + - `and matches neither (SPEC 6.5, 12.0)`, + `${context}, mixed synopsis \`a.mdx b#c.mdx\` — the ` + + `\`#\`-containing file-form destination classifies as a ` + + `\`<file>#<id>\` pair by spelling alone, so the invocation ` + + `mixes the two synopses' forms and matches neither ` + + `(SPEC 6.5, 12.0)`, ); }, `${context}: \`move specs/A.mdx specs/Ha#sh.mdx\` — the usage ` + `error modifies nothing (SPEC 6.5, 12.0)`, ); - // Non-UTF-8 destination operand, staged on the Linux leg only - // (mirroring T1.5-2's platform note): Linux argv is a byte channel, - // so the destination is passed as raw bytes (driver trampoline); - // other platforms cannot carry the argument at all. + // Non-UTF-8 destination operand — the other staging T6.5-4's + // dead-letter note sets aside (SPEC 6.5's non-UTF-8 destination + // clause: a non-UTF-8 argument value is a usage error before any + // refusal is evaluated) — staged on the Linux leg only (mirroring + // T1.5-2's platform note): Linux argv is a byte channel, so the + // destination is passed as raw bytes (driver trampoline); other + // platforms cannot carry the argument at all. if (process.platform === "linux") { await assertLeavesUnchanged( workspace.root, @@ -2093,11 +2270,12 @@ const T6_5_5 = defineProductTest({ // --- Ordering arm: the workspace also fails build validation --- await withWorkspace( - SPECS_ONLY_CONFIG, + SPEC_AND_CODE_CONFIG, { [U5_A]: U5_A_SOURCE, [U5_B]: U5_B_SOURCE, [U5_BAD]: U5_BAD_SOURCE, + [U5_CODE]: U5_CODE_SOURCE, }, async (workspace) => { const context = "T6.5-5 ordering arm"; @@ -2126,6 +2304,16 @@ const T6_5_5 = defineProductTest({ `— the existence checks precede source validation (SPEC 12.0)`, ); } + for (const [argv, label] of U5_WRONG_KIND_CASES) { + await expectMoveUsageError( + product, + workspace, + argv, + `${context}, ${label}, with unrelated validation errors present ` + + `— the wrong-kind operand is judged like existence, before ` + + `source validation (SPEC 6.5, 6.4, 12.0)`, + ); + } }, ); @@ -2168,6 +2356,99 @@ const T6_5_5 = defineProductTest({ ); }, ); + + // --- Parse-local existence: duplicate spellings still establish it --- + await withWorkspace( + SPECS_ONLY_CONFIG, + { [U5_DUP]: U5_DUP_SOURCE }, + async (workspace) => { + // Moving an ID two sections both spell is no usage error: the + // bearers establish existence, their undefined node identities + // notwithstanding (SPEC 6.5, 6.4, 11.2), and the duplicate-ID + // finding refuses instead — the invalid-workspace refusal, exit 1, + // reporting the workspace's numbered findings alone: exactly one + // 14.3 finding (duplicate identities are one finding locating every + // bearer, SPEC 14), nothing modified, the absent target file not + // created (creation is the successful section move's business, + // SPEC 6.5). + await expectRefusalModifiesNothing( + product, + workspace, + ["move", `${U5_DUP}#dup`, "specs/New.mdx#dup2"], + { finding: "14.3", locatedAt: { file: U5_DUP } }, + "T6.5-5 parse-local existence, duplicate spellings (moving an " + + "ID two sections both spell is no usage error — the " + + "duplicate-ID finding refuses instead: exit 1, never exit 2; " + + "SPEC 6.5, 11.2, 14)", + ); + }, + ); + + // --- Parse-local existence: an undefined ancestor chain still + // establishes it --- + await withWorkspace( + SPECS_ONLY_CONFIG, + { [U5_ANC]: U5_ANC_SOURCE }, + async (workspace) => { + // The sole bearer spells `kid` beneath an ancestor spelling no + // identity (no `id` attribute): the bearer establishes existence — + // its undefined ancestor chain notwithstanding (SPEC 6.5, 6.4, + // 11.2) — and the ancestor's finding refuses: exit 1 with exactly + // the one 14.1 finding (the bearer's structural check is masked by + // the parent's condition, SPEC 14 condition 2), never exit 2, + // nothing modified. + await expectRefusalModifiesNothing( + product, + workspace, + ["move", `${U5_ANC}#kid`, "specs/New.mdx#kid2"], + { finding: "14.1", locatedAt: { file: U5_ANC } }, + "T6.5-5 parse-local existence, sole bearer beneath an ancestor " + + "spelling no identity (the bearer establishes existence and " + + "the ancestor's missing-id finding refuses: exit 1, never " + + "exit 2; SPEC 6.5, 11.2, 14)", + ); + }, + ); + + // --- Parse-local nonexistence: a would-be bearer spelling no + // identity --- + await withWorkspace( + SPECS_ONLY_CONFIG, + { [U5_SOLO]: U5_SOLO_SOURCE }, + async (workspace) => { + const context = "T6.5-5 spells-no-identity arm"; + // Staging premise: the repeated-`id` bearer leaves the file with + // exactly one 14.17 finding — a repeated prop is condition 17, + // never 14.1, spells no identity, and has no children whose masked + // 14.2 could add findings (SPEC 11.2, 14). Pinning the premise + // makes the exit-2 assertion below demonstrably run beside that + // file's findings: a product that takes a repeated-`id` value as + // spelled, or that reports the file's findings in the origin ID's + // place, exits 1 here instead. + const findings = await buildFindings( + product, + workspace, + `${context}: \`build --json\` premise — the staged workspace ` + + `fails build validation (repeated \`id\` attribute, SPEC 14.17)`, + ); + assertConditionCounts( + findings, + { "14.17": 1 }, + `${context}: staging premise — the repeated-\`id\` bearer is the ` + + `file's one finding (SPEC 14: a repeated prop is condition 17, ` + + `never condition 1)`, + ); + await expectMoveUsageError( + product, + workspace, + ["move", `${U5_SOLO}#solo`, "specs/New.mdx#solo2"], + `${context}: an origin ID whose only would-be bearer spells no ` + + `identity (its \`id\` attribute repeated on the tag) is ` + + `nonexistent — exit 2 even beside that file's findings ` + + `(SPEC 6.5, 6.4, 11.2, 12.0)`, + ); + }, + ); }, }); From dd90204ef3ed8530ece6a8d697b6a47574033b4d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 17:37:14 +0000 Subject: [PATCH 065/226] sdg(phase-9): add T7-2's string-literal group-name keys arm (FP-020) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 31 +++- test/suite/registry/section-7-basics.ts | 216 +++++++++++++++++++++++- 2 files changed, 245 insertions(+), 2 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index c3c1ab1..4ad8b54 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -671,12 +671,41 @@ A "new test T<x>" task always means, in one change: (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G), S-5 and certification green.] -- [ ] FP-020 — T7-2: add the string-literal group-name keys arm. [R1 #23; +- [x] FP-020 — T7-2: add the string-literal group-name keys arm. [R1 #23; TEST-SPEC §7] Neither `test/suite/registry/section-7-basics.ts` nor `section-7.4-7.5.ts` stages quoted keys. Add: group names as string literals (`"my-group"`/`"test-code"`) load, discover, and resolve in a coverage profile and in a policy selector. + [Done 2026-08-11: one arm appended to T7-2 (section-7-basics.ts) — a + config declaring spec group `"my-group"` and code group `"test-code"` + under string-literal keys (both names non-identifiers, so only that + spelling declares them), referenced from the coverage profile + (`target`/`boundary`, kinds inferred) and both selectors of a forbidden + rule. Asserted: `build` exit 0 (loads; an identifier-keys-only product + refuses 14.14/exit 2 here — TEST-SPEC's discriminator); discovery via + the exact `ids` listing plus whole-graph edge-set equality (2 contains + + `p`→`a` depends + the top-level marker's src/impl.ts→`a` references edge + — the code-group observation, T7-3's contrapositive); resolution via + profile "quoted" reporting covered `a` with path [src/impl.ts, a] and + uncovered `p` (counts/ignored stay T8.2-1's subject, the section-8 + discipline), and via `check --json` exit 1 with exactly one 14.12 + finding, identities [rule, p, depends, a], locations [], path null + (SPEC 14.12's contractual enumeration). Title extended; traceability + unchanged (["7","14"] already covers the asserted conditions; 7.4/7.5/8 + are context with home coverage at T7.4-*/T7.5-*/T8-*); no certification + scope. Verified: direct CLI probes against the built product returned + byte-for-byte the expected ids/edges/coverage documents and exactly the + one old-shape policy violation (the current product already accepts + quoted keys — those observations are genuinely green), and a scratch + execution of the arm body ran green through build/ids/edges/coverage/ + check-exit and red exactly at the form-exact findings decode ("expected + no member \"condition\"" — the FP-001-class product gap), so the arm is + red-as-diagnosed there; in the suite T7-2 still fails at its first + FORM_VIOLATIONS arm (the FP-002-class exit-2 gap), the new arm + unreached until that closes. Typecheck/format clean; `npm run + test:self` unchanged 4 planned mid-loop reds (certification-document ×3 + → FP-091; S-1's 9 unmapped keys → stages E/G).] - [ ] FP-021 — T7.5-5: add the literal-`$` forms arm. [R1 #24; TEST-SPEC §7.5, SPEC 14.14] diff --git a/test/suite/registry/section-7-basics.ts b/test/suite/registry/section-7-basics.ts index f370a28..e0322cb 100644 --- a/test/suite/registry/section-7-basics.ts +++ b/test/suite/registry/section-7-basics.ts @@ -36,6 +36,16 @@ // - T7-2 single-deviation staging: every invalid fixture is the valid // canonical configuration with exactly one deviation, so the refusal is // attributable to the arm's malformation and nothing else. +// - T7-2 string-literal keys arm: "both groups discover their globs' files" +// is observed as the spec group's exact `ids` listing plus whole-graph +// edge-set equality carrying the code file's marker edge (T7-3's +// contrapositive: an undiscovered code file sources no edge); "resolve" +// is observed as the quoted-name coverage profile's covered/uncovered +// rows (counts and ignored composition stay T8.2-1's subject, the +// section-8 discipline) and as the policy selector's violation reported +// per the SPEC 14.12 contract — identities in order the rule name and the +// offending edge's source, kind token, and target; `locations` [], `path` +// `null`. // - T7-3 "the unfiltered `query edges` list carries no edge from it": // asserted as exact whole-graph edge-set equality — the minimal fixture's // complete edge set is spec-forced (SPEC 5.1–5.2: one contains edge per @@ -57,6 +67,7 @@ import type { GraphEdge } from "../../helpers/adapters/index.js"; import { decodeCoverageReport, decodeEdgesReport, + decodeFindingsReport, decodeIdsReport, } from "../../helpers/adapters/index.js"; import { @@ -71,6 +82,7 @@ import { runProduct, summarizeResult } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import type { WorkspaceDecl } from "../../helpers/workspace.js"; import { + assertConditionCounts, assertEdgeSetEqual, assertSameJson, buildOk, @@ -419,13 +431,88 @@ export default makeConfig({ }) `; +// The string-literal keys arm (SPEC 7: the statically literal argument's +// object literals carry "non-computed identifier or string-literal keys"): +// a spec group and a code group whose names are not TypeScript identifiers +// ("my-group", "test-code") have only the string-literal spelling — a +// product accepting identifier keys alone refuses a valid configuration no +// other spelling can declare. The quoted names are referenced from every +// place group names resolve that this arm asserts: the coverage profile's +// `target` and `boundary` (both unambiguous, so their kinds are inferred, +// SPEC 7.4) and both policy selectors (SPEC 7.5). +const QUOTED_KEYS_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + "my-group": ["specs/**/*.mdx"] + }, + code: { + "test-code": ["src/**/*.ts"] + }, + coverage: [ + { + name: "quoted", + target: "my-group", + boundary: "test-code", + mode: "direct" + } + ], + policy: [ + { + name: "no-internal-deps", + type: "forbidden", + from: { group: "my-group" }, + to: { group: "my-group" } + } + ] +}) +`; + +// The quoted-keys workspace: the spec group's file holds two leaves — `a`, +// covered through the code marker's references edge, and `p`, depending +// locally on `a` (SPEC 2.2's string form) — and the code group's file holds +// one top-level marker, so its code location is the file itself (SPEC 4.5, +// 4.6; the T8-3 shape). +const QUOTED_KEYS_FILES: Readonly<Record<string, string>> = { + "xspec.config.ts": QUOTED_KEYS_CONFIG, + "specs/A.mdx": `<S id="a"> +Covered leaf. +</S> + +<S id="p" d={"a"}> +Dependent leaf. +</S> +`, + "src/impl.ts": `import SPEC from "../specs/A.xspec"; + +SPEC.a; +`, +}; + +// The quoted-keys fixture's complete edge set (SPEC 5.1–5.2, 2.2, 4.5). +// Whole-graph equality makes both discovery observations exact: the spec +// group's nodes carry their contains/depends edges, the code group's marker +// its references edge — an undiscovered src/impl.ts would drop it (T7-3's +// contrapositive) — and nothing stray exists. The depends edge doubles as +// the policy premise: both its endpoints are "my-group" nodes, so the +// forbidden rule below has exactly one violation to report. +const QUOTED_KEYS_EDGES: readonly GraphEdge[] = [ + { from: "specs/A.mdx", to: "specs/A.mdx#a", kind: "contains" }, + { from: "specs/A.mdx", to: "specs/A.mdx#p", kind: "contains" }, + { from: "specs/A.mdx#p", to: "specs/A.mdx#a", kind: "depends" }, + { from: "src/impl.ts", to: "specs/A.mdx#a", kind: "references" }, +]; + const T7_2 = defineProductTest({ id: "T7-2", title: "declarative form: a syntax error, a missing or misdirected " + "defineConfig import, extra statements, each non-literal argument " + "form, and a non-call default export are configuration errors (14.14, " + - "exit 2); an aliased defineConfig import is valid (SPEC 7)", + "exit 2); an aliased defineConfig import is valid; string-literal " + + "group-name keys are part of the accepted form — they load, discover, " + + "and resolve in a coverage profile and a policy selector (SPEC 7, 7.4, " + + "7.5, 8)", run: async (product) => { for (const arm of FORM_VIOLATIONS) { await expectConfigRefused(product, arm.config, `T7-2 (${arm.label})`); @@ -459,6 +546,133 @@ const T7_2 = defineProductTest({ ); }, ); + + // String-literal group-name keys are part of the accepted form (SPEC 7): + // the quoted-key groups load, discover, and resolve in a coverage + // profile and in a policy selector. + await withWorkspace({ files: QUOTED_KEYS_FILES }, async (workspace) => { + // Loads without error: a product accepting identifier keys alone + // refuses this configuration (14.14, exit 2) and fails here. The + // staged policy violation cannot fail the build — build never + // evaluates policy (SPEC 7.5, 12.1). + await buildOk( + product, + workspace, + "T7-2 (string-literal keys): `build` — a spec group and a code " + + 'group under string-literal keys ("my-group", "test-code") whose ' + + "names are not TypeScript identifiers load without error (SPEC 7)", + ); + + // Both groups discover their globs' files. + const idsLabel = "T7-2 (string-literal keys) `ids --json`"; + const ids = decodeIdsReport( + await runJson(product, workspace, ["ids", "--json"], idsLabel), + idsLabel, + ); + assertSameJson( + ids.files, + [{ file: "specs/A.mdx", ids: ["a", "p"] }], + `${idsLabel}: the "my-group" spec group discovered its glob's file ` + + `(SPEC 7, 7.1)`, + ); + const edgesLabel = + "T7-2 (string-literal keys) `query edges` (unfiltered)"; + const edges = decodeEdgesReport( + await runJson(product, workspace, ["query", "edges"], edgesLabel), + edgesLabel, + ); + assertEdgeSetEqual( + edges, + QUOTED_KEYS_EDGES, + `${edgesLabel}: the complete edge set carries the references edge ` + + `sourced at src/impl.ts — the "test-code" code group discovered ` + + `its glob's file (SPEC 7, 7.2, 4.5; an undiscovered code file ` + + `sources no edge, as T7-3 asserts) — and nothing stray`, + ); + + // The names resolve in a coverage profile: target "my-group" with + // boundary "test-code" reports its coverage (SPEC 7.4, 8). + const coverageLabel = "T7-2 (string-literal keys) `coverage --json`"; + const coverage = decodeCoverageReport( + await runJson( + product, + workspace, + ["coverage", "--json"], + coverageLabel, + ), + coverageLabel, + ); + const profile = coverage.profiles.find((row) => row.name === "quoted"); + if (profile === undefined) { + fail( + `${coverageLabel}: the report must carry profile "quoted" — its ` + + `target "my-group" and boundary "test-code" resolve to the ` + + `string-literal-keyed groups (SPEC 7, 7.4, 8.2); got profiles ` + + `${JSON.stringify(coverage.profiles.map((row) => row.name))}`, + ); + } + assertSameJson( + profile.covered.map((row) => ({ + identity: row.identity, + path: row.path, + })), + [{ identity: "specs/A.mdx#a", path: ["src/impl.ts", "specs/A.mdx#a"] }], + `${coverageLabel} profile quoted: the "test-code" boundary's ` + + `references edge covers \`a\` over the path [code location, ` + + `target] — both quoted names resolved (SPEC 7.4, 8, 8.2)`, + ); + assertSameJson( + [...profile.uncovered].sort(), + ["specs/A.mdx#p"], + `${coverageLabel} profile quoted: \`p\`, with no boundary edge into ` + + `it, is uncovered — the target set is the quoted spec group's ` + + `leaves (SPEC 7.4, 8.1, 8.2)`, + ); + + // The name resolves in a policy selector: { group: "my-group" } + // matches the group's nodes (SPEC 7.5) — the staged depends edge, + // both endpoints "my-group" nodes (premise pinned by the edge-set + // equality above), is the forbidden rule's one violation. + const checkLabel = "T7-2 (string-literal keys) `check --json`"; + const checkResult = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${checkLabel} — the forbidden rule's selectors match through the ` + + `string-literal group name, so the depends edge violates it and ` + + `check exits 1 (SPEC 7.5, 14.12, 12.0)`, + ); + const checkFindings = decodeFindingsReport( + parseJsonStdout(checkResult, checkLabel), + checkLabel, + ).findings; + assertConditionCounts(checkFindings, { "14.12": 1 }, checkLabel); + assertSameJson( + checkFindings.map((finding) => ({ + locations: finding.locations, + path: finding.path, + identities: finding.identities, + })), + [ + { + locations: [], + path: null, + identities: [ + "no-internal-deps", + "specs/A.mdx#p", + "depends", + "specs/A.mdx#a", + ], + }, + ], + `${checkLabel}: the one policy finding names the rule and the ` + + `offending edge — identities in order rule name, source, kind ` + + `token, target; no in-source locations, no concerned path ` + + `(SPEC 7.5, 14.12, 12.7): { group: "my-group" } matched the ` + + `quoted group's nodes`, + ); + }); }, }); From 60e608bb7c18b6ae88cf8bdeddf3daf48cbc9027 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 17:56:51 +0000 Subject: [PATCH 066/226] sdg(phase-9): add T7.5-5's literal-$ forms arms (FP-021) Six arms: $0, trailing $, and $ before a non-digit, staged once in from and once in to. Build exit 0 is the load-without-14.14 assertion; exact 14.12 finding sets over capture/dropped-$/one-byte-wildcard/regex-anchor bait paths assert literal-bytes-only matching. The trailing-$ to arm pins its anchor-bait edge via query edges, then asserts check exit 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 36 ++- test/suite/registry/section-7.4-7.5.ts | 377 ++++++++++++++++++++++++- 2 files changed, 409 insertions(+), 4 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 4ad8b54..c8ebaa5 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -707,11 +707,45 @@ A "new test T<x>" task always means, in one change: test:self` unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G).] -- [ ] FP-021 — T7.5-5: add the literal-`$` forms arm. [R1 #24; TEST-SPEC +- [x] FP-021 — T7.5-5: add the literal-`$` forms arm. [R1 #24; TEST-SPEC §7.5, SPEC 14.14] `test/suite/registry/section-7.4-7.5.ts`: stage `$0`, trailing `$`, and `$` before a non-digit, each in `from` and in `to`; assert they load without 14.14 and match only the literal bytes. + [Done 2026-08-11: six arms (e)-(j) appended to T7.5-5 — the three forms + staged once in `from` and once in `to` per TEST-SPEC's "one arm each". + Load-without-14.14 is each arm's `build` exit 0 (14.14 is load-enforced by + every command; a capture-reading product refuses the `to`-side `$0`/ + trailing-`$` arms as referencing an absent capture and dies there); + matching-only-the-literal-bytes is the exact 14.12 finding set over bait: + every fixture stages, beside the literal-byte path, the paths a capture + reading (`src/ab.ts` for `src/a$0.ts` — the spec's own example), + dropped-`$` reading (`a0.ts`/`ax.mdx`/`t0.mdx`/`tz.mdx`), one-byte-wildcard + reading (`aQx.mdx`/`tQz.mdx`), or regex-anchor reading (`src/end` for + `src/end$`) would match instead, each bearing a same-shaped edge. The + trailing-`$`-in-`from` arm stages the `$`-suffixed name as a code source + under the extension-free glob `src/*` (a spec source always ends `.mdx`, + 14.19; SPEC 7.2 restricts code groups by glob alone); the + trailing-`$`-in-`to` arm (`tgt/T.mdx$`) can match no discovered target, so + it pins the anchor-bait edge's presence via `query edges` (T7-3's + premise precedent) then asserts plain `check` exit 0 — zero findings. + Title and module header extended; traceability unchanged (["7.5"] — + T7.5-5 is in no TEST-SPEC 14 staging record, the T7.5-3/-4 precedent); no + certification scope. Verified: direct CLI probes of all six stagings + against the built product returned exactly the expected sets in the old + finding shape (the matcher is already literal on every form; all six + builds exit 0), and guarded solo executions of each arm body ran (e), + (f), (g), (i), (j) green through build/check-exit and red exactly at the + form-exact 12.7 findings decode ("expected no member \"condition\"" — the + FP-001-class product gap) while (h) passed in full, so each finding- + bearing arm is red-as-diagnosed at the known product gap with its set + assertion proven satisfiable. In the suite T7.5-5 still fails at its + first arm (a)'s decode, the new arms unreached until that closes; + section-7.4-7.5 unchanged 7 failed / 1 passed (T7.4-2 green). Typecheck/ + format clean; `npm run test:self` unchanged 4 planned mid-loop reds + (certification-document ×3 → FP-091; S-1 unmapped keys → stages E/G), + S-5 and certification green (CONF-VALID 12/12, CONF-MD 8/8, CONF-CORE + 9/9, CONF-DISC 3/3, violators failing as certified).] ## Stage E — §§1–9 missing tests (new-test convention applies) diff --git a/test/suite/registry/section-7.4-7.5.ts b/test/suite/registry/section-7.4-7.5.ts index 844eaa0..1c1117a 100644 --- a/test/suite/registry/section-7.4-7.5.ts +++ b/test/suite/registry/section-7.4-7.5.ts @@ -60,6 +60,20 @@ // `*`, whole-path regex) so the exact finding set pins the captured tuple. // Determinism of the shortest-match disambiguation runs the identical // `check --json` twice and asserts byte-identical outputs (H-6). +// - T7.5-5 literal-`$` forms: `build` succeeding on each arm IS the +// load-without-14.14 observation — configuration validity is enforced at +// load by every command (SPEC 7, 14.14), and a capture-reading product +// refuses the `to`-side arms as referencing an absent capture, exit 2. +// Matching-only-the-literal-bytes is the exact policy-finding set over +// staged bait: beside each literal-byte path, the fixtures stage the paths +// a capture reading, a dropped-`$` reading, a one-byte-wildcard reading, +// or a regex-anchor reading would match instead, each bearing an edge of +// the same shape. The trailing-`$`-in-`to` arm expects zero findings — +// plain `check` exit 0 (any finding causes exit 1, 12.0/14.12) — with the +// anchor-bait edge's presence pinned first via `query edges`, so the +// no-findings observation is not vacuous; no discovered target can spell a +// trailing-`$` path (a spec source always ends `.mdx`, 14.19), which is +// why that arm's match observation is pure absence. // - T7.5-6 "regenerates output" is asserted by tampering with a generated // module after a first build and byte-comparing it back after a rebuild — // a product-to-itself comparison (H-4 allows those; 12.0 makes the @@ -75,6 +89,7 @@ import type { } from "../../helpers/adapters/index.js"; import { decodeCoverageReport, + decodeEdgesReport, decodeFindingsReport, } from "../../helpers/adapters/index.js"; import { @@ -91,6 +106,7 @@ import { TestWorkspace } from "../../helpers/workspace.js"; import type { WorkspaceDecl } from "../../helpers/workspace.js"; import { assertConditionCounts, + assertEdgeSetEqual, assertSameJson, buildOk, expectConfigurationError, @@ -1622,6 +1638,228 @@ W.w "m/wrong.mdx": mdxSection("w"), }; +// (e)-(j) Literal `$` forms (SPEC 7.5: a capture is exactly `$` followed by +// one digit `1`-`9` — every other `$`, `$0` and a trailing `$` included, is a +// literal byte in either pattern, never a capture or a capture violation, +// 14.14). Three forms — `$0`, a trailing `$`, and `$` before a non-digit — +// staged in `from` and in `to`, one arm each (module header: build's success +// is the load assertion; exact finding sets over bait paths are the match +// assertion). + +/** A code file bearing one top-level marker into `tgt/P.mdx#p`. */ +const CODE_MARKER_TO_P = 'import P from "../tgt/P.xspec"\n\nP.p\n'; + +// (e) `$0` in `from` — the spec's own example: `a$0.ts` matches the file +// `a$0.ts` and never `ab.ts` (a capture reading matches `ab.ts` with $0 = b — +// and `a0.ts` with $0 = 0, and `a$0.ts` itself with $0 = "$0"); a dropped-`$` +// reading matches `a0.ts`. All three files bear the same marker edge, so the +// finding set separates every reading. +const LITERAL_DOLLAR0_FROM_FILES: Readonly<Record<string, string>> = { + "xspec.config.ts": `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + tgt: ["tgt/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + }, + policy: [ + { + name: "dz", + type: "forbidden", + from: { files: "src/a$0.ts" }, + to: { group: "tgt" } + } + ] +}) +`, + "src/a$0.ts": CODE_MARKER_TO_P, + "src/ab.ts": CODE_MARKER_TO_P, + "src/a0.ts": CODE_MARKER_TO_P, + "tgt/P.mdx": mdxSection("p"), +}; + +// (f) `$0` in `to` — a capture-reading product refuses the configuration +// (`to` would reference the absent capture $0, 14.14 — the load assertion) or +// expands into `tb.mdx`; a dropped-`$` reading matches `t0.mdx`. The source +// depends on every candidate expansion's node. +const LITERAL_DOLLAR0_TO_FILES: Readonly<Record<string, string>> = { + "xspec.config.ts": `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + pre: ["pre/**/*.mdx"], + tgt: ["tgt/**/*.mdx"] + }, + policy: [ + { + name: "dz", + type: "forbidden", + from: { group: "pre" }, + to: { files: "tgt/t$0.mdx" } + } + ] +}) +`, + "pre/S.mdx": `import P from "../tgt/t$0.xspec" +import Q from "../tgt/tb.xspec" +import R from "../tgt/t0.xspec" + +<S id="s" d={[P.p, Q.q, R.r]}> +Depends on every candidate expansion's node. +</S> +`, + "tgt/t$0.mdx": mdxSection("p"), + "tgt/tb.mdx": mdxSection("q"), + "tgt/t0.mdx": mdxSection("r"), +}; + +// (g) Trailing `$` in `from` — the pattern `src/end$` matches only the +// `$`-suffixed name. The `$`-suffixed discovered file is necessarily a code +// source (a spec source always ends `.mdx`, 14.19), discovered by the +// extension-free glob `src/*` (SPEC 7.2 restricts code groups by glob alone). +// A regex-anchor reading matches `src/end` instead and misses `src/end$`. +const LITERAL_TRAILING_FROM_FILES: Readonly<Record<string, string>> = { + "xspec.config.ts": `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + tgt: ["tgt/**/*.mdx"] + }, + code: { + app: ["src/*"] + }, + policy: [ + { + name: "tr", + type: "forbidden", + from: { files: "src/end$" }, + to: { group: "tgt" } + } + ] +}) +`, + "src/end$": CODE_MARKER_TO_P, + "src/end": CODE_MARKER_TO_P, + "tgt/P.mdx": mdxSection("p"), +}; + +// (h) Trailing `$` in `to` — `tgt/T.mdx$` ends in `$`, references no absent +// capture (the load assertion), and matches no discovered target: edge +// targets are requirement nodes, whose files always end `.mdx` (14.19), so +// no path spells the trailing-`$` bytes. The staged edge into `tgt/T.mdx#t` +// is the regex-anchor bait: an anchor reading matches `tgt/T.mdx` and flags +// it; the literal reading yields zero findings, `check` exit 0. +const LITERAL_TRAILING_TO_FILES: Readonly<Record<string, string>> = { + "xspec.config.ts": `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + pre: ["pre/**/*.mdx"], + tgt: ["tgt/**/*.mdx"] + }, + policy: [ + { + name: "tr", + type: "forbidden", + from: { group: "pre" }, + to: { files: "tgt/T.mdx$" } + } + ] +}) +`, + "pre/S.mdx": `import T from "../tgt/T.xspec" + +<S id="s" d={T.t}> +Depends on the anchor-reading bait. +</S> +`, + "tgt/T.mdx": mdxSection("t"), +}; + +/** (h)'s bait edge: what a regex-anchor reading of `tgt/T.mdx$` would flag. */ +const LITERAL_TRAILING_TO_BAIT_EDGE: readonly GraphEdge[] = [ + { from: "pre/S.mdx#s", kind: "depends", to: "tgt/T.mdx#t" }, +]; + +// (i) `$` before a non-digit in `from` — `pre/a$x.mdx` matches only the +// literal name (spec files can spell mid-name `$`): a dropped-`$` or +// empty-anchor reading matches `ax.mdx`, a one-byte-wildcard reading matches +// `aQx.mdx`, and a regex reading (mid-pattern `$` unmatchable) matches +// nothing. +const LITERAL_NONDIGIT_FROM_FILES: Readonly<Record<string, string>> = { + "xspec.config.ts": `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + pre: ["pre/**/*.mdx"], + tgt: ["tgt/**/*.mdx"] + }, + policy: [ + { + name: "nd", + type: "forbidden", + from: { files: "pre/a$x.mdx" }, + to: { group: "tgt" } + } + ] +}) +`, + "pre/a$x.mdx": `import T from "../tgt/T.xspec" + +<S id="s1" d={T.t}> +Source spelling the literal bytes. +</S> +`, + "pre/ax.mdx": `import T from "../tgt/T.xspec" + +<S id="s2" d={T.t}> +Dropped-dollar bait. +</S> +`, + "pre/aQx.mdx": `import T from "../tgt/T.xspec" + +<S id="s3" d={T.t}> +One-byte-wildcard bait. +</S> +`, + "tgt/T.mdx": mdxSection("t"), +}; + +// (j) `$` before a non-digit in `to` — `tgt/t$z.mdx` loads (no capture, no +// capture violation) and matches only the literal target; baits as in (i). +const LITERAL_NONDIGIT_TO_FILES: Readonly<Record<string, string>> = { + "xspec.config.ts": `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + pre: ["pre/**/*.mdx"], + tgt: ["tgt/**/*.mdx"] + }, + policy: [ + { + name: "nd", + type: "forbidden", + from: { group: "pre" }, + to: { files: "tgt/t$z.mdx" } + } + ] +}) +`, + "pre/S.mdx": `import P from "../tgt/t$z.xspec" +import Q from "../tgt/tz.xspec" +import R from "../tgt/tQz.xspec" + +<S id="s" d={[P.p, Q.q, R.r]}> +Depends on every candidate expansion's node. +</S> +`, + "tgt/t$z.mdx": mdxSection("p"), + "tgt/tz.mdx": mdxSection("q"), + "tgt/tQz.mdx": mdxSection("r"), +}; + const CAPTURE_PAIR_EXPECTED: readonly PolicyExpectation[] = [ { rule: "pair", @@ -1639,9 +1877,12 @@ const T7_5_5 = defineProductTest({ "captures: $1-$2.ts against a-b-c.ts captures a and b-c, *$1* against " + "abc captures a (shortest-match left to right), a capture never matches " + "/ or the empty string, a to with captures matches only agreeing " + - "expansions (mirror-structure allowedOnly fixture), and the " + - "disambiguation is deterministic across repeat runs (SPEC 7.5, 14.12, " + - "12.0, H-6)", + "expansions (mirror-structure allowedOnly fixture), the disambiguation " + + "is deterministic across repeat runs, and the literal $ forms — $0, a " + + "trailing $, and $ before a non-digit, staged in from and in to, one " + + "arm each — load without 14.14 (a to containing $0 or ending in $ " + + "references no absent capture) and match exactly the paths spelling " + + "those literal bytes (SPEC 7.5, 14.12, 14.14, 12.0, H-6)", run: async (product) => { // (a) The $1-$2 tuple, plus determinism: the identical `check --json` // twice with byte-identical outputs (H-6) — the capture-dependent @@ -1729,6 +1970,136 @@ const T7_5_5 = defineProductTest({ "m/good.mdx agrees with its expansion and passes; src/evil.ts's " + "edge into m/wrong.mdx disagrees and violates)", ); + + // (e) `$0` in `from`: a$0.ts matches the file a$0.ts and never ab.ts — + // nor a0.ts (SPEC 7.5's literal-$ example; build's success is the + // load-without-14.14 half, module header). + await expectPolicyFindings( + product, + LITERAL_DOLLAR0_FROM_FILES, + [ + { + rule: "dz", + edge: { from: "src/a$0.ts", to: "tgt/P.mdx#p", kind: "references" }, + }, + ], + "T7.5-5 ($0 in from — src/a$0.ts is literal bytes: it matches the " + + "file src/a$0.ts and never src/ab.ts, which a capture reading of " + + "$0 would match, nor src/a0.ts, which a dropped-$ reading would " + + "match; SPEC 7.5, 14.14)", + ); + + // (f) `$0` in `to`: loads — references no absent capture — and matches + // only tgt/t$0.mdx. + await expectPolicyFindings( + product, + LITERAL_DOLLAR0_TO_FILES, + [ + { + rule: "dz", + edge: { from: "pre/S.mdx#s", to: "tgt/t$0.mdx#p", kind: "depends" }, + }, + ], + "T7.5-5 ($0 in to — a to containing $0 references no absent capture " + + "(a capture-reading product refuses the configuration with 14.14 " + + "and fails the build step) and matches only the literal " + + "tgt/t$0.mdx target, never tgt/tb.mdx or tgt/t0.mdx; SPEC 7.5, " + + "14.14)", + ); + + // (g) Trailing `$` in `from`: the pattern matches only the `$`-suffixed + // name. + await expectPolicyFindings( + product, + LITERAL_TRAILING_FROM_FILES, + [ + { + rule: "tr", + edge: { from: "src/end$", to: "tgt/P.mdx#p", kind: "references" }, + }, + ], + "T7.5-5 (trailing $ in from — src/end$ matches only the $-suffixed " + + "name src/end$, never src/end, which a regex-anchor reading would " + + "match instead; SPEC 7.5, 14.14)", + ); + + // (h) Trailing `$` in `to`: loads — ends in `$`, references no absent + // capture — and matches no discovered target (fixture comment), so the + // staged bait edge yields no finding: `check` exits 0. + await withWorkspace( + { files: LITERAL_TRAILING_TO_FILES }, + async (workspace) => { + const base = "T7.5-5 (trailing $ in to — tgt/T.mdx$)"; + await buildOk( + product, + workspace, + `${base} \`build\` — a to ending in $ references no absent ` + + `capture: the configuration loads without 14.14 (SPEC 7.5, ` + + `14.14)`, + ); + const premise = `${base} \`query edges --kinds depends\` (fixture premise)`; + assertEdgeSetEqual( + decodeEdgesReport( + await runJson( + product, + workspace, + ["query", "edges", "--kinds", "depends"], + premise, + ), + premise, + ), + LITERAL_TRAILING_TO_BAIT_EDGE, + `${premise}: the depends edge a regex-anchor reading of ` + + `tgt/T.mdx$ would flag is present, so the no-findings check ` + + `below is not vacuous (SPEC 2.2, 7.5)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${base} \`check\` — no discovered path spells the trailing-$ ` + + `bytes (edge targets are requirement nodes and a spec source ` + + `always ends .mdx, 14.19), so the literal pattern matches no ` + + `target and the staged edge yields no finding; a regex-anchor ` + + `reading flags tgt/T.mdx and exits 1 (SPEC 7.5, 14.12, 12.0)`, + ); + }, + ); + + // (i) `$` before a non-digit in `from`: pre/a$x.mdx matches only the + // literal name. + await expectPolicyFindings( + product, + LITERAL_NONDIGIT_FROM_FILES, + [ + { + rule: "nd", + edge: { from: "pre/a$x.mdx#s1", to: "tgt/T.mdx#t", kind: "depends" }, + }, + ], + "T7.5-5 ($ before a non-digit in from — pre/a$x.mdx is literal " + + "bytes: it matches the file pre/a$x.mdx and never pre/ax.mdx " + + "(dropped-$ reading) or pre/aQx.mdx (one-byte-wildcard reading); " + + "SPEC 7.5, 14.14)", + ); + + // (j) `$` before a non-digit in `to`: loads and matches only the + // literal target. + await expectPolicyFindings( + product, + LITERAL_NONDIGIT_TO_FILES, + [ + { + rule: "nd", + edge: { from: "pre/S.mdx#s", to: "tgt/t$z.mdx#p", kind: "depends" }, + }, + ], + "T7.5-5 ($ before a non-digit in to — tgt/t$z.mdx is neither a " + + "capture nor a capture violation: the configuration loads without " + + "14.14 and the pattern matches only the literal tgt/t$z.mdx " + + "target, never tgt/tz.mdx or tgt/tQz.mdx; SPEC 7.5, 14.14)", + ); }, }); From 500548c61952a87dc7b25c6fbfe07992b40ceb06 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 18:23:58 +0000 Subject: [PATCH 067/226] =?UTF-8?q?sdg(phase-9):=20implement=20T1.7-2=20?= =?UTF-8?q?=E2=80=94=20code-location=20ranges=20via=20occurrence=20records?= =?UTF-8?q?=20(FP-022)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 51 ++- test/helpers/adapters/forms.ts | 153 ++++++++ test/helpers/adapters/model.ts | 46 +++ test/self/s5-output-adapters.test.ts | 264 ++++++++++++++ test/suite/registry/section-1.6-1.7.ts | 473 ++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 6 files changed, 986 insertions(+), 2 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index c8ebaa5..ef5f6a0 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -749,13 +749,62 @@ A "new test T<x>" task always means, in one change: ## Stage E — §§1–9 missing tests (new-test convention applies) -- [ ] FP-022 — Implement T1.7-2: code-location ranges via occurrence +- [x] FP-022 — Implement T1.7-2: code-location ranges via occurrence records. [R1 #1; TEST-SPEC §1.7] First harness use of `xspec occurrences` (it is never invoked anywhere today). Assert ranges against precomputed byte offsets for: whole-file, function/class, multi-declaration variable, dotted namespace, default exports, and `@2` disambiguation. Registry module `section-1.6-1.7.ts`; map `"1.7"`. + [Done 2026-08-11: T1.7-2 registered in section-1.6-1.7.ts — one valid + workspace, eight code files each staging one sanctioned reference inside + one SPEC 4.6 unit shape, every file opening with multi-byte UTF-8 before + its constructs (byte offsets diverge from code-point/UTF-16 counts); one + bare `occurrences` invocation (11.3 is JSON-only, no `--json`) asserting + the complete nine-record document per-index against precomputed offsets: + whole-file (top-level marker: identity the path alone, range 0..byteLen), + `function fn`/`class Cls` construct ranges (the class attribution via a + `text(SPEC.alt)` property initializer — a call expression is no named + unit, so the embed attributes to the class; also the one `embeds` arm), + `handler` name-through-initializer inside `const one = 1, handler = …` + (statement excluded), dotted `namespace Outer.Inner` whole-declaration + range (the shared range pinned through the reachable unit — every body + position lies within `Inner`, and a unit's range is reachable exactly + through occurrences it sources, so bare `Outer` sources none; comment + documents the reading), `export default function named` = construct's own + range (prefix excluded) vs anonymous default = whole declaration under + unit `default` (two files — one default export per module), getter + `Pair.value` vs setter `Pair.value@2` each carrying its own construct. + The document decodes through a NEW form-exact 12.7 layer + (`decodeOccurrencesReport`/`decodeOccurrenceRecordForm` in + adapters/forms.ts, model types in model.ts): exactly + {"findings","occurrences"}, records exactly + {"file","range","kind","source","target"}, kind from the dependency-kind + vocabulary (never `contains`), `source` decoded through the S-5-guarded + three-state datum decode with `null` rejected (defined or the + unavailability marker, never null), the 5.7 total order enforced (file + path bytes, start, end; identical spans reject). S-5 gains the document's + DECODERS entry (positive controls incl. unavailable source, byte-form + referencing file, accompanying findings, same-start tie by end; 24 + targeted rejections). A pre-product fixture self-check slices every + claimed range back out of the staged bytes, so staging-arithmetic errors + fail harness-side, never as wrong-but-satisfiable expectations. + Traceability: "T1.7-2": ["1.7"] (no numbered condition asserted; 4.6/5.7/ + 11.3/12.7 context with home coverage elsewhere); in no certification + scope (CERTIFICATIONS.md Exclusions names T1.7-2 explicitly). Verified: + red-as-diagnosed exactly at the `occurrences` invocation (exit 2 "unknown + command 'occurrences'" — the whole 11.3 surface is patch-new) with the + build premise green, and a scratchpad probe of the identical staging via + `query edges --kinds references,embeds` returned byte-for-byte all nine + expected (source identity, kind, target) triples — `#default`, `#named`, + `#Outer.Inner`, `#Pair.value`, `#Pair.value@2`, `#Cls`, `#fn`, + `#handler`, whole-file `src/top.ts` — against the product's pre-existing + 4.6 attribution, so only the ranges (the arm's patch-new subject) await + the product. Section file 2 failed / 5 passed (T1.6-5 keeps its + pre-existing FP-001-class red); typecheck/format clean; `npm run + test:self` unchanged 4 planned mid-loop reds (certification-document ×3 + → FP-091; S-1's 9 unmapped keys → stages E/G), S-5 (243 tests now) and + certification green.] - [ ] FP-023 — Implement T5.7-1: occurrence units and duplicates. [R1 #2; TEST-SPEC §5.7] diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts index 5fadac2..8aeb970 100644 --- a/test/helpers/adapters/forms.ts +++ b/test/helpers/adapters/forms.ts @@ -22,6 +22,8 @@ // - the exit-2 error document {"error": …} holding one finding form (12.0) // - the three-state datum decode: plain value / `null` / // {"unavailable": true} (11.4, 12.7) +// - the occurrence-record form {"file","range","kind","source","target"} +// and the occurrences document {"findings","occurrences"} (5.7, 11.3) // - the unavailability-marker structural walk T12.7-1 relies on: no object // of any form other than the marker carries a member named "unavailable" @@ -32,11 +34,16 @@ import type { FindingLocation, FindingsReport, MarkedBytePath, + OccurrenceRecord, + OccurrenceSource, + OccurrenceSourceNode, + OccurrencesReport, PathValue, SourceRange, } from "./model.js"; import { CONDITION_CODE_TOKENS, + DEPENDENCY_EDGE_KINDS, REFUSAL_CODE_TOKENS, conditionIdentityOf, } from "./model.js"; @@ -48,6 +55,7 @@ import { expectNonEmptyString, expectNonNegativeInteger, expectObject, + expectToken, requiredKey, requiredMember, rootSite, @@ -488,6 +496,151 @@ export function decodeDatum<T>( return { state: "value", value: decodeValue(value, site) }; } +// --- the occurrences document (5.7, 11.3, 12.7) ------------------------------- + +const OCCURRENCE_RECORD_MEMBERS = [ + "file", + "range", + "kind", + "source", + "target", +] as const; + +/** The source graph node member form: `{"identity", "range"}` exactly. */ +function decodeOccurrenceSourceNode( + value: unknown, + site: DecodeSite, +): OccurrenceSourceNode { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["identity", "range"], site); + return { + identity: expectNonEmptyString( + requiredKey(obj, "identity", site), + at(site, "identity"), + ), + range: decodeRangeForm(requiredKey(obj, "range", site), at(site, "range")), + }; +} + +/** + * One reference occurrence record in the literal 12.7 form: exactly the five + * members `{"file", "range", "kind", "source", "target"}` — the referencing + * file as a 12.7 path value, the occurrence's own range, its edge kind + * (`"depends"`, `"embeds"`, or `"references"`; 5.2 — `contains` is no + * reference kind), the source graph node `{"identity", "range"}` or the + * unavailability marker where 11.2 leaves the source node's identity + * undefined (one datum, never `null`), and the resolved target's identity. + */ +export function decodeOccurrenceRecordForm( + value: unknown, + site: DecodeSite, +): OccurrenceRecord { + const obj = expectObject(value, site); + expectOnlyMembers(obj, OCCURRENCE_RECORD_MEMBERS, site); + const file = decodePathValue( + requiredKey(obj, "file", site), + at(site, "file"), + ); + const range = decodeRangeForm( + requiredKey(obj, "range", site), + at(site, "range"), + ); + const kind = expectToken( + requiredKey(obj, "kind", site), + DEPENDENCY_EDGE_KINDS, + at(site, "kind"), + ); + const sourceSite = at(site, "source"); + const sourceDatum = decodeDatum( + obj["source"], + sourceSite, + decodeOccurrenceSourceNode, + ); + if (sourceDatum.state === "null") { + formFail( + sourceSite, + 'the source graph node {"identity", "range"} or the unavailability ' + + "marker — one datum, defined or explicitly unavailable, never null " + + "(SPEC 5.7, 11.2, 12.7)", + null, + ); + } + const source: OccurrenceSource = + sourceDatum.state === "value" + ? sourceDatum.value + : { unavailable: true as const }; + const target = expectNonEmptyString( + requiredKey(obj, "target", site), + at(site, "target"), + ); + return { file, range, kind, source, target }; +} + +/** + * The pinned occurrence order (SPEC 5.7): by referencing file path bytes, + * then range start, then range end. Total and deterministic; distinct + * occurrences occupy distinct spans, so equal keys never occur. + */ +function compareOccurrenceRecords( + a: OccurrenceRecord, + b: OccurrenceRecord, +): number { + const byFile = Buffer.compare(pathValueBytes(a.file), pathValueBytes(b.file)); + if (byFile !== 0) return byFile; + if (a.range.start !== b.range.start) return a.range.start - b.range.start; + return a.range.end - b.range.end; +} + +/** + * The `occurrences` document (11.3) — `{"findings", "occurrences"}` exactly + * (SPEC 12.7): the consulted domain's findings in the pinned findings order, + * and occurrence records in occurrence order (5.7 — file path bytes, then + * range start, then range end; identical spans do not occur). Form-exact + * (H-3): 11.3 is a JSON-only surface, no adapter in the path. + */ +export function decodeOccurrencesReport( + doc: unknown, + context?: string, +): OccurrencesReport { + const site = rootSite("12.7 occurrences document", context); + const obj = expectObject(doc, site); + expectOnlyMembers(obj, ["findings", "occurrences"], site); + const findings = decodeFindingsArray( + requiredKey(obj, "findings", site), + at(site, "findings"), + ); + const occurrencesSite = at(site, "occurrences"); + const occurrences = expectArray( + requiredKey(obj, "occurrences", site), + occurrencesSite, + ).map((element, index) => + decodeOccurrenceRecordForm(element, at(occurrencesSite, index)), + ); + for (let i = 1; i < occurrences.length; i += 1) { + const order = compareOccurrenceRecords( + occurrences[i - 1]!, + occurrences[i]!, + ); + if (order === 0) { + formFail( + at(occurrencesSite, i), + "distinct occurrences occupying distinct spans — records with an " + + "identical (file, range) key do not occur (SPEC 5.7)", + obj["occurrences"], + ); + } + if (order > 0) { + formFail( + at(occurrencesSite, i), + "records in occurrence order: by referencing file path bytes, then " + + "range start, then range end (SPEC 5.7, 12.7)", + obj["occurrences"], + ); + } + } + return { findings, occurrences }; +} + // --- the unavailability-marker structural walk (T12.7-1) ----------------------- /** diff --git a/test/helpers/adapters/model.ts b/test/helpers/adapters/model.ts index 426633c..0ed20c4 100644 --- a/test/helpers/adapters/model.ts +++ b/test/helpers/adapters/model.ts @@ -29,6 +29,7 @@ export const DEPENDENCY_EDGE_KINDS = [ "embeds", "references", ] as const; +export type DependencyEdgeKind = (typeof DEPENDENCY_EDGE_KINDS)[number]; /** Change categories of SPEC.md 5.6 (T5.6-*, T9.1-1). */ export const CHANGE_CATEGORIES = [ @@ -295,6 +296,51 @@ export interface ErrorDocument { readonly error: Finding; } +/** + * An occurrence record's source graph node — one datum: the node's identity + * together with that node's own source range (SPEC.md 5.7, 1.7, 12.7). + */ +export interface OccurrenceSourceNode { + readonly identity: string; + readonly range: SourceRange; +} + +/** + * The source datum of an occurrence record: the node, or explicitly + * unavailable as one datum — identity and range withheld together — where + * 11.2 leaves the source node's identity undefined. Never `null` (12.7). + */ +export type OccurrenceSource = + OccurrenceSourceNode | { readonly unavailable: true }; + +/** + * One reference occurrence record in the literal SPEC.md 12.7 form (a + * form-exact surface, H-3): `{"file", "range", "kind", "source", "target"}` + * — the referencing file (a path value: the marked byte form where the + * path's bytes are not valid UTF-8, 12.0); the occurrence's own range; its + * edge kind (`"depends"`, `"embeds"`, or `"references"`, 5.2 — `contains` + * is no reference kind); its source graph node per 11.2; and the resolved + * target's identity (a string — no identity carries a non-UTF-8 path, 12.0). + */ +export interface OccurrenceRecord { + readonly file: PathValue; + readonly range: SourceRange; + readonly kind: DependencyEdgeKind; + readonly source: OccurrenceSource; + readonly target: string; +} + +/** + * The `occurrences` document (SPEC.md 11.3) — `{"findings", "occurrences"}` + * exactly (12.7): the consulted domain's findings, and one record per + * occurrence in occurrence order (5.7: by referencing file path bytes, then + * range start, then range end). + */ +export interface OccurrencesReport { + readonly findings: readonly Finding[]; + readonly occurrences: readonly OccurrenceRecord[]; +} + /** `coverage` (T8.2-1): all profiles by default, one when named. */ export interface CoverageReport { readonly profiles: readonly CoverageProfileReport[]; diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index 1927031..a1d897c 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -46,6 +46,7 @@ import { decodeNodeMetadataSummary, decodeNodeReport, decodeNodeIdentityRowsReport, + decodeOccurrencesReport, decodeNodeRowsReport, decodeNodeSummary, decodeNodeSummaryRowsReport, @@ -279,6 +280,46 @@ const GOOD_FINDINGS = { ], }; +// An `occurrences` document in the literal SPEC 12.7 form (a form-exact +// surface, H-3): `{"findings", "occurrences"}`, each record +// `{"file", "range", "kind", "source", "target"}` in occurrence order (5.7 — +// file path bytes, then range start, then range end). Records deliberately +// span the three reference kinds and both source states: a defined +// `{"identity", "range"}` node and the one-datum unavailability marker +// (11.2). +const GOOD_OCCURRENCES = { + findings: [], + occurrences: [ + { + file: "specs/B.mdx", + range: { start: 30, end: 47 }, + kind: "depends", + source: { + identity: "specs/B.mdx#intro", + range: { start: 10, end: 90 }, + }, + target: "specs/A.mdx#login", + }, + { + file: "src/app.ts", + range: { start: 120, end: 128 }, + kind: "references", + source: { + identity: "src/app.ts#entry", + range: { start: 80, end: 140 }, + }, + target: "specs/A.mdx#login", + }, + { + file: "src/app.ts", + range: { start: 200, end: 216 }, + kind: "embeds", + source: { unavailable: true }, + target: "specs/A.mdx#login", + }, + ], +}; + const GOOD_COVERAGE = { profiles: [ { @@ -1123,6 +1164,229 @@ const DECODERS: readonly DecoderSpec[] = [ }, ], }, + { + name: "12.7 occurrences document", + decode: decodeOccurrencesReport, + good: GOOD_OCCURRENCES, + verify: (decoded: ReturnType<typeof decodeOccurrencesReport>) => { + expect(decoded.findings).toEqual([]); + expect(decoded.occurrences).toHaveLength(3); + // The record members decode literally (form-exact, H-3) … + expect(decoded.occurrences[0]).toEqual({ + file: "specs/B.mdx", + range: { start: 30, end: 47 }, + kind: "depends", + source: { + identity: "specs/B.mdx#intro", + range: { start: 10, end: 90 }, + }, + target: "specs/A.mdx#login", + }); + expect(decoded.occurrences[1]!.kind).toBe("references"); + expect(decoded.occurrences[1]!.source).toEqual({ + identity: "src/app.ts#entry", + range: { start: 80, end: 140 }, + }); + // … and the marker decodes as the one-datum unavailability state, + // never as a defaulted node (11.2, 12.7). + expect(decoded.occurrences[2]!.source).toEqual({ unavailable: true }); + }, + alsoGood: [ + { + label: "an empty enumeration (a finding-free empty answer, 11.3)", + doc: { findings: [], occurrences: [] }, + verify: (decoded: ReturnType<typeof decodeOccurrencesReport>): void => { + expect(decoded.findings).toEqual([]); + expect(decoded.occurrences).toEqual([]); + }, + }, + { + label: "the consulted domain's findings accompany the answer (11.2)", + doc: { + findings: [structuredClone(GOOD_FINDINGS.findings[0])], + occurrences: [structuredClone(GOOD_OCCURRENCES.occurrences[0])], + }, + verify: (decoded: ReturnType<typeof decodeOccurrencesReport>): void => { + expect(decoded.findings).toHaveLength(1); + expect(decoded.findings[0]!.code).toBe("invalid-structural-id"); + }, + }, + { + label: + "a non-UTF-8 referencing file in the marked byte form (SPEC 12.0)", + doc: { + findings: [], + occurrences: [ + { + file: { bytes: "ff2f61" }, + range: { start: 4, end: 12 }, + kind: "depends", + source: { unavailable: true }, + target: "specs/A.mdx#login", + }, + ], + }, + verify: (decoded: ReturnType<typeof decodeOccurrencesReport>): void => { + expect(decoded.occurrences[0]!.file).toEqual({ bytes: "ff2f61" }); + }, + }, + { + label: + "same-start ranges break the tie by range end (5.7's stated order)", + doc: { + findings: [], + occurrences: [ + structuredClone(GOOD_OCCURRENCES.occurrences[1]), + { + ...structuredClone(GOOD_OCCURRENCES.occurrences[2]), + range: { start: 120, end: 140 }, + }, + ], + }, + }, + ], + bad: [ + { + label: "missing findings member", + doc: omit(GOOD_OCCURRENCES, "findings"), + }, + { + label: "null findings (a list-valued member is [] when empty)", + doc: put(GOOD_OCCURRENCES, null, "findings"), + }, + { + label: "missing occurrences member", + doc: omit(GOOD_OCCURRENCES, "occurrences"), + }, + { + label: "null occurrences (null never encodes emptiness, SPEC 12.7)", + doc: put(GOOD_OCCURRENCES, null, "occurrences"), + }, + { + label: "occurrences not an array", + doc: put(GOOD_OCCURRENCES, {}, "occurrences"), + }, + { + label: + "an extra member on the document (12.7: exactly " + + "{findings, occurrences})", + doc: put(GOOD_OCCURRENCES, 3, "count"), + }, + { + label: "record missing its file", + doc: omit(GOOD_OCCURRENCES, "occurrences", 0, "file"), + }, + { + label: "record missing its range", + doc: omit(GOOD_OCCURRENCES, "occurrences", 0, "range"), + }, + { + label: "record missing its kind", + doc: omit(GOOD_OCCURRENCES, "occurrences", 0, "kind"), + }, + { + label: "record missing its source (one datum, never omitted)", + doc: omit(GOOD_OCCURRENCES, "occurrences", 0, "source"), + }, + { + label: "record missing its target", + doc: omit(GOOD_OCCURRENCES, "occurrences", 0, "target"), + }, + { + label: "empty target identity", + doc: put(GOOD_OCCURRENCES, "", "occurrences", 0, "target"), + }, + { + label: "an extra member on a record (12.7: exactly the five)", + doc: put(GOOD_OCCURRENCES, "hint", "occurrences", 0, "note"), + }, + { + label: + '"contains" as a record kind (5.2: no reference occurrence ' + + "carries it)", + doc: put(GOOD_OCCURRENCES, "contains", "occurrences", 0, "kind"), + }, + { + label: + "null source (the datum is defined or explicitly unavailable, " + + "never null)", + doc: put(GOOD_OCCURRENCES, null, "occurrences", 1, "source"), + }, + { + label: "source node missing its identity", + doc: omit(GOOD_OCCURRENCES, "occurrences", 1, "source", "identity"), + }, + { + label: "source node missing its range (one datum: both together)", + doc: omit(GOOD_OCCURRENCES, "occurrences", 1, "source", "range"), + }, + { + label: "source node with an extra member", + doc: put(GOOD_OCCURRENCES, 1, "occurrences", 1, "source", "n"), + }, + { + label: + "a widened unavailability marker (12.7: the marker is exactly " + + '{"unavailable": true})', + doc: put( + GOOD_OCCURRENCES, + { unavailable: true, identity: "src/app.ts" }, + "occurrences", + 2, + "source", + ), + }, + { + label: "a bare-identity source (12.7 fixes the object form)", + doc: put( + GOOD_OCCURRENCES, + "src/app.ts#entry", + "occurrences", + 1, + "source", + ), + }, + { + label: "negative range offset", + doc: put(GOOD_OCCURRENCES, -1, "occurrences", 0, "range", "start"), + }, + { + label: + "records out of occurrence order (5.7: file path bytes, then " + + "range start, then range end)", + doc: { + findings: [], + occurrences: [ + structuredClone(GOOD_OCCURRENCES.occurrences[1]), + structuredClone(GOOD_OCCURRENCES.occurrences[0]), + ], + }, + }, + { + label: + "two records over one (file, range) key (5.7: distinct " + + "occurrences occupy distinct spans)", + doc: { + findings: [], + occurrences: [ + structuredClone(GOOD_OCCURRENCES.occurrences[0]), + structuredClone(GOOD_OCCURRENCES.occurrences[0]), + ], + }, + }, + { + label: "findings out of the pinned order inside the document", + doc: put( + GOOD_OCCURRENCES, + [ + structuredClone(GOOD_FINDINGS.findings[1]), + structuredClone(GOOD_FINDINGS.findings[0]), + ], + "findings", + ), + }, + ], + }, { name: "12.7 error document", decode: decodeErrorDocument, diff --git a/test/suite/registry/section-1.6-1.7.ts b/test/suite/registry/section-1.6-1.7.ts index 6c1a0b2..e884df3 100644 --- a/test/suite/registry/section-1.6-1.7.ts +++ b/test/suite/registry/section-1.6-1.7.ts @@ -1,5 +1,6 @@ // TEST-SPEC §1.6 (own text, subtree text, and own content) and §1.7 (source -// ranges) — SUITE-05: T1.6-1, T1.6-2, T1.6-3, T1.6-4, T1.6-5, T1.7-1. +// ranges) — SUITE-05: T1.6-1, T1.6-2, T1.6-3, T1.6-4, T1.6-5, T1.7-1, +// T1.7-2. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -18,6 +19,9 @@ import type { Finding, GraphEdge, NodeReport, + OccurrenceRecord, + OccurrenceSourceNode, + SourceRange, } from "../../helpers/adapters/index.js"; import { assertBareEdgeEndpoints, @@ -26,6 +30,7 @@ import { decodeImpactReport, decodeNextReport, decodeNodeReport, + decodeOccurrencesReport, decodeReachableReport, decodeSessionStatusReport, } from "../../helpers/adapters/index.js"; @@ -1352,6 +1357,471 @@ const T1_7_1 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T1.7-2 +// --------------------------------------------------------------------------- + +// Occurrence records (SPEC 5.7, 11.3) are the surface making every code +// unit's range reachable (SPEC 1.7): each fixture file below stages exactly +// one sanctioned TypeScript reference — a dependency marker (4.5) or a +// `text(...)` call (4.3) — inside one named-code-unit shape of SPEC 4.6, and +// the test asserts the complete `occurrences` document, a form-exact 12.7 +// surface (H-3: no adapter in the path, and a JSON-only surface, so no +// `--json` flag is passed), against precomputed byte offsets. Every file +// opens with a multi-byte UTF-8 comment (é: 1 code point, 2 bytes; 🦄: 1 code +// point / 2 UTF-16 units / 4 bytes) before its constructs, so byte offsets +// diverge from code-point and UTF-16 offsets and a product counting either +// fails. Expected ranges are composed from the same string parts the files +// are — never measured from product output — and a fixture self-check slices +// every claimed range back out of the staged bytes before the product is +// invoked, so a staging-arithmetic error fails as a harness-side diagnosis, +// never as a wrong-but-satisfiable expectation. + +const OCC_MARKER = "SPEC.req"; +const OCC_REQ_ID = "specs/R.mdx#req"; +const OCC_ALT_ID = "specs/R.mdx#alt"; + +// The referenced spec source: two sections, so the marker target and the +// `text(...)` target are distinct nodes. +const OCC_TARGET_SOURCE = [ + '<S id="req">', + "Req text.", + "</S>", + "", + '<S id="alt">', + "Alt text.", + "</S>", + "", +].join("\n"); + +/** Byte range of `span` where it follows exactly `prefix` in a file. */ +function rangeAfter(prefix: string, span: string): SourceRange { + const start = utf8Length(prefix); + return { start, end: start + utf8Length(span) }; +} + +// src/anon.ts — a default export of an ANONYMOUS construct: unit `default`, +// whose range is the WHOLE export declaration, `export` through the closing +// `}` (SPEC 1.7, 4.6). +const OCC_ANON_HEAD = + '// prélude 🦄 anon\nimport SPEC from "../specs/R.xspec";\n\n'; +const OCC_ANON_DECL_PRE = "export default function () {\n "; +const OCC_ANON_DECL = OCC_ANON_DECL_PRE + OCC_MARKER + ";\n}"; +const OCC_ANON_SOURCE = OCC_ANON_HEAD + OCC_ANON_DECL + "\n"; + +// src/cls.ts — a class declaration: the property initializer is a `text(...)` +// call (a call expression, so `greeting` is no named unit, SPEC 4.6), and the +// innermost enclosing named unit of the embed is the class itself — the +// construct binding the name, `class` through the closing `}`. +const OCC_CLS_HEAD = + '// prélude 🦄 class\nimport SPEC, { text } from "../specs/R.xspec";\n\n'; +const OCC_CLS_CALL = "text(SPEC.alt)"; +const OCC_CLS_DECL_PRE = "class Cls {\n greeting = "; +const OCC_CLS_DECL = OCC_CLS_DECL_PRE + OCC_CLS_CALL + ";\n}"; +const OCC_CLS_SOURCE = OCC_CLS_HEAD + OCC_CLS_DECL + "\n"; + +// src/fn.ts — a function declaration: the construct binding the name. +const OCC_FN_HEAD = + '// prélude 🦄 fn\nimport SPEC from "../specs/R.xspec";\n\n'; +const OCC_FN_DECL_PRE = "function fn() {\n "; +const OCC_FN_DECL = OCC_FN_DECL_PRE + OCC_MARKER + ";\n}"; +const OCC_FN_SOURCE = OCC_FN_HEAD + OCC_FN_DECL + "\n"; + +// src/multi.ts — a function-valued variable declaration inside a +// multi-declaration statement (`const one = 1, handler = () => {…};`): unit +// `handler` spans its own name through its initializer — NOT the enclosing +// statement, so `const one = 1, ` and the trailing `;` lie outside the range. +const OCC_MULTI_HEAD = + '// prélude 🦄 multi\nimport SPEC from "../specs/R.xspec";\n\n'; +const OCC_MULTI_STMT_PRE = "const one = 1, "; +const OCC_MULTI_UNIT_PRE = "handler = () => {\n "; +const OCC_MULTI_UNIT = OCC_MULTI_UNIT_PRE + OCC_MARKER + ";\n}"; +const OCC_MULTI_SOURCE = + OCC_MULTI_HEAD + OCC_MULTI_STMT_PRE + OCC_MULTI_UNIT + ";\n"; + +// src/named.ts — a default export of a NAMED construct: the unit takes that +// construct's OWN range — `function` through the closing `}`, the +// `export default ` prefix excluded (SPEC 1.7's contrast with the anonymous +// case, where the whole export declaration is the range). +const OCC_NAMED_HEAD = + '// prélude 🦄 named\nimport SPEC from "../specs/R.xspec";\n\n'; +const OCC_NAMED_EXPORT_PRE = "export default "; +const OCC_NAMED_CONSTRUCT_PRE = "function named() {\n "; +const OCC_NAMED_CONSTRUCT = OCC_NAMED_CONSTRUCT_PRE + OCC_MARKER + ";\n}"; +const OCC_NAMED_SOURCE = + OCC_NAMED_HEAD + OCC_NAMED_EXPORT_PRE + OCC_NAMED_CONSTRUCT + "\n"; + +// src/ns.ts — a dotted namespace (`namespace Outer.Inner`): one named unit +// per dot-separated name, all sharing the SINGLE namespace declaration's +// range — the one construct binding them all (SPEC 1.7, 4.6). A unit's range +// is reachable exactly through the occurrences it sources (1.7), and every +// position in the dotted declaration's body lies within `Inner`, so the +// shared construct range is pinned through the reachable unit `Outer.Inner` +// (`Outer`, deriving from the same declaration, shares this same range by +// 1.7 but encloses no position outside `Inner` and so sources no occurrence +// of its own): a product ranging the unit at anything narrower than the +// whole `namespace Outer.Inner { … }` declaration fails the byte assertion. +const OCC_NS_HEAD = + '// prélude 🦄 ns\nimport SPEC from "../specs/R.xspec";\n\n'; +const OCC_NS_DECL_PRE = "namespace Outer.Inner {\n "; +const OCC_NS_DECL = OCC_NS_DECL_PRE + OCC_MARKER + ";\n}"; +const OCC_NS_SOURCE = OCC_NS_HEAD + OCC_NS_DECL + "\n"; + +// src/pair.ts — a getter/setter pair: the same unit chain `Pair.value` +// occurs twice in document order, so the getter is `Pair.value` and the +// setter the disambiguated `Pair.value@2` (SPEC 4.6), each carrying the +// range of its OWN occurrence's construct (SPEC 1.7). +const OCC_PAIR_HEAD = + '// prélude 🦄 pair\nimport SPEC from "../specs/R.xspec";\n\n'; +const OCC_PAIR_CLASS_PRE = "class Pair {\n "; +const OCC_PAIR_GET_PRE = "get value(): number {\n "; +const OCC_PAIR_GET = OCC_PAIR_GET_PRE + OCC_MARKER + ";\n return 1;\n }"; +const OCC_PAIR_BETWEEN = "\n "; +const OCC_PAIR_SET_PRE = "set value(next: number) {\n "; +const OCC_PAIR_SET = OCC_PAIR_SET_PRE + OCC_MARKER + ";\n }"; +const OCC_PAIR_SOURCE = + OCC_PAIR_HEAD + + OCC_PAIR_CLASS_PRE + + OCC_PAIR_GET + + OCC_PAIR_BETWEEN + + OCC_PAIR_SET + + "\n}\n"; + +// src/top.ts — a top-level marker: no named unit encloses it, so it +// attributes to the file (SPEC 4.6) and the source node is the whole-file +// location — identity the path alone, range the entire file, start 0, end +// the file's byte length (SPEC 1.7). +const OCC_TOP_HEAD = + '// prélude 🦄 top\nimport SPEC from "../specs/R.xspec";\n\n'; +const OCC_TOP_SOURCE = OCC_TOP_HEAD + OCC_MARKER + ";\n"; + +/** One staged occurrence: its expected record plus fixture-self-check data. */ +interface OccurrenceArm { + readonly what: string; + /** The staged file's full content (self-check ground). */ + readonly fileSource: string; + /** The exact characters the occurrence's own range must slice to. */ + readonly occurrenceSpan: string; + /** The exact characters the source unit's range must slice to. */ + readonly unitSpan: string; + readonly record: OccurrenceRecord & { + readonly source: OccurrenceSourceNode; + }; +} + +// The complete expected enumeration, in occurrence order (SPEC 5.7: by +// referencing file path bytes — anon < cls < fn < multi < named < ns < pair +// < top — then by range start): the spec source stages no `d` prop, no MDX +// embedding, and no import, and import declarations record no occurrence +// (5.7), so the nine staged references are the workspace's only occurrences. +const OCC_EXPECTED: readonly OccurrenceArm[] = [ + { + what: + "anonymous default export — unit `default` carries the WHOLE export " + + "declaration's range (SPEC 1.7, 4.6)", + fileSource: OCC_ANON_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_ANON_DECL, + record: { + file: "src/anon.ts", + range: rangeAfter(OCC_ANON_HEAD + OCC_ANON_DECL_PRE, OCC_MARKER), + kind: "references", + source: { + identity: "src/anon.ts#default", + range: rangeAfter(OCC_ANON_HEAD, OCC_ANON_DECL), + }, + target: OCC_REQ_ID, + }, + }, + { + what: + "class declaration — the `text(...)` embed in a non-unit property " + + "initializer attributes to the class, the construct binding the name; " + + "the occurrence spans the call expression, callee through closing " + + "parenthesis (SPEC 1.7, 4.6, 5.7)", + fileSource: OCC_CLS_SOURCE, + occurrenceSpan: OCC_CLS_CALL, + unitSpan: OCC_CLS_DECL, + record: { + file: "src/cls.ts", + range: rangeAfter(OCC_CLS_HEAD + OCC_CLS_DECL_PRE, OCC_CLS_CALL), + kind: "embeds", + source: { + identity: "src/cls.ts#Cls", + range: rangeAfter(OCC_CLS_HEAD, OCC_CLS_DECL), + }, + target: OCC_ALT_ID, + }, + }, + { + what: "function declaration — the construct binding the name (SPEC 1.7, 4.6)", + fileSource: OCC_FN_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_FN_DECL, + record: { + file: "src/fn.ts", + range: rangeAfter(OCC_FN_HEAD + OCC_FN_DECL_PRE, OCC_MARKER), + kind: "references", + source: { + identity: "src/fn.ts#fn", + range: rangeAfter(OCC_FN_HEAD, OCC_FN_DECL), + }, + target: OCC_REQ_ID, + }, + }, + { + what: + "function-valued variable in a multi-declaration statement — the " + + "unit's own name through its initializer, NOT the enclosing " + + "statement (SPEC 1.7)", + fileSource: OCC_MULTI_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_MULTI_UNIT, + record: { + file: "src/multi.ts", + range: rangeAfter( + OCC_MULTI_HEAD + OCC_MULTI_STMT_PRE + OCC_MULTI_UNIT_PRE, + OCC_MARKER, + ), + kind: "references", + source: { + identity: "src/multi.ts#handler", + range: rangeAfter(OCC_MULTI_HEAD + OCC_MULTI_STMT_PRE, OCC_MULTI_UNIT), + }, + target: OCC_REQ_ID, + }, + }, + { + what: + "default export of a NAMED construct — that construct's OWN range, " + + "the `export default ` prefix excluded (SPEC 1.7)", + fileSource: OCC_NAMED_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_NAMED_CONSTRUCT, + record: { + file: "src/named.ts", + range: rangeAfter( + OCC_NAMED_HEAD + OCC_NAMED_EXPORT_PRE + OCC_NAMED_CONSTRUCT_PRE, + OCC_MARKER, + ), + kind: "references", + source: { + identity: "src/named.ts#named", + range: rangeAfter( + OCC_NAMED_HEAD + OCC_NAMED_EXPORT_PRE, + OCC_NAMED_CONSTRUCT, + ), + }, + target: OCC_REQ_ID, + }, + }, + { + what: + "dotted namespace — the nested units share the SINGLE namespace " + + "declaration's range, pinned through the reachable unit `Outer.Inner` " + + "(SPEC 1.7, 4.6)", + fileSource: OCC_NS_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_NS_DECL, + record: { + file: "src/ns.ts", + range: rangeAfter(OCC_NS_HEAD + OCC_NS_DECL_PRE, OCC_MARKER), + kind: "references", + source: { + identity: "src/ns.ts#Outer.Inner", + range: rangeAfter(OCC_NS_HEAD, OCC_NS_DECL), + }, + target: OCC_REQ_ID, + }, + }, + { + what: + "getter — the FIRST occurrence of chain `Pair.value` stays " + + "unsuffixed and carries its own construct's range (SPEC 1.7, 4.6)", + fileSource: OCC_PAIR_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_PAIR_GET, + record: { + file: "src/pair.ts", + range: rangeAfter( + OCC_PAIR_HEAD + OCC_PAIR_CLASS_PRE + OCC_PAIR_GET_PRE, + OCC_MARKER, + ), + kind: "references", + source: { + identity: "src/pair.ts#Pair.value", + range: rangeAfter(OCC_PAIR_HEAD + OCC_PAIR_CLASS_PRE, OCC_PAIR_GET), + }, + target: OCC_REQ_ID, + }, + }, + { + what: + "setter — the document-order-disambiguated `Pair.value@2` carries " + + "the range of its OWN — second — occurrence's construct (SPEC 1.7, " + + "4.6)", + fileSource: OCC_PAIR_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_PAIR_SET, + record: { + file: "src/pair.ts", + range: rangeAfter( + OCC_PAIR_HEAD + + OCC_PAIR_CLASS_PRE + + OCC_PAIR_GET + + OCC_PAIR_BETWEEN + + OCC_PAIR_SET_PRE, + OCC_MARKER, + ), + kind: "references", + source: { + identity: "src/pair.ts#Pair.value@2", + range: rangeAfter( + OCC_PAIR_HEAD + OCC_PAIR_CLASS_PRE + OCC_PAIR_GET + OCC_PAIR_BETWEEN, + OCC_PAIR_SET, + ), + }, + target: OCC_REQ_ID, + }, + }, + { + what: + "top-level marker — a whole-file location: identity the path alone, " + + "range the entire file, start 0, end the file's byte length (SPEC " + + "1.7, 4.6)", + fileSource: OCC_TOP_SOURCE, + occurrenceSpan: OCC_MARKER, + unitSpan: OCC_TOP_SOURCE, + record: { + file: "src/top.ts", + range: rangeAfter(OCC_TOP_HEAD, OCC_MARKER), + kind: "references", + source: { + identity: "src/top.ts", + range: { start: 0, end: utf8Length(OCC_TOP_SOURCE) }, + }, + target: OCC_REQ_ID, + }, + }, +]; + +/** + * Fixture self-check (harness-side, before any product invocation): the + * precomputed range must slice the staged file's bytes to exactly the span + * it claims. A failure here is a staging-arithmetic defect of this test, + * never a product failure. + */ +function assertStagedSpan( + fileSource: string, + range: SourceRange, + span: string, + what: string, +): void { + const actual = Buffer.from(fileSource, "utf8") + .subarray(range.start, range.end) + .toString("utf8"); + if (actual !== span) { + fail( + `T1.7-2 fixture self-check — ${what}: the precomputed byte range ` + + `[${String(range.start)}, ${String(range.end)}) slices the staged bytes to ` + + `${JSON.stringify(actual)}, expected ${JSON.stringify(span)} (a harness-side ` + + `staging error, not a product failure)`, + ); + } +} + +const T1_7_2 = defineProductTest({ + id: "T1.7-2", + title: + "code-location ranges via occurrence records: against precomputed byte offsets, the `source` node of a marker or TS `text(...)` occurrence carries the entire file for a whole-file location; the construct binding the name for a function and a class declaration; the unit's own name through its initializer — not the enclosing multi-declaration statement; the single dotted-namespace declaration's shared range; the named construct's own range vs the whole export declaration under unit `default` for default exports; and the second occurrence's construct for `path#unit@2` (SPEC 1.7, 4.6, 5.7, 11.3, 12.7)", + run: async (product) => { + for (const arm of OCC_EXPECTED) { + assertStagedSpan( + arm.fileSource, + arm.record.range, + arm.occurrenceSpan, + `${arm.what} — the occurrence's own span`, + ); + assertStagedSpan( + arm.fileSource, + arm.record.source.range, + arm.unitSpan, + `${arm.what} — the source unit's construct range`, + ); + } + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + "specs/R.mdx": OCC_TARGET_SOURCE, + "src/anon.ts": OCC_ANON_SOURCE, + "src/cls.ts": OCC_CLS_SOURCE, + "src/fn.ts": OCC_FN_SOURCE, + "src/multi.ts": OCC_MULTI_SOURCE, + "src/named.ts": OCC_NAMED_SOURCE, + "src/ns.ts": OCC_NS_SOURCE, + "src/pair.ts": OCC_PAIR_SOURCE, + "src/top.ts": OCC_TOP_SOURCE, + }, + }); + try { + // Premise: the workspace is valid — every staged reference is a + // sanctioned use (4.5) that resolves, so the enumeration below is + // complete and finding-free (11.2). A product disputing any staging + // judgment (the property-initializer `text(...)`, the namespace-body + // marker) fails loudly here. + await buildOk( + product, + workspace, + "T1.7-2 `build` (premise: every staged reference is sanctioned and resolves)", + ); + + const context = "T1.7-2 `occurrences`"; + const report = decodeOccurrencesReport( + await runJson(product, workspace, ["occurrences"], context), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: a complete, finding-free answer — the consulted domain ` + + `(the entire discovered set, no \`--file\`) carries no finding ` + + `(SPEC 11.2, 11.3)`, + ); + if (report.occurrences.length !== OCC_EXPECTED.length) { + fail( + `${context}: expected exactly ${String(OCC_EXPECTED.length)} occurrence ` + + `records — one per staged reference; import declarations record ` + + `none (SPEC 5.7) — got ${String(report.occurrences.length)}: ` + + JSON.stringify( + report.occurrences.map((record) => ({ + file: record.file, + range: record.range, + source: + "unavailable" in record.source + ? "unavailable" + : record.source.identity, + })), + ), + ); + } + // Per-index equality over the length-checked enumeration pins the + // occurrence order of 5.7 along with every record member. + OCC_EXPECTED.forEach((arm, index) => { + assertSameJson( + report.occurrences[index], + arm.record, + `${context} record [${String(index)}] — ${arm.what}; zero-based ` + + `byte offsets, start-inclusive end-exclusive, so code-point, ` + + `UTF-16, line/column, or 1-based ranges all fail (SPEC 1.7)`, + ); + }); + } finally { + await workspace.dispose(); + } + }, +}); + /** TEST-SPEC §1.6–1.7, in canonical ID order (SUITE-05). */ export const section16to17Tests: readonly ProductTestEntry[] = [ T1_6_1, @@ -1360,4 +1830,5 @@ export const section16to17Tests: readonly ProductTestEntry[] = [ T1_6_4, T1_6_5, T1_7_1, + T1_7_2, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 9fdcc58..c43eae5 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -111,6 +111,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T1.6-4": ["1.6"], "T1.6-5": ["1.6", "14"], "T1.7-1": ["1.7"], + "T1.7-2": ["1.7"], "T2.1-1": ["2.1"], "T2.1-2": ["2.1", "14"], "T2.1-3": ["2.1", "14"], From 4b4f5622400449ee0db9a662dd9061d5c5fe31ef Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 18:40:50 +0000 Subject: [PATCH 068/226] =?UTF-8?q?sdg(phase-9):=20implement=20T5.7-1=20?= =?UTF-8?q?=E2=80=94=20occurrence=20units=20and=20duplicates=20(FP-023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New registry module test/suite/registry/section-5.7.ts (+ thin wrapper, manifest spread, traceability "T5.7-1": ["5.7"]): one workspace staging every occurrence kind — a three-entry mixed d array, a single-reference d, an MDX {text(...)}, a TS text(...) call, a TS marker — plus the duplicate d={[BASE.a.b, BASE.a.b]} and a twice-spelled marker. Asserts the complete 11-record occurrence multiset (one per d array entry, never the array or the prop; one per embedding/call/marker with its edge kind; two per duplicate pair at distinct ranges) through the form-exact 12.7 occurrences decode, and the collapse side via the complete 17-edge query edges set. Red-as-diagnosed at the occurrences invocation (exit 2, unknown command — the 11.3 surface is patch-new); staging proven by direct edge-set probe and by shim runs (conforming answer green, per-array deviation red at the multiset). test:self unchanged: 4 planned mid-loop reds, S-1 unmapped keys 9 -> 8 ("5.7" now mapped). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 39 ++- test/suite/registry/index.ts | 2 + test/suite/registry/section-5.7.ts | 426 ++++++++++++++++++++++++++++ test/suite/registry/traceability.ts | 1 + test/suite/section-5.7.test.ts | 9 + 5 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 test/suite/registry/section-5.7.ts create mode 100644 test/suite/section-5.7.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index ef5f6a0..0b9af1f 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -806,11 +806,48 @@ A "new test T<x>" task always means, in one change: → FP-091; S-1's 9 unmapped keys → stages E/G), S-5 (243 tests now) and certification green.] -- [ ] FP-023 — Implement T5.7-1: occurrence units and duplicates. [R1 #2; +- [x] FP-023 — Implement T5.7-1: occurrence units and duplicates. [R1 #2; TEST-SPEC §5.7] One record per `d` array entry / embedding / call / marker; collapsed edges vs distinct occurrence records. New registry module for §5.7 (e.g. `test/suite/registry/section-5.7.ts` + suite wrapper); map `"5.7"`. + [Done 2026-08-11: new registry module section-5.7.ts (wrapper + section-5.7.test.ts, spread into index.ts) registering T5.7-1 — one + workspace staging all five occurrence kinds: `tri`'s three-entry mixed + `d` array (external chain, local string, external chain), `solo`'s + single-reference `d={"peer"}`, `emb`'s MDX `{text(BASE.a.b)}`, and in + src/app.ts a `text(SPEC.emb)` call, a once-spelled marker, and the + twice-spelled marker, plus TEST-SPEC's literal duplicate pair + `d={[BASE.a.b, BASE.a.b]}`. Premise `build` exit 0; bare `occurrences` + (JSON-only, no `--json`) decoded through FP-022's form-exact 12.7 + layer; findings []; the complete 11-record (file, [kind], source -> + target) multiset asserted order-free — one record per `d` array entry, + never one for the array or the prop; one per embedding/call/marker, + each carrying its edge kind; two per duplicate pair (byte-precise + spans are T5.7-2's subject, the total order T5.7-3's, decode-enforced + as 12.7 form meanwhile) — each duplicate pair additionally pinned to + exactly two records at distinct ranges, and the collapse side pinned + via unfiltered `query edges` against the complete 17-edge set (8 + contains + 5 depends + 2 embeds + 2 references — the dup pair and the + twice-spelled marker one edge each). Traceability "T5.7-1": ["5.7"] + (no numbered condition asserted; 2.2/5.2/11.3 context with home + coverage elsewhere); in no certification scope (CERTIFICATIONS.md + Exclusions: the TS-side occurrence enumerations sit behind the + tooling wall). Verified: red-as-diagnosed exactly at the `occurrences` + invocation (exit 2 "unknown command 'occurrences'" — the whole 11.3 + surface is patch-new) with the build premise green; a direct probe of + the staging against the built product returned byte-for-byte the + expected 17-edge set (both duplicate groups already collapse, per-unit + 4.6 attribution confirmed); satisfiability proven by running the + registered body via a scratch binding against a shim product (real + product + conforming occurrences answer computed from the staged + bytes) — green through every assertion — and against a + one-record-per-array/per-group deviation shim — red exactly at the + multiset assertion (teeth). Typecheck/format clean; `npm run + test:self` 4 planned mid-loop reds (certification-document ×3 → + FP-091; S-1 unmapped keys now the 8-key set {6.6, 11.2, 11.3, 11.4, + 11.5, 11.6, 12.6, 12.7} — "5.7" mapped by this task), S-5 and + certification green.] - [ ] FP-024 — Implement T5.7-2: byte-precise occurrence spans per kind. [R1 #3; TEST-SPEC §5.7] diff --git a/test/suite/registry/index.ts b/test/suite/registry/index.ts index ce3261f..1a7a1da 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -33,6 +33,7 @@ import { section51to53Tests } from "./section-5.1-5.3.js"; import { section54Tests } from "./section-5.4.js"; import { section55Tests } from "./section-5.5.js"; import { section56Tests } from "./section-5.6.js"; +import { section57Tests } from "./section-5.7.js"; import { section61Tests } from "./section-6.1.js"; import { section62Tests } from "./section-6.2.js"; import { section63Tests } from "./section-6.3.js"; @@ -95,6 +96,7 @@ export const productTestSuite = new ProductTestSuite([ ...section54Tests, ...section55Tests, ...section56Tests, + ...section57Tests, ...section61Tests, ...section62Tests, ...section63Tests, diff --git a/test/suite/registry/section-5.7.ts b/test/suite/registry/section-5.7.ts new file mode 100644 index 0000000..a8f9231 --- /dev/null +++ b/test/suite/registry/section-5.7.ts @@ -0,0 +1,426 @@ +// TEST-SPEC §5.7 (reference occurrences) — SUITE-51: T5.7-1. The section's +// remaining tests (T5.7-2, T5.7-3, T5.7-4) register here as they are +// implemented. +// +// Registered product-facing bodies (C-2 "one code path"): each builds its own +// fresh workspace (H-1), drives the product strictly as a subprocess (H-2), +// asserts exact exit codes (H-5), decodes output through the H-3 adapters — +// the `occurrences` document (SPEC 11.3) is a form-exact 12.7 surface decoded +// literally with no adapter in the path and, being JSON-only (SPEC 11), no +// `--json` flag — and rejects a product only via diagnosed assertion failures +// (H-8). +// +// SPEC 5.7: a reference occurrence is one textual spelling of a +// dependency-kind reference whose target resolves — one `d` reference (each +// entry of a `d` array separately, never the array or the prop, 2.2), one MDX +// `{text(...)}` embedding (2.3), one TypeScript `text(...)` call (4.3), or +// one TypeScript dependency marker (4.5). Edges are sets; occurrences are the +// positions behind them: duplicate references that collapse to a single edge +// each remain distinct occurrences at distinct ranges. Byte-precise +// occurrence spans are T5.7-2's subject and full record data with the total +// order T5.7-3's; T5.7-1 asserts the units — record cardinality per staged +// construct, each record's edge kind — and the duplicate contrast, so its +// occurrence-record assertions compare complete (file, kind, source, target) +// multisets, order-free, with ranges consulted only for the duplicates' +// distinctness. + +import type { + DependencyEdgeKind, + GraphEdge, + OccurrenceRecord, +} from "../../helpers/adapters/index.js"; +import { + decodeEdgesReport, + decodeOccurrencesReport, + renderPathValue, +} from "../../helpers/adapters/index.js"; +import { fail } from "../../helpers/assertions.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { + assertEdgeSetEqual, + assertSameJson, + buildOk, + runJson, +} from "./support.js"; + +// One spec group plus one code group (SPEC 7.2): TypeScript files under +// `src/` are discovered code sources, so `build` analyzes their spec-module +// usage (4.3, 4.5) — the TS half of the occurrence kinds. +const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + } +}) +`; + +// --------------------------------------------------------------------------- +// T5.7-1 — units and duplicates +// --------------------------------------------------------------------------- + +// The imported spec source: `a` with child `a.b` (the duplicate pair's +// target, TEST-SPEC's literal `d={[BASE.a.b, BASE.a.b]}` spelling) and +// `other`, so the three-entry array has distinct external targets. +const T5_7_1_BASE_SOURCE = [ + '<S id="a">', + "Alpha text.", + "", + '<S id="a.b">', + "Alpha B text.", + "</S>", + "</S>", + "", + '<S id="other">', + "Other text.", + "</S>", + "", +].join("\n"); + +// The main spec source, one section per staged MDX occurrence unit: +// - `tri`: a three-entry `d` array mixing the external chain and local +// string forms (2.2 permits mixing) — one occurrence per ENTRY, so a +// product recording one occurrence for the array or for the prop reports +// 1 where 3 are expected; +// - `solo`: a single-reference `d` (no array) — exactly one occurrence; +// - `emb`: an MDX `{text(...)}` embedding — exactly one occurrence, kind +// `embeds`; +// - `dup`: TEST-SPEC's duplicate pair `d={[BASE.a.b, BASE.a.b]}` — one +// edge, two occurrences at distinct ranges. +const T5_7_1_MAIN_SOURCE = [ + 'import BASE from "./BASE.xspec"', + "", + '<S id="peer">', + "Peer text.", + "</S>", + "", + '<S id="tri" d={[BASE.a, "peer", BASE.other]}>', + "Tri text.", + "</S>", + "", + '<S id="solo" d={"peer"}>', + "Solo text.", + "</S>", + "", + '<S id="emb">', + "Emb: {text(BASE.a.b)}", + "</S>", + "", + '<S id="dup" d={[BASE.a.b, BASE.a.b]}>', + "Dup text.", + "</S>", + "", +].join("\n"); + +// The TypeScript side, one named function per staged unit (SPEC 4.6 makes +// the source attribution determinate): a `text(...)` call (kind `embeds`), +// a single marker (kind `references`), and the twice-spelled marker — one +// edge, two occurrences at distinct ranges. The import declaration records +// no edge and no occurrence (SPEC 2.1, 5.7). +const T5_7_1_APP_SOURCE = [ + 'import SPEC, { text } from "../specs/MAIN.xspec";', + "", + "export function useText(): string {", + " return text(SPEC.emb);", + "}", + "", + "export function once(): void {", + " SPEC.tri;", + "}", + "", + "export function twice(): void {", + " SPEC.dup;", + " SPEC.dup;", + "}", + "", +].join("\n"); + +const BASE_FILE = "specs/BASE.mdx"; +const MAIN_FILE = "specs/MAIN.mdx"; +const APP_FILE = "src/app.ts"; +const A_ID = "specs/BASE.mdx#a"; +const AB_ID = "specs/BASE.mdx#a.b"; +const OTHER_ID = "specs/BASE.mdx#other"; +const PEER_ID = "specs/MAIN.mdx#peer"; +const TRI_ID = "specs/MAIN.mdx#tri"; +const SOLO_ID = "specs/MAIN.mdx#solo"; +const EMB_ID = "specs/MAIN.mdx#emb"; +const DUP_ID = "specs/MAIN.mdx#dup"; +const USE_TEXT_LOCATION = "src/app.ts#useText"; +const ONCE_LOCATION = "src/app.ts#once"; +const TWICE_LOCATION = "src/app.ts#twice"; + +/** One expected occurrence unit: its identifying data and record count. */ +interface OccurrenceUnit { + readonly what: string; + readonly file: string; + readonly kind: DependencyEdgeKind; + readonly source: string; + readonly target: string; + /** How many records the staged spelling(s) of this unit produce. */ + readonly count: number; +} + +// The workspace's complete expected occurrence multiset — 11 records. Every +// record's (file, kind, source, target) tuple is determinate from the staging +// (SPEC 5.7, 4.6, 5.4), and no two staged units share a tuple, so the +// order-free multiset comparison individuates every unit: a missing, +// phantom, per-array, per-prop, uncollapsed-edge-shaped, or mis-kinded +// record fails with the offending tuple named. +const T5_7_1_UNITS: readonly OccurrenceUnit[] = [ + { + what: "three-entry `d` array, entry 1 (external chain `BASE.a`)", + file: MAIN_FILE, + kind: "depends", + source: TRI_ID, + target: A_ID, + count: 1, + }, + { + what: 'three-entry `d` array, entry 2 (local string `"peer"`)', + file: MAIN_FILE, + kind: "depends", + source: TRI_ID, + target: PEER_ID, + count: 1, + }, + { + what: "three-entry `d` array, entry 3 (external chain `BASE.other`)", + file: MAIN_FILE, + kind: "depends", + source: TRI_ID, + target: OTHER_ID, + count: 1, + }, + { + what: "single-reference `d` (no array)", + file: MAIN_FILE, + kind: "depends", + source: SOLO_ID, + target: PEER_ID, + count: 1, + }, + { + what: "MDX `{text(...)}` embedding", + file: MAIN_FILE, + kind: "embeds", + source: EMB_ID, + target: AB_ID, + count: 1, + }, + { + what: "duplicate `d={[BASE.a.b, BASE.a.b]}` — two entries, one edge", + file: MAIN_FILE, + kind: "depends", + source: DUP_ID, + target: AB_ID, + count: 2, + }, + { + what: "TS `text(...)` call", + file: APP_FILE, + kind: "embeds", + source: USE_TEXT_LOCATION, + target: EMB_ID, + count: 1, + }, + { + what: "TS marker, spelled once", + file: APP_FILE, + kind: "references", + source: ONCE_LOCATION, + target: TRI_ID, + count: 1, + }, + { + what: "twice-spelled TS marker — two spellings, one edge", + file: APP_FILE, + kind: "references", + source: TWICE_LOCATION, + target: DUP_ID, + count: 2, + }, +]; + +// The workspace's complete edge set (SPEC 5.2): document structure gives the +// `contains` edges, and each dependency-kind unit above gives exactly ONE +// edge — the duplicate `d` pair and the twice-spelled marker collapsed +// (edges are sets), so the exact-set comparison pins the collapse side of +// the duplicate contrast (T2.2-3's and T5.2-1's home subject, asserted here +// against the same staging the occurrence records answer over). +const T5_7_1_EXPECTED_EDGES: readonly GraphEdge[] = [ + { from: BASE_FILE, to: A_ID, kind: "contains" }, + { from: A_ID, to: AB_ID, kind: "contains" }, + { from: BASE_FILE, to: OTHER_ID, kind: "contains" }, + { from: MAIN_FILE, to: PEER_ID, kind: "contains" }, + { from: MAIN_FILE, to: TRI_ID, kind: "contains" }, + { from: MAIN_FILE, to: SOLO_ID, kind: "contains" }, + { from: MAIN_FILE, to: EMB_ID, kind: "contains" }, + { from: MAIN_FILE, to: DUP_ID, kind: "contains" }, + { from: TRI_ID, to: A_ID, kind: "depends" }, + { from: TRI_ID, to: PEER_ID, kind: "depends" }, + { from: TRI_ID, to: OTHER_ID, kind: "depends" }, + { from: SOLO_ID, to: PEER_ID, kind: "depends" }, + { from: DUP_ID, to: AB_ID, kind: "depends" }, + { from: EMB_ID, to: AB_ID, kind: "embeds" }, + { from: USE_TEXT_LOCATION, to: EMB_ID, kind: "embeds" }, + { from: ONCE_LOCATION, to: TRI_ID, kind: "references" }, + { from: TWICE_LOCATION, to: DUP_ID, kind: "references" }, +]; + +/** + * Render one decoded record's identifying tuple for the order-free multiset + * comparison. Every staged path is valid UTF-8 and every source identity is + * defined (11.2), so a marked byte-form file or an unavailable source renders + * to a value no expected tuple matches and fails the comparison visibly. + */ +function renderOccurrenceUnit(record: OccurrenceRecord): string { + const source = + "unavailable" in record.source + ? "(source unavailable)" + : record.source.identity; + return `${renderPathValue(record.file)} [${record.kind}] ${source} -> ${record.target}`; +} + +/** The expected multiset, each unit expanded to its count, sorted. */ +function expectedUnitMultiset(units: readonly OccurrenceUnit[]): string[] { + return units + .flatMap((unit) => + Array<string>(unit.count).fill( + `${unit.file} [${unit.kind}] ${unit.source} -> ${unit.target}`, + ), + ) + .sort(); +} + +/** + * A duplicate pair's occurrence side: exactly two records carry the unit's + * (file, kind, source, target) tuple, and their ranges are distinct — the + * two spellings collapse to one edge yet remain two distinct occurrences at + * distinct ranges (SPEC 5.7). Distinct-span totality over the whole document + * is already decode-enforced (12.7 occurrence order); this assertion names + * the duplicate subject when a product merges the pair's positions. + */ +function assertDuplicateOccurrencePair( + records: readonly OccurrenceRecord[], + unit: OccurrenceUnit, + context: string, +): void { + const pair = records.filter( + (record) => + renderPathValue(record.file) === unit.file && + record.kind === unit.kind && + !("unavailable" in record.source) && + record.source.identity === unit.source && + record.target === unit.target, + ); + if (pair.length !== 2) { + fail( + `${context}: the ${unit.what} must yield exactly two occurrence ` + + `records for ${unit.file} [${unit.kind}] ${unit.source} -> ` + + `${unit.target} (SPEC 5.7: duplicates collapse to one edge yet ` + + `remain distinct occurrences); got ${String(pair.length)}: ` + + JSON.stringify(pair), + ); + } + const [first, second] = pair as [OccurrenceRecord, OccurrenceRecord]; + if ( + first.range.start === second.range.start && + first.range.end === second.range.end + ) { + fail( + `${context}: the ${unit.what}'s two occurrence records must lie at ` + + `distinct ranges — distinct spellings occupy distinct spans (SPEC ` + + `5.7); both report ${JSON.stringify(first.range)}`, + ); + } +} + +const T5_7_1 = defineProductTest({ + id: "T5.7-1", + title: + "one workspace spells every occurrence kind — a three-entry `d` array, a single-reference `d`, an MDX `{text(...)}`, a TS `text(...)` call, a TS marker — and `occurrences` reports one occurrence per `d` array entry (never one for the array or the prop) and one per embedding, call, and marker, each carrying its edge kind; the duplicate `d={[BASE.a.b, BASE.a.b]}` and a twice-spelled marker collapse to one edge each yet remain two distinct occurrences each, at distinct ranges (SPEC 5.7, 2.2, 5.2, 11.3)", + run: async (product) => { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + "specs/BASE.mdx": T5_7_1_BASE_SOURCE, + "specs/MAIN.mdx": T5_7_1_MAIN_SOURCE, + "src/app.ts": T5_7_1_APP_SOURCE, + }, + }); + try { + // Premise: the workspace is valid — every staged reference is a + // sanctioned spelling that resolves — so the enumeration below is + // complete and finding-free (11.2, 11.3). + await buildOk( + product, + workspace, + "T5.7-1 `build` (premise: every staged reference resolves and the workspace is valid)", + ); + + const context = "T5.7-1 `occurrences`"; + const report = decodeOccurrencesReport( + await runJson(product, workspace, ["occurrences"], context), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the consulted domain (the entire discovered set, no ` + + `\`--file\`) carries no finding (SPEC 11.2, 11.3)`, + ); + + // The complete record multiset: one record per `d` array entry — + // never one for the array or the prop (2.2) — one per embedding, + // call, and marker, each carrying its edge kind, and exactly two for + // each duplicate pair. Order-free (the occurrence ORDER is T5.7-3's + // subject; the decode already enforces it as 12.7 form). + assertSameJson( + report.occurrences.map(renderOccurrenceUnit).sort(), + expectedUnitMultiset(T5_7_1_UNITS), + `${context}: the complete (file, [kind], source -> target) record ` + + `multiset — one occurrence per \`d\` array entry, never one for ` + + `the array or the prop (SPEC 2.2, 5.7); one per MDX embedding, ` + + `TS call, and marker, each carrying its edge kind (5.2); two per ` + + `duplicate pair — so 1-per-array, 1-per-prop, dropped-duplicate, ` + + `phantom-import, or mis-kinded reporting all fail`, + ); + + // The duplicate contrast's occurrence side: two distinct records at + // distinct ranges for each collapsed pair. + const dupUnit = T5_7_1_UNITS.find((unit) => unit.source === DUP_ID)!; + const twiceUnit = T5_7_1_UNITS.find( + (unit) => unit.source === TWICE_LOCATION, + )!; + assertDuplicateOccurrencePair(report.occurrences, dupUnit, context); + assertDuplicateOccurrencePair(report.occurrences, twiceUnit, context); + + // The duplicate contrast's edge side: the same staging's complete + // edge set, the duplicate `d` pair and the twice-spelled marker each + // collapsed to a single edge (SPEC 5.2: edges are sets; occurrences + // are the positions behind them). + const edgesContext = "T5.7-1 unfiltered `query edges`"; + assertEdgeSetEqual( + decodeEdgesReport( + await runJson(product, workspace, ["query", "edges"], edgesContext), + edgesContext, + ), + T5_7_1_EXPECTED_EDGES, + `${edgesContext}: the workspace's complete edge set — the duplicate ` + + `\`d\` entries and the twice-spelled marker collapse to one edge ` + + `each while remaining two occurrences each (SPEC 2.2, 5.2, 5.7)`, + ); + } finally { + await workspace.dispose(); + } + }, +}); + +/** TEST-SPEC §5.7, in canonical ID order (SUITE-51). */ +export const section57Tests: readonly ProductTestEntry[] = [T5_7_1]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index c43eae5..7f882ee 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -186,6 +186,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T5.6-4": ["5.6"], "T5.6-5": ["5.6"], "T5.6-6": ["5.6"], + "T5.7-1": ["5.7"], "T6.1-1": ["6.1"], "T6.1-2": ["6.1"], "T6.1-3": ["6.1", "14"], diff --git a/test/suite/section-5.7.test.ts b/test/suite/section-5.7.test.ts new file mode 100644 index 0000000..35929ce --- /dev/null +++ b/test/suite/section-5.7.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §5.7 (SUITE-51): thin Vitest wrapper over the registered +// bodies — the identical bodies the certification runner executes against +// fixture products (C-2 "one code path"). Expected to fail as diagnosed +// assertion failures until the product exists (H-8). + +import { declareProductTests } from "./declare.js"; +import { section57Tests } from "./registry/section-5.7.js"; + +declareProductTests(section57Tests); From 31a170e8620379b2be01c80be691980da4962821 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 18:57:42 +0000 Subject: [PATCH 069/226] =?UTF-8?q?sdg(phase-9):=20implement=20T5.7-2=20?= =?UTF-8?q?=E2=80=94=20byte-precise=20occurrence=20spans=20per=20kind=20(F?= =?UTF-8?q?P-024)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 36 ++- test/suite/registry/section-5.7.ts | 354 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 387 insertions(+), 4 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 0b9af1f..1367001 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -849,10 +849,44 @@ A "new test T<x>" task always means, in one change: 11.5, 11.6, 12.6, 12.7} — "5.7" mapped by this task), S-5 and certification green.] -- [ ] FP-024 — Implement T5.7-2: byte-precise occurrence spans per kind. +- [x] FP-024 — Implement T5.7-2: byte-precise occurrence spans per kind. [R1 #3; TEST-SPEC §5.7] Array-entry expression only; the whole braced `{text(...)}` container; callee-through-paren; bare marker chain without `;`. + [Done 2026-08-11: T5.7-2 registered in section-5.7.ts — one workspace, + six occurrences, every span byte-asserted against precomputed offsets + composed from the same string parts the files are (the T1.7-2 discipline: + multi-byte UTF-8 before every asserted construct so byte offsets diverge + from code-point/UTF-16 counts; a pre-product fixture self-check slices + each claimed range back out of the staged bytes). Arms: the three-entry + `d` array `[BASE.x , BASE.mid , "pre"]` with whitespace on BOTH sides of + each comma — all three entry spans asserted, the middle entry the featured + no-brackets/commas/whitespace subject, the string entry spanning its + quotes; the MDX `{text(BASE.y)}` container brace-through-brace; the + import-ALIASED TS callee `t(SPEC.x)` from its `t` through `)` (SPEC 4.4's + sanctioned aliasing), `;` excluded; the marker chain `SPEC.y.leaf` alone, + indentation, `;`, and trailing comment excluded. Records identified by + their unique (file, kind, source, target) tuples — report order stays + T5.7-3's subject (decode-enforced as 12.7 form meanwhile), source-node + range data likewise; findings [] and exact count 6 pinned. Decode through + FP-022's form-exact layer (no new adapter; S-5's existing guards cover + it). Traceability "T5.7-2": ["5.7"] (no numbered condition; 1.7/3/4.4/ + 11.3 context with home coverage elsewhere); in no certification scope + (CERTIFICATIONS.md Exclusions: T5.7-1 through T5.7-4 behind the tooling + wall). Verified: red-as-diagnosed exactly at the `occurrences` invocation + (exit 2 "unknown command" — the whole 11.3 surface is patch-new) with the + fixture self-check and build premise green; staging externally validated + by direct probe of the built product — `build` exit 0 and `query edges` + byte-for-byte the six expected dependency tuples (aliased callee + attributed to src/app.ts#call included); satisfiability and teeth proven + by running the registered body against a shim product (real product + + conforming occurrences answer recomputed from staged bytes by anchored + search, independent of the module's prefix arithmetic) — green through + every assertion — and against a marker-span-includes-`;` deviation — red + exactly at the marker span assertion. Typecheck/format clean; `npm run + test:self` unchanged 4 planned mid-loop reds (certification-document ×3 + → FP-091; S-1's 8 unmapped keys → stages E/G), S-5 and certification + green.] - [ ] FP-025 — Implement T5.7-3: occurrence record data and total deterministic order. [R1 #4; TEST-SPEC §5.7, H-6] diff --git a/test/suite/registry/section-5.7.ts b/test/suite/registry/section-5.7.ts index a8f9231..462f4c0 100644 --- a/test/suite/registry/section-5.7.ts +++ b/test/suite/registry/section-5.7.ts @@ -1,5 +1,5 @@ -// TEST-SPEC §5.7 (reference occurrences) — SUITE-51: T5.7-1. The section's -// remaining tests (T5.7-2, T5.7-3, T5.7-4) register here as they are +// TEST-SPEC §5.7 (reference occurrences) — SUITE-51: T5.7-1, T5.7-2. The +// section's remaining tests (T5.7-3, T5.7-4) register here as they are // implemented. // // Registered product-facing bodies (C-2 "one code path"): each builds its own @@ -24,10 +24,12 @@ // multisets, order-free, with ranges consulted only for the duplicates' // distinctness. +import { Buffer } from "node:buffer"; import type { DependencyEdgeKind, GraphEdge, OccurrenceRecord, + SourceRange, } from "../../helpers/adapters/index.js"; import { decodeEdgesReport, @@ -422,5 +424,351 @@ const T5_7_1 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T5.7-2 — byte-precise spans per kind +// --------------------------------------------------------------------------- + +// Occurrence spans are exact per kind (SPEC 5.7): a `d` occurrence spans that +// one reference's own expression; an MDX embedding occurrence spans the +// entire `{text(...)}` expression container, brace through brace; a TS +// `text(...)` occurrence spans the whole call expression, callee through +// closing parenthesis; a marker occurrence spans the bare reference chain +// alone, exclusive of any statement terminator. Every expected range below is +// composed from the same string parts the staged files are — never measured +// from product output — and a fixture self-check slices each claimed range +// back out of the staged bytes before the product is invoked (the T1.7-2 +// discipline), so a staging-arithmetic error fails as a harness-side +// diagnosis, never as a wrong-but-satisfiable expectation. Both referencing +// files put multi-byte UTF-8 (é: 1 code point, 2 bytes; 🦄: 1 code point / 2 +// UTF-16 units / 4 bytes) before every asserted construct, so byte offsets +// diverge from code-point and UTF-16 offsets and a product counting either +// fails (SPEC 1.7). + +/** UTF-8 byte length of a composed fixture part. */ +function utf8Length(text: string): number { + return Buffer.byteLength(text, "utf8"); +} + +/** Byte range of `span` where it follows exactly `prefix` in a file. */ +function rangeAfter(prefix: string, span: string): SourceRange { + const start = utf8Length(prefix); + return { start, end: start + utf8Length(span) }; +} + +// The referenced spec source: three top-level targets plus a nested child, so +// the marker's chain is multi-segment (`SPEC.y.leaf`) and every staged +// occurrence resolves to its own distinct target. +const SPAN_BASE_SOURCE = [ + '<S id="x">', + "X text.", + "</S>", + "", + '<S id="mid">', + "Mid text.", + "</S>", + "", + '<S id="y">', + "Y text.", + "", + '<S id="y.leaf">', + "Leaf text.", + "</S>", + "</S>", + "", +].join("\n"); + +// specs/MAIN.mdx, composed from the exact parts the expected ranges cite. The +// `pre` section's multi-byte text shifts every later byte offset. `arr`'s +// three-entry `d` array spells whitespace on BOTH sides of each comma +// (` , `), so an entry span including any bracket, comma, or neighboring +// whitespace misses byte-precisely; `emb` holds the braced embedding. +const SPAN_MAIN_HEAD = + 'import BASE from "./BASE.xspec"\n\n<S id="pre">\nPrélude 🦄 text.\n</S>\n\n'; +const SPAN_ARR_TAG_PRE = '<S id="arr" d={['; +const SPAN_ARR_ENTRY_1 = "BASE.x"; +const SPAN_ARR_SEP = " , "; +const SPAN_ARR_ENTRY_2 = "BASE.mid"; +const SPAN_ARR_ENTRY_3 = '"pre"'; +const SPAN_ARR_TAG_POST = "]}>\nArr text.\n</S>\n\n"; +const SPAN_EMB_PRE = '<S id="emb">\nEmb: '; +const SPAN_EMB_CONTAINER = "{text(BASE.y)}"; +const SPAN_EMB_POST = "\n</S>\n"; +const SPAN_MAIN_SOURCE = + SPAN_MAIN_HEAD + + SPAN_ARR_TAG_PRE + + SPAN_ARR_ENTRY_1 + + SPAN_ARR_SEP + + SPAN_ARR_ENTRY_2 + + SPAN_ARR_SEP + + SPAN_ARR_ENTRY_3 + + SPAN_ARR_TAG_POST + + SPAN_EMB_PRE + + SPAN_EMB_CONTAINER + + SPAN_EMB_POST; + +// src/app.ts: the `text` export is aliased ON IMPORT (SPEC 4.4's sanctioned +// aliasing — TEST-SPEC's aliased callee `t(...)`), and each reference +// statement wears the trivia its span must exclude — leading indentation, a +// terminating `;`, and (for the marker) a trailing comment. +const SPAN_APP_HEAD = + '// prélude 🦄 spans\nimport SPEC, { text as t } from "../specs/BASE.xspec";\n\n'; +const SPAN_CALL_PRE = "export function call(): string {\n return "; +const SPAN_CALL_EXPR = "t(SPEC.x)"; +const SPAN_CALL_POST = ";\n}\n\n"; +const SPAN_MARK_PRE = "export function mark(): void {\n "; +const SPAN_MARK_CHAIN = "SPEC.y.leaf"; +const SPAN_MARK_POST = "; // trailing trivia\n}\n"; +const SPAN_APP_SOURCE = + SPAN_APP_HEAD + + SPAN_CALL_PRE + + SPAN_CALL_EXPR + + SPAN_CALL_POST + + SPAN_MARK_PRE + + SPAN_MARK_CHAIN + + SPAN_MARK_POST; + +const SPAN_X_ID = "specs/BASE.mdx#x"; +const SPAN_MID_ID = "specs/BASE.mdx#mid"; +const SPAN_Y_ID = "specs/BASE.mdx#y"; +const SPAN_LEAF_ID = "specs/BASE.mdx#y.leaf"; +const SPAN_PRE_ID = "specs/MAIN.mdx#pre"; +const SPAN_ARR_ID = "specs/MAIN.mdx#arr"; +const SPAN_EMB_ID = "specs/MAIN.mdx#emb"; +const SPAN_CALL_LOCATION = "src/app.ts#call"; +const SPAN_MARK_LOCATION = "src/app.ts#mark"; + +/** + * One staged occurrence and the exact span its record must carry. The + * (file, kind, source, target) tuple is unique per arm in this staging, so it + * identifies the arm's record without leaning on the report order (T5.7-3's + * subject, decode-enforced as 12.7 form meanwhile); the source node's own + * range datum is likewise T5.7-3's subject, consulted here only as identity. + */ +interface SpanArm { + readonly what: string; + /** The staged file's full content (fixture self-check ground). */ + readonly fileSource: string; + /** The exact characters the occurrence's own range must slice to. */ + readonly span: string; + readonly file: string; + readonly kind: DependencyEdgeKind; + readonly source: string; + readonly target: string; + /** Precomputed byte range: zero-based, start-inclusive end-exclusive. */ + readonly range: SourceRange; +} + +// The complete expected enumeration — the staged references are the +// workspace's only occurrences (import declarations record none, SPEC 5.7), +// one record each, every span byte-precise. +const SPAN_ARMS: readonly SpanArm[] = [ + { + what: + "`d` array entry 1 (`BASE.x`) — the reference's own expression, the " + + "opening `[` and the following ` , ` excluded (SPEC 5.7, 2.2)", + fileSource: SPAN_MAIN_SOURCE, + span: SPAN_ARR_ENTRY_1, + file: "specs/MAIN.mdx", + kind: "depends", + source: SPAN_ARR_ID, + target: SPAN_X_ID, + range: rangeAfter(SPAN_MAIN_HEAD + SPAN_ARR_TAG_PRE, SPAN_ARR_ENTRY_1), + }, + { + what: + "`d` array MIDDLE entry (`BASE.mid`) alone — no brackets, no commas, " + + "no surrounding whitespace: the ` , ` on each side lies outside the " + + "span (SPEC 5.7, 2.2)", + fileSource: SPAN_MAIN_SOURCE, + span: SPAN_ARR_ENTRY_2, + file: "specs/MAIN.mdx", + kind: "depends", + source: SPAN_ARR_ID, + target: SPAN_MID_ID, + range: rangeAfter( + SPAN_MAIN_HEAD + SPAN_ARR_TAG_PRE + SPAN_ARR_ENTRY_1 + SPAN_ARR_SEP, + SPAN_ARR_ENTRY_2, + ), + }, + { + what: + '`d` array entry 3 (the local string `"pre"`) — the string literal ' + + "expression's own characters, quotes included, the preceding ` , ` " + + "and the closing `]}` excluded (SPEC 5.7, 2.2)", + fileSource: SPAN_MAIN_SOURCE, + span: SPAN_ARR_ENTRY_3, + file: "specs/MAIN.mdx", + kind: "depends", + source: SPAN_ARR_ID, + target: SPAN_PRE_ID, + range: rangeAfter( + SPAN_MAIN_HEAD + + SPAN_ARR_TAG_PRE + + SPAN_ARR_ENTRY_1 + + SPAN_ARR_SEP + + SPAN_ARR_ENTRY_2 + + SPAN_ARR_SEP, + SPAN_ARR_ENTRY_3, + ), + }, + { + what: + "MDX embedding — the ENTIRE braced container `{text(BASE.y)}`, " + + "opening brace through closing brace, the whole construct Markdown " + + "compilation replaces (SPEC 5.7, 3): a call-only span missing either " + + "brace fails", + fileSource: SPAN_MAIN_SOURCE, + span: SPAN_EMB_CONTAINER, + file: "specs/MAIN.mdx", + kind: "embeds", + source: SPAN_EMB_ID, + target: SPAN_Y_ID, + range: rangeAfter( + SPAN_MAIN_HEAD + + SPAN_ARR_TAG_PRE + + SPAN_ARR_ENTRY_1 + + SPAN_ARR_SEP + + SPAN_ARR_ENTRY_2 + + SPAN_ARR_SEP + + SPAN_ARR_ENTRY_3 + + SPAN_ARR_TAG_POST + + SPAN_EMB_PRE, + SPAN_EMB_CONTAINER, + ), + }, + { + what: + "TS `text(...)` call with an ALIASED callee — `t(SPEC.x)` from its " + + "`t` through the closing parenthesis, argument included, the " + + "terminating `;` excluded (SPEC 5.7, 4.3, 4.4)", + fileSource: SPAN_APP_SOURCE, + span: SPAN_CALL_EXPR, + file: "src/app.ts", + kind: "embeds", + source: SPAN_CALL_LOCATION, + target: SPAN_X_ID, + range: rangeAfter(SPAN_APP_HEAD + SPAN_CALL_PRE, SPAN_CALL_EXPR), + }, + { + what: + "TS marker — the bare reference chain `SPEC.y.leaf` alone, every " + + "segment included, the leading indentation, terminating `;`, and " + + "trailing comment all excluded (SPEC 5.7, 4.5)", + fileSource: SPAN_APP_SOURCE, + span: SPAN_MARK_CHAIN, + file: "src/app.ts", + kind: "references", + source: SPAN_MARK_LOCATION, + target: SPAN_LEAF_ID, + range: rangeAfter( + SPAN_APP_HEAD + + SPAN_CALL_PRE + + SPAN_CALL_EXPR + + SPAN_CALL_POST + + SPAN_MARK_PRE, + SPAN_MARK_CHAIN, + ), + }, +]; + +/** + * Fixture self-check (harness-side, before any product invocation): the + * precomputed range must slice the staged file's bytes to exactly the span it + * claims. A failure here is a staging-arithmetic defect of this test, never a + * product failure. + */ +function assertStagedSpan(arm: SpanArm): void { + const actual = Buffer.from(arm.fileSource, "utf8") + .subarray(arm.range.start, arm.range.end) + .toString("utf8"); + if (actual !== arm.span) { + fail( + `T5.7-2 fixture self-check — ${arm.what}: the precomputed byte range ` + + `[${String(arm.range.start)}, ${String(arm.range.end)}) slices the ` + + `staged bytes to ${JSON.stringify(actual)}, expected ` + + `${JSON.stringify(arm.span)} (a harness-side staging error, not a ` + + `product failure)`, + ); + } +} + +const T5_7_2 = defineProductTest({ + id: "T5.7-2", + title: + "byte-precise occurrence spans per kind against precomputed offsets: a `d` occurrence spans exactly that one reference's own expression — an array's middle entry alone, no brackets, commas, or surrounding whitespace; an MDX embedding occurrence spans the entire braced container `{text(...)}`, opening brace through closing brace — the whole construct compilation replaces; a TS call occurrence spans callee through closing parenthesis, argument included — an aliased callee `t(SPEC.x)` from its `t`; a marker occurrence spans the bare reference chain alone, exclusive of the statement's terminating `;` and surrounding trivia (SPEC 5.7, 1.7, 3, 4.4, 11.3)", + run: async (product) => { + for (const arm of SPAN_ARMS) assertStagedSpan(arm); + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + "specs/BASE.mdx": SPAN_BASE_SOURCE, + "specs/MAIN.mdx": SPAN_MAIN_SOURCE, + "src/app.ts": SPAN_APP_SOURCE, + }, + }); + try { + // Premise: the workspace is valid — every staged reference is a + // sanctioned spelling that resolves (the import-aliased `t` callee + // included, SPEC 4.4) — so the enumeration below is complete and + // finding-free (11.2, 11.3). + await buildOk( + product, + workspace, + "T5.7-2 `build` (premise: every staged reference is a sanctioned spelling that resolves)", + ); + + const context = "T5.7-2 `occurrences`"; + const report = decodeOccurrencesReport( + await runJson(product, workspace, ["occurrences"], context), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the consulted domain (the entire discovered set, no ` + + `\`--file\`) carries no finding (SPEC 11.2, 11.3)`, + ); + if (report.occurrences.length !== SPAN_ARMS.length) { + fail( + `${context}: expected exactly ${String(SPAN_ARMS.length)} ` + + `occurrence records — one per staged reference; the import ` + + `declarations record none (SPEC 5.7) — got ` + + `${String(report.occurrences.length)}: ` + + JSON.stringify(report.occurrences.map(renderOccurrenceUnit)), + ); + } + for (const arm of SPAN_ARMS) { + const matches = report.occurrences.filter( + (record) => + renderPathValue(record.file) === arm.file && + record.kind === arm.kind && + !("unavailable" in record.source) && + record.source.identity === arm.source && + record.target === arm.target, + ); + if (matches.length !== 1) { + fail( + `${context}: expected exactly one record for the ${arm.what} — ` + + `${arm.file} [${arm.kind}] ${arm.source} -> ${arm.target}; ` + + `got ${String(matches.length)} among ` + + JSON.stringify(report.occurrences.map(renderOccurrenceUnit)), + ); + } + assertSameJson( + matches[0]!.range, + arm.range, + `${context} — ${arm.what}: the occurrence's own range against ` + + `precomputed byte offsets — zero-based, start-inclusive ` + + `end-exclusive, so code-point, UTF-16, line/column, or 1-based ` + + `counting all fail (SPEC 1.7, 5.7)`, + ); + } + } finally { + await workspace.dispose(); + } + }, +}); + /** TEST-SPEC §5.7, in canonical ID order (SUITE-51). */ -export const section57Tests: readonly ProductTestEntry[] = [T5_7_1]; +export const section57Tests: readonly ProductTestEntry[] = [T5_7_1, T5_7_2]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 7f882ee..101fc0b 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -187,6 +187,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T5.6-5": ["5.6"], "T5.6-6": ["5.6"], "T5.7-1": ["5.7"], + "T5.7-2": ["5.7"], "T6.1-1": ["6.1"], "T6.1-2": ["6.1"], "T6.1-3": ["6.1", "14"], From 3976719a4cba6812a1d7e3f931616efdabafab2d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 19:18:12 +0000 Subject: [PATCH 070/226] =?UTF-8?q?sdg(phase-9):=20implement=20T5.7-3=20?= =?UTF-8?q?=E2=80=94=20occurrence=20record=20data=20and=20total=20order=20?= =?UTF-8?q?(FP-025)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register T5.7-3 in section-5.7.ts: a multi-file fixture (specs/Zed.mdx before specs/alpha.mdx in path-byte order — a case-folding collation reverses them — before src/app.ts) whose complete six-record occurrences document is asserted per-index against precomputed byte offsets: every record's file, own range, edge kind, source graph node as one identity-plus-range datum (nested containing section with its construct range; the root with the whole-file range for a top-level embedding; TS whole-file and innermost nested named unit wrap.deep), and resolved target identity. H-6: the identical invocation twice, stdout byte-identical. Fixture self-checks slice every claimed range from the staged bytes and re-derive the claimed sequence under the pinned comparator. Traceability: T5.7-3 -> ["5.7"]. Red-as-diagnosed at the occurrences invocation (exit 2 unknown command); staging validated against the built product via query edges; conforming shim green, three deviation shims red at their diagnosed assertions. test:self unchanged: 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 43 ++- test/suite/registry/section-5.7.ts | 494 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 530 insertions(+), 8 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 1367001..af128e9 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -888,11 +888,52 @@ A "new test T<x>" task always means, in one change: → FP-091; S-1's 8 unmapped keys → stages E/G), S-5 and certification green.] -- [ ] FP-025 — Implement T5.7-3: occurrence record data and total +- [x] FP-025 — Implement T5.7-3: occurrence record data and total deterministic order. [R1 #4; TEST-SPEC §5.7, H-6] Record members: file, range, edge kind, source node as identity-plus-range, target identity. Total order: path bytes, range start, range end. + [Done 2026-08-11: T5.7-3 registered in section-5.7.ts — three referencing + files whose paths give the byte-order clause teeth (`specs/Zed.mdx`, 0x5A, + sorts before `specs/alpha.mdx`, 0x61, while any case-folding collation + reverses the pair; `specs/` before `src/`), six occurrences asserted + per-index as the complete document — every member: file, own range, kind, + source as ONE identity-plus-range datum, target identity — against + offsets composed from the staged files' own string parts (the + T1.7-2/T5.7-2 discipline: multi-byte UTF-8 before every asserted + construct; fixture self-checks slice each claimed range back out of the + staged bytes AND re-derive the claimed sequence under the pinned + comparator). Source-datum arms: MDX nested section `zout.zin` sourcing + both a `d` and an embedding (identical datum, construct range strictly + inside the parent's), the ROOT sourcing a top-level embedding (identity + the path alone, range 0..byteLen — the T8-5 shape), TS whole-file + (top-level marker) and innermost nested named unit `wrap.deep` (the inner + declaration's own construct, never the enclosing `wrap`). H-6: the + identical `occurrences` invocation twice, stdout byte-identical + (stdoutBytes compare); no two records share a range (pairwise-distinct + expected ranges; same-start pairs are unstageable — distinct spellings + occupy distinct spans — so the comparator's range-end leg decides no + staged pair, and the decode enforces it as 12.7 form over whatever a + product emits, the T6.6-4 latitude treatment). Traceability "T5.7-3": + ["5.7"] (no numbered condition; 1.7/4.6/11.3 context with home coverage + elsewhere); in no certification scope (CERTIFICATIONS.md Exclusions: + T5.7-1 through T5.7-4 behind the tooling wall). Verified: + red-as-diagnosed exactly at the `occurrences` invocation (exit 2 "unknown + command" — the whole 11.3 surface is patch-new) with self-checks and + build premise green; staging externally validated by direct probe of the + built product (`build` exit 0; `query edges` byte-for-byte the six + dependency edges with exactly the claimed source attributions — + root-sourced embeds, innermost nested section, whole-file and `wrap.deep` + code sources); satisfiability and teeth proven by running the registered + body against a shim product (real product + conforming occurrences answer + recomputed from staged bytes by anchored search, independent of the + module's prefix arithmetic) — green through every assertion — and against + three deviations: case-insensitive file order, outer-section source + attribution, run-to-run member-order jitter — each red at its diagnosed + assertion (order decode, record[0]'s source datum, the H-6 byte compare). + Typecheck/format clean; `npm run test:self` unchanged 4 planned mid-loop + reds (certification-document ×3 → FP-091; S-1's 8 unmapped keys → stages + E/G), S-5 and certification green.] - [ ] FP-026 — Implement T5.7-4: no-occurrence constructs and the exit-1 answer carrying the domain's findings. [R1 #5; TEST-SPEC §5.7] diff --git a/test/suite/registry/section-5.7.ts b/test/suite/registry/section-5.7.ts index 462f4c0..434d5c6 100644 --- a/test/suite/registry/section-5.7.ts +++ b/test/suite/registry/section-5.7.ts @@ -1,6 +1,5 @@ -// TEST-SPEC §5.7 (reference occurrences) — SUITE-51: T5.7-1, T5.7-2. The -// section's remaining tests (T5.7-3, T5.7-4) register here as they are -// implemented. +// TEST-SPEC §5.7 (reference occurrences) — SUITE-51: T5.7-1, T5.7-2, T5.7-3. +// The section's remaining test (T5.7-4) registers here as it is implemented. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -17,8 +16,9 @@ // one TypeScript dependency marker (4.5). Edges are sets; occurrences are the // positions behind them: duplicate references that collapse to a single edge // each remain distinct occurrences at distinct ranges. Byte-precise -// occurrence spans are T5.7-2's subject and full record data with the total -// order T5.7-3's; T5.7-1 asserts the units — record cardinality per staged +// occurrence spans are T5.7-2's subject; full record data — the source graph +// node as one identity-plus-range datum — and the total deterministic order +// are T5.7-3's; T5.7-1 asserts the units — record cardinality per staged // construct, each record's edge kind — and the duplicate contrast, so its // occurrence-record assertions compare complete (file, kind, source, target) // multisets, order-free, with ranges consulted only for the duplicates' @@ -29,6 +29,7 @@ import type { DependencyEdgeKind, GraphEdge, OccurrenceRecord, + OccurrenceSourceNode, SourceRange, } from "../../helpers/adapters/index.js"; import { @@ -36,7 +37,11 @@ import { decodeOccurrencesReport, renderPathValue, } from "../../helpers/adapters/index.js"; -import { fail } from "../../helpers/assertions.js"; +import { + assertBytesEqual, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { TestWorkspace } from "../../helpers/workspace.js"; @@ -44,6 +49,7 @@ import { assertEdgeSetEqual, assertSameJson, buildOk, + expectExit, runJson, } from "./support.js"; @@ -770,5 +776,479 @@ const T5_7_2 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T5.7-3 — record data and total deterministic order +// --------------------------------------------------------------------------- + +// Each record carries the referencing file, its own range, its edge kind, its +// source graph node as ONE identity-plus-range datum — for MDX the containing +// section with its construct range (opening tag's first character through +// closing tag's last, 1.7; the ROOT with the whole-file range for a top-level +// embedding — the T8-5 shape, SPEC 1.2/2.3), for TS the innermost enclosing +// named unit with the construct binding its name, or the file (SPEC 4.6; +// T1.7-2 owns the full unit-shape matrix) — and the resolved target's +// identity. Order is total and deterministic (SPEC 5.7): by referencing file +// path BYTES, then range start, then range end. The three referencing files +// give the byte-order clause teeth: `specs/Zed.mdx` (`Z` = 0x5A) sorts before +// `specs/alpha.mdx` (`a` = 0x61) in byte order while any case-folding or +// locale collation reverses the pair, and `specs/...` sorts before `src/...` +// (`p` = 0x70 < `r` = 0x72). The complete six-record document is asserted +// per-index — every member, byte-precise ranges — against offsets composed +// from the same string parts the staged files are (the T1.7-2/T5.7-2 +// discipline: multi-byte UTF-8 before every asserted construct so byte +// offsets diverge from code-point and UTF-16 counts; fixture self-checks +// slice every claimed range back out of the staged bytes AND re-derive the +// claimed sequence under the pinned comparator before the product is +// invoked). H-6: the identical command runs twice, byte-identical stdout. No +// two records share a range: the six expected ranges are pairwise distinct +// (distinct spellings occupy distinct spans, and no two sanctioned constructs +// share a span start, so the comparator's range-end leg decides no stageable +// pair — the decode enforces both the sharing rejection and the full +// comparator, range-end leg included, as 12.7 form over whatever a product +// emits). + +const ORD_ZED_FILE = "specs/Zed.mdx"; +const ORD_ALPHA_FILE = "specs/alpha.mdx"; +const ORD_APP_FILE = "src/app.ts"; +const ORD_ZIN_ID = "specs/Zed.mdx#zout.zin"; +const ORD_ZLOC_ID = "specs/Zed.mdx#zloc"; +const ORD_T_ID = "specs/alpha.mdx#t"; +const ORD_U_ID = "specs/alpha.mdx#u"; +const ORD_MID_ID = "specs/alpha.mdx#mid"; +const ORD_DEEP_ID = "src/app.ts#wrap.deep"; + +// specs/Zed.mdx — byte-FIRST referencing file (`Z` < `a`), three occurrences +// at increasing starts: a `d` on the NESTED section `zout.zin` (the +// containing section is the innermost, its construct range strictly inside +// the parent `zout`'s), an embedding in that same nested section's content +// (same source datum), and a top-level embedding outside any section (source +// the ROOT: identity the path alone, range the entire file). +const ORD_ZED_IMPORT = 'import ALPHA from "./alpha.xspec"\n\n'; +const ORD_ZED_PRELUDE = "Prélude 🦄 Zed.\n\n"; +const ORD_ZED_ZOUT_OPEN = '<S id="zout">\nOuter text.\n\n'; +const ORD_ZED_ZIN_TAG_PRE = '<S id="zout.zin" d={'; +const ORD_ZED_ZIN_DEP = "ALPHA.t"; +const ORD_ZED_ZIN_TAG_POST = "}>\nInner: "; +const ORD_ZED_ZIN_EMB = '{text("zloc")}'; +const ORD_ZED_ZIN_CLOSE = "\n</S>"; +const ORD_ZED_ZIN_CONSTRUCT = + ORD_ZED_ZIN_TAG_PRE + + ORD_ZED_ZIN_DEP + + ORD_ZED_ZIN_TAG_POST + + ORD_ZED_ZIN_EMB + + ORD_ZED_ZIN_CLOSE; +const ORD_ZED_ZOUT_CLOSE = "\n</S>\n\n"; +const ORD_ZED_ZLOC = '<S id="zloc">\nLocal target text.\n</S>\n\n'; +const ORD_ZED_TAIL_PRE = "Tail text.\n\n"; +const ORD_ZED_TAIL_EMB = "{text(ALPHA.u)}"; +const ORD_ZED_SOURCE = + ORD_ZED_IMPORT + + ORD_ZED_PRELUDE + + ORD_ZED_ZOUT_OPEN + + ORD_ZED_ZIN_CONSTRUCT + + ORD_ZED_ZOUT_CLOSE + + ORD_ZED_ZLOC + + ORD_ZED_TAIL_PRE + + ORD_ZED_TAIL_EMB + + "\n"; + +// specs/alpha.mdx — byte-SECOND (under a case-folding collation it would sort +// FIRST and its record would lead the enumeration): the two external targets +// `t` and `u`, plus one local-string `d` occurrence on `mid`. +const ORD_ALPHA_PRELUDE = "Prélude 🦄 alpha.\n\n"; +const ORD_ALPHA_TARGETS = + '<S id="t">\nT text.\n</S>\n\n<S id="u">\nU text.\n</S>\n\n'; +const ORD_ALPHA_MID_TAG_PRE = '<S id="mid" d={'; +const ORD_ALPHA_MID_DEP = '"u"'; +const ORD_ALPHA_MID_TAG_POST = "}>\nMid text.\n"; +const ORD_ALPHA_MID_CLOSE = "</S>"; +const ORD_ALPHA_MID_CONSTRUCT = + ORD_ALPHA_MID_TAG_PRE + + ORD_ALPHA_MID_DEP + + ORD_ALPHA_MID_TAG_POST + + ORD_ALPHA_MID_CLOSE; +const ORD_ALPHA_SOURCE = + ORD_ALPHA_PRELUDE + ORD_ALPHA_TARGETS + ORD_ALPHA_MID_CONSTRUCT + "\n"; + +// src/app.ts — byte-LAST (`src/` after `specs/`): a top-level marker (no +// named unit encloses it — the source is the whole-file location, identity +// the path alone, range 0..byte length) and a marker inside the NESTED +// function `deep` (the innermost enclosing named unit, chain `wrap.deep`, +// with the inner declaration's own construct range — not the enclosing +// `wrap`'s; SPEC 4.6, 1.7). +const ORD_APP_HEAD = + '// prélude 🦄 app\nimport SPEC from "../specs/alpha.xspec";\n\n'; +const ORD_APP_TOP_MARKER = "SPEC.t"; +const ORD_APP_TOP_POST = ";\n\n"; +const ORD_APP_WRAP_PRE = "function wrap(): void {\n "; +const ORD_APP_DEEP_PRE = "function deep(): void {\n "; +const ORD_APP_DEEP_MARKER = "SPEC.u"; +const ORD_APP_DEEP_POST = ";\n }"; +const ORD_APP_DEEP_CONSTRUCT = + ORD_APP_DEEP_PRE + ORD_APP_DEEP_MARKER + ORD_APP_DEEP_POST; +const ORD_APP_WRAP_POST = "\n deep();\n}\n"; +const ORD_APP_SOURCE = + ORD_APP_HEAD + + ORD_APP_TOP_MARKER + + ORD_APP_TOP_POST + + ORD_APP_WRAP_PRE + + ORD_APP_DEEP_CONSTRUCT + + ORD_APP_WRAP_POST; + +/** One staged occurrence: its complete expected record plus self-check data. */ +interface OrderArm { + readonly what: string; + /** The staged file's full content (self-check ground). */ + readonly fileSource: string; + /** The exact characters the occurrence's own range must slice to. */ + readonly occurrenceSpan: string; + /** The exact characters the source node's range must slice to. */ + readonly sourceSpan: string; + readonly record: OccurrenceRecord & { + readonly source: OccurrenceSourceNode; + }; +} + +// The complete expected document, in occurrence order (SPEC 5.7): file path +// bytes — Zed.mdx, then alpha.mdx, then src/app.ts — then range start. The +// staged references are the workspace's only occurrences (plain sections, +// prose, and import declarations record none). +const ORD_EXPECTED: readonly OrderArm[] = [ + { + what: + "`d={ALPHA.t}` on the NESTED section `zout.zin` — the source datum is " + + "the containing section itself: its identity plus its construct " + + "range, opening tag through closing tag, strictly inside the parent " + + "`zout`'s construct, so an outer-section attribution fails identity " + + "AND range (SPEC 5.7, 1.7, 2.2)", + fileSource: ORD_ZED_SOURCE, + occurrenceSpan: ORD_ZED_ZIN_DEP, + sourceSpan: ORD_ZED_ZIN_CONSTRUCT, + record: { + file: ORD_ZED_FILE, + range: rangeAfter( + ORD_ZED_IMPORT + + ORD_ZED_PRELUDE + + ORD_ZED_ZOUT_OPEN + + ORD_ZED_ZIN_TAG_PRE, + ORD_ZED_ZIN_DEP, + ), + kind: "depends", + source: { + identity: ORD_ZIN_ID, + range: rangeAfter( + ORD_ZED_IMPORT + ORD_ZED_PRELUDE + ORD_ZED_ZOUT_OPEN, + ORD_ZED_ZIN_CONSTRUCT, + ), + }, + target: ORD_T_ID, + }, + }, + { + what: + '`{text("zloc")}` inside the nested section\'s content — the INNERMOST ' + + "containing section (`zout.zin`, never `zout`) sources it, carrying " + + "the identical identity-plus-range datum as the sibling `d` " + + "occurrence (SPEC 5.7, 1.7, 2.3)", + fileSource: ORD_ZED_SOURCE, + occurrenceSpan: ORD_ZED_ZIN_EMB, + sourceSpan: ORD_ZED_ZIN_CONSTRUCT, + record: { + file: ORD_ZED_FILE, + range: rangeAfter( + ORD_ZED_IMPORT + + ORD_ZED_PRELUDE + + ORD_ZED_ZOUT_OPEN + + ORD_ZED_ZIN_TAG_PRE + + ORD_ZED_ZIN_DEP + + ORD_ZED_ZIN_TAG_POST, + ORD_ZED_ZIN_EMB, + ), + kind: "embeds", + source: { + identity: ORD_ZIN_ID, + range: rangeAfter( + ORD_ZED_IMPORT + ORD_ZED_PRELUDE + ORD_ZED_ZOUT_OPEN, + ORD_ZED_ZIN_CONSTRUCT, + ), + }, + target: ORD_ZLOC_ID, + }, + }, + { + what: + "top-level `{text(ALPHA.u)}` outside any section — the containing " + + "node is the ROOT: identity the file's path alone, range the entire " + + "file, start 0, end the byte length (SPEC 5.7, 1.2, 1.7, 2.3 — the " + + "T8-5 root-sourced shape)", + fileSource: ORD_ZED_SOURCE, + occurrenceSpan: ORD_ZED_TAIL_EMB, + sourceSpan: ORD_ZED_SOURCE, + record: { + file: ORD_ZED_FILE, + range: rangeAfter( + ORD_ZED_IMPORT + + ORD_ZED_PRELUDE + + ORD_ZED_ZOUT_OPEN + + ORD_ZED_ZIN_CONSTRUCT + + ORD_ZED_ZOUT_CLOSE + + ORD_ZED_ZLOC + + ORD_ZED_TAIL_PRE, + ORD_ZED_TAIL_EMB, + ), + kind: "embeds", + source: { + identity: ORD_ZED_FILE, + range: { start: 0, end: utf8Length(ORD_ZED_SOURCE) }, + }, + target: ORD_U_ID, + }, + }, + { + what: + '`d={"u"}` (local string form) on `mid` in the byte-SECOND file — ' + + "under a case-folding or locale collation `specs/alpha.mdx` would " + + "sort before `specs/Zed.mdx` and this record would lead the " + + "enumeration; file-path BYTE order places it fourth (SPEC 5.7)", + fileSource: ORD_ALPHA_SOURCE, + occurrenceSpan: ORD_ALPHA_MID_DEP, + sourceSpan: ORD_ALPHA_MID_CONSTRUCT, + record: { + file: ORD_ALPHA_FILE, + range: rangeAfter( + ORD_ALPHA_PRELUDE + ORD_ALPHA_TARGETS + ORD_ALPHA_MID_TAG_PRE, + ORD_ALPHA_MID_DEP, + ), + kind: "depends", + source: { + identity: ORD_MID_ID, + range: rangeAfter( + ORD_ALPHA_PRELUDE + ORD_ALPHA_TARGETS, + ORD_ALPHA_MID_CONSTRUCT, + ), + }, + target: ORD_U_ID, + }, + }, + { + what: + "top-level TS marker `SPEC.t` — no named unit encloses it, so the " + + "source is the whole-file location: identity the path alone, range " + + "the entire file (SPEC 4.6, 1.7; T1.7-2)", + fileSource: ORD_APP_SOURCE, + occurrenceSpan: ORD_APP_TOP_MARKER, + sourceSpan: ORD_APP_SOURCE, + record: { + file: ORD_APP_FILE, + range: rangeAfter(ORD_APP_HEAD, ORD_APP_TOP_MARKER), + kind: "references", + source: { + identity: ORD_APP_FILE, + range: { start: 0, end: utf8Length(ORD_APP_SOURCE) }, + }, + target: ORD_T_ID, + }, + }, + { + what: + "marker inside the nested function `deep` — the INNERMOST enclosing " + + "named unit sources it: identity `src/app.ts#wrap.deep` (the " + + "dot-joined chain, outermost first) with the inner declaration's own " + + "construct range, not the enclosing `wrap`'s (SPEC 4.6, 1.7; T1.7-2)", + fileSource: ORD_APP_SOURCE, + occurrenceSpan: ORD_APP_DEEP_MARKER, + sourceSpan: ORD_APP_DEEP_CONSTRUCT, + record: { + file: ORD_APP_FILE, + range: rangeAfter( + ORD_APP_HEAD + + ORD_APP_TOP_MARKER + + ORD_APP_TOP_POST + + ORD_APP_WRAP_PRE + + ORD_APP_DEEP_PRE, + ORD_APP_DEEP_MARKER, + ), + kind: "references", + source: { + identity: ORD_DEEP_ID, + range: rangeAfter( + ORD_APP_HEAD + + ORD_APP_TOP_MARKER + + ORD_APP_TOP_POST + + ORD_APP_WRAP_PRE, + ORD_APP_DEEP_CONSTRUCT, + ), + }, + target: ORD_U_ID, + }, + }, +]; + +/** + * Fixture self-check (harness-side, before any product invocation): the + * precomputed range must slice the staged file's bytes to exactly the span it + * claims. A failure here is a staging-arithmetic defect of this test, never a + * product failure. + */ +function assertOrdSpan( + fileSource: string, + range: SourceRange, + span: string, + what: string, +): void { + const actual = Buffer.from(fileSource, "utf8") + .subarray(range.start, range.end) + .toString("utf8"); + if (actual !== span) { + fail( + `T5.7-3 fixture self-check — ${what}: the precomputed byte range ` + + `[${String(range.start)}, ${String(range.end)}) slices the staged ` + + `bytes to ${JSON.stringify(actual)}, expected ${JSON.stringify(span)} ` + + `(a harness-side staging error, not a product failure)`, + ); + } +} + +/** + * Fixture self-check: the claimed expected sequence must be strictly + * increasing under the pinned occurrence comparator — file path bytes, then + * range start, then range end (SPEC 5.7). This protects the ORDER the arms + * claim exactly as the span self-checks protect their offsets: a mis-ordered + * expectation fails harness-side, never as a wrong-but-satisfiable one. + */ +function assertOrdSequenceSorted(arms: readonly OrderArm[]): void { + for (let i = 1; i < arms.length; i += 1) { + const a = arms[i - 1]!.record; + const b = arms[i]!.record; + const byFile = Buffer.compare( + Buffer.from(renderPathValue(a.file), "utf8"), + Buffer.from(renderPathValue(b.file), "utf8"), + ); + const order = + byFile !== 0 + ? byFile + : a.range.start !== b.range.start + ? a.range.start - b.range.start + : a.range.end - b.range.end; + if (order >= 0) { + fail( + `T5.7-3 fixture self-check — the expected sequence is not strictly ` + + `increasing under the pinned occurrence comparator at index ` + + `${String(i)}: ${JSON.stringify(a)} vs ${JSON.stringify(b)} ` + + `(a harness-side staging error, not a product failure)`, + ); + } + } +} + +const T5_7_3 = defineProductTest({ + id: "T5.7-3", + title: + "each occurrence record carries the referencing file, its own range, its edge kind, its source graph node as one identity-plus-range datum — the containing section for MDX with its construct range (the root with the whole-file range for a top-level embedding), the innermost enclosing named unit or the file for TS — and the resolved target's identity; order is total and deterministic: a multi-file fixture asserts file-path BYTE order (`specs/Zed.mdx` before `specs/alpha.mdx`), then range start, then range end, byte-identical across repeated runs; no two records share a range (SPEC 5.7, 1.7, 4.6, 11.3; H-6)", + run: async (product) => { + for (const arm of ORD_EXPECTED) { + assertOrdSpan( + arm.fileSource, + arm.record.range, + arm.occurrenceSpan, + `${arm.what} — the occurrence's own span`, + ); + assertOrdSpan( + arm.fileSource, + arm.record.source.range, + arm.sourceSpan, + `${arm.what} — the source node's construct range`, + ); + } + assertOrdSequenceSorted(ORD_EXPECTED); + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + [ORD_ZED_FILE]: ORD_ZED_SOURCE, + [ORD_ALPHA_FILE]: ORD_ALPHA_SOURCE, + [ORD_APP_FILE]: ORD_APP_SOURCE, + }, + }); + try { + // Premise: the workspace is valid — every staged reference is a + // sanctioned spelling that resolves (the top-level embedding and the + // nested-function marker included) — so the enumeration below is + // complete and finding-free (11.2, 11.3). A product disputing any + // staging judgment fails loudly here. + await buildOk( + product, + workspace, + "T5.7-3 `build` (premise: every staged reference is sanctioned and resolves)", + ); + + const context = "T5.7-3 `occurrences`"; + const first = await expectExit( + product, + workspace, + ["occurrences"], + 0, + `${context} (first run)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout(first, `${context} (first run)`), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the consulted domain (the entire discovered set, no ` + + `\`--file\`) carries no finding (SPEC 11.2, 11.3)`, + ); + if (report.occurrences.length !== ORD_EXPECTED.length) { + fail( + `${context}: expected exactly ${String(ORD_EXPECTED.length)} ` + + `occurrence records — one per staged reference; plain sections, ` + + `prose, and import declarations record none (SPEC 5.7) — got ` + + `${String(report.occurrences.length)}: ` + + JSON.stringify(report.occurrences.map(renderOccurrenceUnit)), + ); + } + // Per-index equality over the length-checked enumeration pins the + // total order — file path BYTES, then range start, then range end + // (SPEC 5.7: a case-folding collation surfaces alpha.mdx's record + // first and fails at index 0) — along with every record member: file, + // own range, kind, the source node's identity-plus-range datum, and + // the target identity. + ORD_EXPECTED.forEach((arm, index) => { + assertSameJson( + report.occurrences[index], + arm.record, + `${context} record [${String(index)}] — ${arm.what}; zero-based ` + + `byte offsets, start-inclusive end-exclusive (SPEC 1.7)`, + ); + }); + + // H-6 determinism: the identical invocation again, byte-identical + // stdout — order and every datum stable across repeated runs. + const second = await expectExit( + product, + workspace, + ["occurrences"], + 0, + `${context} (second run, H-6)`, + ); + assertBytesEqual( + second.stdoutBytes, + first.stdoutBytes, + `${context}: stdout of the second run vs the first — the ` + + `enumeration is total and deterministic, byte-identical across ` + + `repeated runs (SPEC 5.7, H-6)`, + ); + } finally { + await workspace.dispose(); + } + }, +}); + /** TEST-SPEC §5.7, in canonical ID order (SUITE-51). */ -export const section57Tests: readonly ProductTestEntry[] = [T5_7_1, T5_7_2]; +export const section57Tests: readonly ProductTestEntry[] = [ + T5_7_1, + T5_7_2, + T5_7_3, +]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 101fc0b..bbedb21 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -188,6 +188,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T5.6-6": ["5.6"], "T5.7-1": ["5.7"], "T5.7-2": ["5.7"], + "T5.7-3": ["5.7"], "T6.1-1": ["6.1"], "T6.1-2": ["6.1"], "T6.1-3": ["6.1", "14"], From 17e2b7e0194736051fa84ae3e1166600673f6cdc Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 19:40:50 +0000 Subject: [PATCH 071/226] =?UTF-8?q?sdg(phase-9):=20implement=20T5.7-4=20?= =?UTF-8?q?=E2=80=94=20no-occurrence=20constructs=20and=20exit-1=20finding?= =?UTF-8?q?s=20(FP-026)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One workspace stages every no-occurrence class (used/unused imports, type-only uses, shadowed chains, a dynamic spelling, unresolving spellings in all three kinds) beside three resolving spellings; build --json pins the staging premise to exactly {14.5,14.6,14.7,14.8}, the 14.6 finding's range byte-exact the full braced container; occurrences exits 1 carrying the domain's findings with records for exactly the resolving spellings, an unavailable target rejected by the form-exact decode. Traceability T5.7-4 -> ["5.7","14"]. Red-as-diagnosed at the premise findings decode (FP-001-class product gap), then the patch-new occurrences surface; satisfiability and teeth proven against a conforming shim and four deviations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 46 +++- test/suite/registry/section-5.7.ts | 360 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 404 insertions(+), 3 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index af128e9..ab4dc5a 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -935,11 +935,55 @@ A "new test T<x>" task always means, in one change: reds (certification-document ×3 → FP-091; S-1's 8 unmapped keys → stages E/G), S-5 and certification green.] -- [ ] FP-026 — Implement T5.7-4: no-occurrence constructs and the exit-1 +- [x] FP-026 — Implement T5.7-4: no-occurrence constructs and the exit-1 answer carrying the domain's findings. [R1 #5; TEST-SPEC §5.7] Imports, type-only uses, shadowed chains, dynamic/unresolving spellings produce no records; the exit-1 answer still carries the domain's findings. + [Done 2026-08-11: T5.7-4 registered in section-5.7.ts — one workspace + staging every no-occurrence class beside three resolving spellings (one + per dependency-kind surface): MDX used + never-used imports (2.1's pair), + both T4-4 type-only forms with marker-shaped/call-shaped uses, the T4.5-4 + shadowing function re-spelling the IDENTICAL statement texts `SPEC.ok;`/ + `SPEC.absent;` rooted at the local, a dynamic `` d={`ok`} `` (template + literal spelling an EXISTING id, so an evaluating product both drops the + 14.8 and emits a phantom resolved record — failing twice), an unresolving + local `d={"nope"}` (14.5), an unresolving embedding `{text(BASE.gone)}` + (14.6), an unresolving marker `SPEC.absent` (14.7). Premise pinned first: + `build --json` reports EXACTLY {14.5, 14.6, 14.7, 14.8} ×1 — so imports/ + type-only/shadowed provably trigger nothing and the resolving spellings + resolve — each finding located in its construct's byte window, the 14.6 + finding's range asserted EXACTLY the full braced container (SPEC 14's + amended pinning, the span its occurrence would occupy; one spelling → one + location). Then bare `occurrences`: exit 1 with the full answer still + emitted (11.2), the same finding assertions on the answer's findings, and + the complete record multiset exactly the three resolving tuples — + phantom records fail by count/tuple, an unavailable TARGET is rejected by + the form-exact decode itself (12.7: target is an identity string). No new + adapter (FP-022's layer; S-5 unchanged). Traceability "T5.7-4": ["5.7", + "14"] (numbered conditions asserted; 2.1/2.4/4.5/11.2/11.3 context with + home coverage elsewhere); in no certification scope (Exclusions name + T5.7-1..-4). Verified: red-as-diagnosed — in the suite T5.7-4 fails at + the premise `build --json` form-exact findings decode (the FP-001-class + product gap: old-shape `condition`-member findings), downstream arms + unreached until that closes, then red at the `occurrences` invocation + (probe: exit 2 "unknown command") and at the 14.6 container range (probe: + the current product locates the chain `BASE.gone` [248,257) where the + container is [242,259)); staging premise externally validated by direct + probe of the built product — exit 1 with EXACTLY the four old-shape + findings, correctly classified and located, nothing for imports/ + type-only/shadowed; satisfiability and teeth proven by running the + registered body via a scratch binding against a pure-synthetic conforming + shim (12.7-form documents recomputed from the staged bytes by anchored + search, independent of the module's prefix arithmetic) — green through + every assertion — and against four deviations: unavailable-target (red at + the decode's target form), phantom-dynamic resolved record (red at the + multiset), chain-only 14.6 range = the current product's precision (red + at the exact container range), exit-0-with-findings (red at the exit + assertion). Typecheck/format clean; section-5.7 now 4 registered tests, + 4 failed as diagnosed; `npm run test:self` unchanged 4 planned mid-loop + reds (certification-document ×3 → FP-091; S-1's 8 unmapped keys → stages + E/G), S-5 and certification green.] - [ ] FP-027 — Implement T6.5-7: operation-side rewrite bytes for the real move. [R1 #6; TEST-SPEC §6.5] diff --git a/test/suite/registry/section-5.7.ts b/test/suite/registry/section-5.7.ts index 434d5c6..e512a5f 100644 --- a/test/suite/registry/section-5.7.ts +++ b/test/suite/registry/section-5.7.ts @@ -1,5 +1,4 @@ -// TEST-SPEC §5.7 (reference occurrences) — SUITE-51: T5.7-1, T5.7-2, T5.7-3. -// The section's remaining test (T5.7-4) registers here as it is implemented. +// TEST-SPEC §5.7 (reference occurrences) — SUITE-51: T5.7-1 through T5.7-4. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -27,6 +26,7 @@ import { Buffer } from "node:buffer"; import type { DependencyEdgeKind, + Finding, GraphEdge, OccurrenceRecord, OccurrenceSourceNode, @@ -46,9 +46,13 @@ import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { + assertConditionCounts, assertEdgeSetEqual, + assertFindingLocated, assertSameJson, + buildFindings, buildOk, + byteWindow, expectExit, runJson, } from "./support.js"; @@ -1246,9 +1250,361 @@ const T5_7_3 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T5.7-4 — no-occurrence constructs; the exit-1 answer carries the findings +// --------------------------------------------------------------------------- + +// A construct that records no edge records no occurrence (SPEC 5.7): an +// import declaration (its binding used or not, 2.1), a binding introduced +// type-only, a chain rooted at a shadowing local declaration (4.5), and a +// reference spelling that is dynamic or does not resolve (11.2) record none. +// One workspace stages every class beside three resolving spellings — one per +// dependency-kind surface — so the complete record multiset individuates +// "records for exactly the resolving spellings": any phantom record (for an +// import, a type-only use, a shadowed chain, the dynamic spelling, or an +// unresolved one) is an extra tuple and fails the exact comparison, and a +// record carrying an unavailable TARGET is rejected by the form-exact decode +// itself (SPEC 5.7/11.2: occurrence existence turns on target resolution — an +// unresolved spelling never reports as a record with an unavailable target; +// 12.7: `target` is an identity string). The dynamic and unresolving +// spellings each carry their finding instead (14.8, 14.5–14.7), the +// unresolved spelling's position reaching consumers only through its +// finding's range — for the MDX embedding form, pinned to the FULL braced +// container, the span its occurrence would occupy (SPEC 14, T14-8's rule) — +// and the consulted domain's findings accompany the answer, exit 1 with the +// full answer document still emitted (11.2). The dynamic template literal +// spells an EXISTING id (`` `ok` ``), so a product that evaluates it instead +// of classifying it dynamic both drops the 14.8 finding and emits a phantom +// resolved record — failing twice, visibly. + +const NO_OCC_BASE_SOURCE = '<S id="a">\nA text.\n</S>\n'; +const NO_OCC_SPARE_SOURCE = '<S id="sp">\nSpare text.\n</S>\n'; + +// specs/MAIN.mdx, composed from the exact parts the expected offsets cite +// (the T5.7-2/T5.7-3 discipline): the used import (BASE — its references +// resolve), the never-used import (SPARE — valid, records no edges, 2.1), +// multi-byte UTF-8 in `ok` shifting every later byte offset, then one +// resolving `d`, the dynamic `d` (a template literal is not static, 2.4 → +// 14.8), the unresolving local-string `d` (14.5), the unresolving embedding +// (14.6), and the resolving embedding. +const NO_OCC_MAIN_HEAD = + 'import BASE from "./BASE.xspec"\n' + + 'import SPARE from "./SPARE.xspec"\n\n' + + '<S id="ok">\nPrélude 🦄 ok text.\n</S>\n\n'; +const NO_OCC_MAIN_USE = '<S id="use" d={BASE.a}>\nUse text.\n</S>\n\n'; +const NO_OCC_DYN_CONSTRUCT = '<S id="dyn" d={`ok`}>'; +const NO_OCC_DYN_POST = "\nDyn text.\n</S>\n\n"; +const NO_OCC_UN_CONSTRUCT = '<S id="un" d={"nope"}>'; +const NO_OCC_UN_POST = "\nUn text.\n</S>\n\n"; +const NO_OCC_BAD_PRE = '<S id="bad">\nBad: '; +const NO_OCC_BAD_CONTAINER = "{text(BASE.gone)}"; +const NO_OCC_BAD_POST = "\n</S>\n\n"; +const NO_OCC_EMB_PRE = '<S id="emb">\nEmb: '; +const NO_OCC_EMB_CONTAINER = "{text(BASE.a)}"; +const NO_OCC_EMB_POST = "\n</S>\n"; +const NO_OCC_MAIN_SOURCE = + NO_OCC_MAIN_HEAD + + NO_OCC_MAIN_USE + + NO_OCC_DYN_CONSTRUCT + + NO_OCC_DYN_POST + + NO_OCC_UN_CONSTRUCT + + NO_OCC_UN_POST + + NO_OCC_BAD_PRE + + NO_OCC_BAD_CONTAINER + + NO_OCC_BAD_POST + + NO_OCC_EMB_PRE + + NO_OCC_EMB_CONTAINER + + NO_OCC_EMB_POST; + +// src/app.ts: the used ordinary import, both T4-4 type-only forms (a `type` +// modifier on the declaration and on a named binding), a resolving marker in +// `keeper`, the unresolving marker in `stray` (14.7), the T4.5-4 shadowing +// function — the IDENTICAL statement texts `SPEC.ok;` and `SPEC.absent;` +// rooted at the local, recording nothing and triggering nothing — and the +// T4-4 marker-shaped/call-shaped uses of the type-only bindings (no edge, no +// occurrence, no finding: such value-level uses fall under no condition, +// SPEC 4.5). +const NO_OCC_APP_HEAD = + "// prélude 🦄 no-occurrence\n" + + 'import SPEC from "../specs/MAIN.xspec";\n' + + 'import type TSPEC from "../specs/BASE.xspec";\n' + + 'import { type text as tt } from "../specs/BASE.xspec";\n\n'; +const NO_OCC_APP_KEEPER = "export function keeper(): void {\n SPEC.ok;\n}\n\n"; +const NO_OCC_STRAY_PRE = "export function stray(): void {\n "; +const NO_OCC_STRAY_CONSTRUCT = "SPEC.absent;"; +const NO_OCC_STRAY_POST = "\n}\n\n"; +const NO_OCC_APP_TAIL = + "export function shadowScope(): string {\n" + + ' const SPEC = { ok: "shadow value", absent: "also local" };\n' + + " SPEC.ok;\n" + + " SPEC.absent;\n" + + " return SPEC.ok;\n" + + "}\n\n" + + "TSPEC.a;\ntt(TSPEC.a);\n"; +const NO_OCC_APP_SOURCE = + NO_OCC_APP_HEAD + + NO_OCC_APP_KEEPER + + NO_OCC_STRAY_PRE + + NO_OCC_STRAY_CONSTRUCT + + NO_OCC_STRAY_POST + + NO_OCC_APP_TAIL; + +// The complete expected record multiset: the three resolving spellings and +// nothing else — no record for any import declaration (binding used or +// unused), type-only use, shadowed chain, dynamic spelling, or unresolved +// spelling. +const NO_OCC_UNITS: readonly OccurrenceUnit[] = [ + { + what: "resolving `d={BASE.a}` on `use`", + file: "specs/MAIN.mdx", + kind: "depends", + source: "specs/MAIN.mdx#use", + target: "specs/BASE.mdx#a", + count: 1, + }, + { + what: "resolving MDX embedding `{text(BASE.a)}` in `emb`", + file: "specs/MAIN.mdx", + kind: "embeds", + source: "specs/MAIN.mdx#emb", + target: "specs/BASE.mdx#a", + count: 1, + }, + { + what: "resolving TS marker `SPEC.ok` in `keeper`", + file: "src/app.ts", + kind: "references", + source: "src/app.ts#keeper", + target: "specs/MAIN.mdx#ok", + count: 1, + }, +]; + +// The staged defects, exactly one finding each (SPEC 14: every condition +// reported, and nothing else — so the type-only uses, the shadowed chains, +// and the unused import provably trigger NO finding beside these four). +const NO_OCC_EXPECTED_CONDITIONS = { + "14.5": 1, + "14.6": 1, + "14.7": 1, + "14.8": 1, +} as const; + +// The unresolved MDX embedding's finding range: the FULL braced container, +// opening brace through closing brace — the span its occurrence would occupy +// (SPEC 14, 5.7; T14-8's cardinality rule cross-cited by T5.7-4). Exact, not +// windowed: a chain-only or call-only range fails. +const NO_OCC_BAD_RANGE = rangeAfter( + NO_OCC_MAIN_HEAD + + NO_OCC_MAIN_USE + + NO_OCC_DYN_CONSTRUCT + + NO_OCC_DYN_POST + + NO_OCC_UN_CONSTRUCT + + NO_OCC_UN_POST + + NO_OCC_BAD_PRE, + NO_OCC_BAD_CONTAINER, +); + +/** + * Fixture self-check (harness-side, before any product invocation): the + * precomputed container range must slice the staged file's bytes to exactly + * the braced container. A failure here is a staging-arithmetic defect of this + * test, never a product failure. + */ +function assertNoOccContainerRange(): void { + const actual = Buffer.from(NO_OCC_MAIN_SOURCE, "utf8") + .subarray(NO_OCC_BAD_RANGE.start, NO_OCC_BAD_RANGE.end) + .toString("utf8"); + if (actual !== NO_OCC_BAD_CONTAINER) { + fail( + `T5.7-4 fixture self-check: the precomputed byte range ` + + `[${String(NO_OCC_BAD_RANGE.start)}, ${String(NO_OCC_BAD_RANGE.end)}) ` + + `slices the staged bytes to ${JSON.stringify(actual)}, expected ` + + `${JSON.stringify(NO_OCC_BAD_CONTAINER)} (a harness-side staging ` + + `error, not a product failure)`, + ); + } +} + +/** + * Resolve the unique finding carrying `condition` (the caller has already + * pinned the condition multiset, so a miss here is a diagnosed count defect). + */ +function findingWithCondition( + findings: readonly Finding[], + condition: string, + context: string, +): Finding { + const matching = findings.filter( + (finding) => finding.condition === condition, + ); + if (matching.length !== 1) { + fail( + `${context}: expected exactly one condition-${condition} finding ` + + `(SPEC 14); got ${String(matching.length)} among ` + + JSON.stringify(findings.map((finding) => finding.condition)), + ); + } + return matching[0]!; +} + +/** The four staged findings: counts, files, and ranges (shared by surfaces). */ +function assertNoOccFindings( + findings: readonly Finding[], + context: string, +): void { + assertConditionCounts( + findings, + NO_OCC_EXPECTED_CONDITIONS, + `${context}: exactly the staged defects are reported — one 14.8 (the ` + + `dynamic template-literal \`d\` reference), one 14.5 (the unresolving ` + + `local \`d\`), one 14.6 (the unresolving embedding), one 14.7 (the ` + + `unresolving marker) — and NOTHING for the import declarations ` + + `(binding used and unused, SPEC 2.1), the type-only uses, or the ` + + `shadowed chains (such value-level uses fall under no condition, ` + + `SPEC 4.5)`, + ); + assertFindingLocated( + findingWithCondition(findings, "14.8", context), + { + file: "specs/MAIN.mdx", + window: byteWindow( + NO_OCC_MAIN_HEAD + NO_OCC_MAIN_USE, + NO_OCC_DYN_CONSTRUCT, + ), + }, + `${context}: the 14.8 finding locates the dynamic \`d\` spelling (SPEC 14, 2.4)`, + ); + assertFindingLocated( + findingWithCondition(findings, "14.5", context), + { + file: "specs/MAIN.mdx", + window: byteWindow( + NO_OCC_MAIN_HEAD + + NO_OCC_MAIN_USE + + NO_OCC_DYN_CONSTRUCT + + NO_OCC_DYN_POST, + NO_OCC_UN_CONSTRUCT, + ), + }, + `${context}: the 14.5 finding locates the unresolving \`d\` spelling (SPEC 14)`, + ); + assertFindingLocated( + findingWithCondition(findings, "14.7", context), + { + file: "src/app.ts", + window: byteWindow( + NO_OCC_APP_HEAD + NO_OCC_APP_KEEPER + NO_OCC_STRAY_PRE, + NO_OCC_STRAY_CONSTRUCT, + ), + }, + `${context}: the 14.7 finding locates the unresolving marker (SPEC 14, 4.5)`, + ); + // The MDX embedding form's finding range is pinned exactly: the full + // braced container, opening brace through closing brace — the span its + // occurrence would occupy (SPEC 14, 5.7). One offending spelling, one + // location; the unresolved spelling's position reaches consumers only + // through this range, never as an occurrence record. + const bad = findingWithCondition(findings, "14.6", context); + assertFindingLocated( + bad, + { file: "specs/MAIN.mdx" }, + `${context}: the 14.6 finding locates in the embedding's file (SPEC 14)`, + ); + if (bad.locations.length !== 1) { + fail( + `${context}: the 14.6 finding has exactly one offending spelling, so ` + + `exactly one location (SPEC 14 location cardinality); got ` + + `${String(bad.locations.length)}: ${JSON.stringify(bad.locations)}`, + ); + } + assertSameJson( + bad.locations[0]!.range, + NO_OCC_BAD_RANGE, + `${context}: the 14.6 finding's range is the FULL braced container ` + + `\`{text(BASE.gone)}\`, opening brace through closing brace — the ` + + `span its occurrence would occupy (SPEC 14, 5.7): a chain-only or ` + + `call-only range fails; zero-based byte offsets, start-inclusive ` + + `end-exclusive (SPEC 1.7)`, + ); +} + +const T5_7_4 = defineProductTest({ + id: "T5.7-4", + title: + "constructs that record no edge record no occurrence — an import declaration (binding used and unused), a type-only binding's marker-shaped uses, a chain rooted at a shadowing local declaration, a dynamic reference spelling and unresolving ones (each also its finding, 14.8/14.5–14.7) — so `occurrences` reports records for exactly the resolving spellings, never a record with an unavailable target, the unresolved spelling's position reaching consumers only through its finding's range (the MDX embedding form's spanning its full braced container), the answer carrying the domain's findings, exit 1 (SPEC 5.7, 2.1, 2.4, 4.5, 11.2, 11.3, 14)", + run: async (product) => { + assertNoOccContainerRange(); + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + "specs/BASE.mdx": NO_OCC_BASE_SOURCE, + "specs/SPARE.mdx": NO_OCC_SPARE_SOURCE, + "specs/MAIN.mdx": NO_OCC_MAIN_SOURCE, + "src/app.ts": NO_OCC_APP_SOURCE, + }, + }); + try { + // Staging premise, pinned first: `build --json` reports EXACTLY the + // four staged defects — so the resolving spellings provably resolve, + // and the no-occurrence constructs that are also no-finding constructs + // (imports, type-only uses, shadowed chains) provably trigger nothing. + const buildContext = "T5.7-4 `build --json` (staging premise)"; + assertNoOccFindings( + await buildFindings(product, workspace, buildContext), + buildContext, + ); + + // The enumeration over the imperfect workspace: the answer carries the + // consulted domain's findings, so the invocation exits 1 — with the + // full answer document still emitted (SPEC 11.2; 11.3 is JSON-only). + const context = "T5.7-4 `occurrences`"; + const result = await expectExit( + product, + workspace, + ["occurrences"], + 1, + `${context} — an answer carrying any finding exits 1, the full ` + + `answer document still emitted (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout(result, context), + context, + ); + + // The domain's findings accompany the answer (the entire discovered + // set — no `--file`), each locating its spelling. + assertNoOccFindings(report.findings, context); + + // Records for exactly the resolving spellings. The form-exact decode + // has already rejected any record with an unavailable target (12.7: + // `target` is an identity string; SPEC 5.7/11.2 — an unresolved + // spelling is never a record), so the exact multiset comparison is the + // remaining edge: an extra record for an import declaration, a + // type-only use, a shadowed chain, the dynamic spelling, or an + // unresolved spelling fails by count and tuple. + assertSameJson( + report.occurrences.map(renderOccurrenceUnit).sort(), + expectedUnitMultiset(NO_OCC_UNITS), + `${context}: the complete (file, [kind], source -> target) record ` + + `multiset — exactly the three resolving spellings (SPEC 5.7, ` + + `11.2): no record for an import declaration (binding used or ` + + `unused, 2.1), a type-only binding's marker-shaped uses, a chain ` + + `rooted at a shadowing local declaration (4.5), the dynamic ` + + `spelling, or the unresolved spellings — each of the latter ` + + `reaching consumers only through its finding's range`, + ); + } finally { + await workspace.dispose(); + } + }, +}); + /** TEST-SPEC §5.7, in canonical ID order (SUITE-51). */ export const section57Tests: readonly ProductTestEntry[] = [ T5_7_1, T5_7_2, T5_7_3, + T5_7_4, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index bbedb21..e67f34a 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -189,6 +189,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T5.7-1": ["5.7"], "T5.7-2": ["5.7"], "T5.7-3": ["5.7"], + "T5.7-4": ["5.7", "14"], "T6.1-1": ["6.1"], "T6.1-2": ["6.1"], "T6.1-3": ["6.1", "14"], From 853df77917a8b07ada301f307b40008d240decbe Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 19:59:05 +0000 Subject: [PATCH 072/226] =?UTF-8?q?sdg(phase-9):=20implement=20T6.5-7=20?= =?UTF-8?q?=E2=80=94=20operation-side=20rewrite=20bytes=20(FP-027)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register T6.5-7 in section-6.5.ts: the real section move's import-edit extents and reference-conversion spellings, byte-asserted as whole-file compares against expected constants independently composed from the rules of SPEC 6.5, 6.4, and 3 — staged so no import is added, the one rewrite direction free of implementation latitude. The origin's own-line target-module import is dropped with its line and terminator; the shared-line declaration's own characters alone are deleted, the retained third-module import surviving byte-for-byte with its `;` and separating space; the moved references convert to local form double-quoted; the single-quoted local reference is re-identified with its quote spelling preserved. Premise `build` pins the staging valid, post-move `check` guards the composition's soundness. Traceability maps "6.5"; no certification scope (Exclusions). Genuinely green against this repo's product (machinery predates the patch); teeth proven by shim-deviation probes failing at exactly the diagnosed compares. test:self unchanged: 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 38 ++++- test/suite/registry/section-6.5.ts | 233 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 270 insertions(+), 2 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index ab4dc5a..4e5d3f9 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -985,13 +985,49 @@ A "new test T<x>" task always means, in one change: reds (certification-document ×3 → FP-091; S-1's 8 unmapped keys → stages E/G), S-5 and certification green.] -- [ ] FP-027 — Implement T6.5-7: operation-side rewrite bytes for the real +- [x] FP-027 — Implement T6.5-7: operation-side rewrite bytes for the real move. [R1 #6; TEST-SPEC §6.5] Import-removal extents (own-line import dropped with terminator; shared-line declaration's own characters only), double-quoted conversion spellings, preserved single-quote local reference — whole files byte-asserted against composed expected bytes. Registry `section-6.5.ts`; map `"6.5"`. + [Done 2026-08-11: T6.5-7 registered in section-6.5.ts — one workspace, + TEST-SPEC's exact staging: Origin.mdx imports Target.xspec under two + bindings (own-line `TWO`; `TB` following the retained, still-referenced + `Keep` import on a shared line, `"; "`-separated with the removed + declaration last and semicolonless so its own-characters span is + rule-unique), the moved subtree `org.mv` holding every reference through + both bindings (`d={TWO.hub}`, `{text(TB.aux)}`) plus the single-quoted + local `d={'org.mv.leaf'}`, `Keep.keep` referenced only outside the + subtree, no reference to a moved node outside it — so the section move + into Target.mdx (top-level `mv`, end-of-file insertion) adds no import + anywhere. Body: premise `build` exit 0 (pins the two-declaration + shared-line staging parses, 2.1), plain `move` exit 0, then whole-file + byte compares of Origin (own-line declaration's line dropped with its + terminator; shared line kept as `import Keep from "./Keep.xspec"; ` — the + `;` AND the separating space survive, spelled as an explicit `+ " "` + concatenation; both pre-existing blanks around the dropped construct + kept), Target (before-bytes + rewritten moved text + U+000A: `d={"hub"}` + / `{text("aux")}` double-quoted conversions, `d={'mv.leaf'}` single-quote + preserved, ids prefix-replaced), and Keep.mdx as bystander — expected + constants independently composed from the rules of 6.5/6.4/3 with each + delta's rule cited — plus post-move `check` exit 0 as the composition's + soundness guard. Traceability "T6.5-7": ["6.5"] (no numbered condition; + 6.4/3/2.1 carriage context per precedent); CERTIFICATIONS.md names + T6.5-7 in the Exclusions (no fixture scope). NOT red against this repo's + product: the section-move rewrite machinery predates the patch — the + probe returned byte-for-byte the composed expectation (trailing space + included) with `check` clean, so the pass is genuine; teeth proven by a + scratch shim-binding probe running the registered body against two + deviations, whitespace-normalized shared line and double-quoted local + reference — each failed as HarnessAssertionError at exactly its + diagnosed compare (origin / target). Section-6.5 suite now 5 failed / + 2 passed (T6.5-2 and T6.5-7 green; the five reds are the pre-existing + FP-001/FP-002-class product gaps). Typecheck/format clean; `npm run + test:self` unchanged 4 planned mid-loop reds (certification-document ×3 + → FP-091; S-1's 8 unmapped keys → stages E/G), S-5 green, certification + 17/17.] - [ ] FP-028 — Implement T6.6-2: preview is inert and predictive. [R1 #7; TEST-SPEC §6.6 (new preview section)] diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index a81f860..00493fe 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §6.5 (move) — SUITE-25: T6.5-1…T6.5-6. +// TEST-SPEC §6.5 (move) — SUITE-25: T6.5-1…T6.5-7. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -141,6 +141,20 @@ // staged on the Linux leg only (mirroring T1.5-2's platform note): argv // bytes exist as a channel there, carried by the subprocess driver's // raw-byte argv support. +// - T6.5-7 asserts the real move's operation-side rewrite bytes — the +// assertion T6.5-2's no-other-byte-changes check excludes and T6.6-4 makes +// only of the preview's report — as whole-file byte compares against +// independently composed expected constants (H-4, normalizing nothing), +// each delta cited to the rule of SPEC 6.5, 6.4, or 3 that forces it. The +// fixture is staged so no import is added: every moved reference converts +// imported → local, the one rewrite direction free of implementation +// latitude (SPEC 6.5: identifier choice and insertion offset attach to +// added imports alone), so the two files' post-move bytes are the rules' +// unique composition. A premise `build` pins the staging valid (the +// shared-line two-declaration import block parses, SPEC 2.1) and a +// post-move `check` guards the composition's soundness: if the product's +// bytes equal the expected bytes yet something failed to resolve, the +// staging itself was defective and must fail loud. // - T6.5-6's unstageable clauses are documented at the test, per TEST-SPEC: // the collision clause's after-the-removal qualifier admits no // discriminating fixture (structural IDs make the vacated set exactly the @@ -2578,6 +2592,222 @@ const T6_5_6 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T6.5-7 — operation-side rewrite bytes for the real move +// --------------------------------------------------------------------------- + +// The fixture (TEST-SPEC T6.5-7): the origin imports the target module under +// two bindings (valid, SPEC 2.1 — multiple imports may bind one module under +// different names), one declaration alone on its line (`TWO`), the other +// (`TB`) following the retained, still-referenced third-module import +// (`Keep`, referenced by `org.stay` OUTSIDE the moved subtree) on a shared +// line. Every reference through the two bindings — the `d` chain +// `d={TWO.hub}` and the embedding `{text(TB.aux)}` — lies inside the moved +// subtree `org.mv`, which also holds the single-quoted local string +// reference `d={'org.mv.leaf'}` to a moved descendant; no reference to a +// moved node lies outside the subtree, and no moved reference targets a +// node remaining in the origin — so the rewrite adds no import anywhere, +// the one direction free of implementation latitude (SPEC 6.5). +const B7_ORIGIN = "specs/Origin.mdx"; +const B7_TARGET = "specs/Target.mdx"; +const B7_KEEP = "specs/Keep.mdx"; + +const B7_ORIGIN_BEFORE = [ + 'import TWO from "./Target.xspec"', + 'import Keep from "./Keep.xspec"; import TB from "./Target.xspec"', + "", + '<S id="org">', + "Origin holder text.", + "", + '<S id="org.mv" d={TWO.hub}>', + "Moved head text.", + "", + "{text(TB.aux)}", + "", + '<S id="org.mv.leaf">', + "Moved leaf text.", + "</S>", + "", + "<S id=\"org.mv.use\" d={'org.mv.leaf'}>", + "Moved user text.", + "</S>", + "</S>", + "", + '<S id="org.stay" d={Keep.keep}>', + "Staying text.", + "</S>", + "</S>", + "", +].join("\n"); + +const B7_TARGET_BEFORE = [ + '<S id="hub">', + "Hub text.", + "</S>", + "", + '<S id="aux">', + "Aux text.", + "</S>", + "", +].join("\n"); + +const B7_KEEP_SOURCE = ['<S id="keep">', "Keep text.", "</S>", ""].join("\n"); + +// Expected origin bytes, composed from the rules of SPEC 6.5 and 3 — not +// from any product output: +// - The own-line `TWO` declaration's own characters are deleted in place; +// its line, left empty purely by that deletion, is dropped with its +// terminator (SPEC 6.5, 3) — a product leaving an emptied line behind +// fails here. +// - On the shared line, the removed `TB` declaration's own characters ALONE +// are deleted — the declaration spans `import TB from "./Target.xspec"` +// exactly (no trailing `;` exists to reach) — so the retained `Keep` +// import, its `;`, AND the separating U+0020 survive byte-for-byte: the +// kept line ends `"./Keep.xspec"; ` with a trailing space before its +// terminator (spelled as an explicit concatenation below so the byte is +// loud). A product normalizing whitespace around a removed declaration +// fails here. +// - The moved text — the `org.mv` construct's own characters, opening `<` +// through the closing tag's `>` — is deleted in place; the merged line it +// leaves holds only the closing tag's terminator and is dropped (SPEC +// 6.5, 3). Both surrounding blank lines were already blank in the source, +// so both are kept: two adjacent blank lines remain (rule of 3 drops only +// lines a removal blanked). +const B7_ORIGIN_AFTER = [ + 'import Keep from "./Keep.xspec";' + " ", + "", + '<S id="org">', + "Origin holder text.", + "", + "", + '<S id="org.stay" d={Keep.keep}>', + "Staying text.", + "</S>", + "</S>", + "", +].join("\n"); + +// Expected target bytes, composed from the same rules: +// - Top-level `<new-id>` (`mv`): the moved text is inserted at the end of +// the file, followed by U+000A; the existing final line is terminated, so +// the insertion point sits at the start of a line and no preceding U+000A +// is added (SPEC 6.5). +// - Re-identification by prefix replacement `org.mv` → `mv` rewrites the +// three `id` attributes in place (SPEC 6.5). +// - The imported references convert to local form — their targets `hub` and +// `aux` live in the target file — in 6.4's pinned spelling for converted +// references: double-quoted string literals, `d={"hub"}` and +// `{text("aux")}` (SPEC 6.5, 6.4). A product spelling a converted +// reference single-quoted fails here. +// - The local reference stays local, re-identified by prefix replacement +// with its single-quote spelling preserved: `d={'mv.leaf'}` (SPEC 6.4: +// minimal in-place edits preserve quote style). +const B7_TARGET_AFTER = [ + '<S id="hub">', + "Hub text.", + "</S>", + "", + '<S id="aux">', + "Aux text.", + "</S>", + '<S id="mv" d={"hub"}>', + "Moved head text.", + "", + '{text("aux")}', + "", + '<S id="mv.leaf">', + "Moved leaf text.", + "</S>", + "", + "<S id=\"mv.use\" d={'mv.leaf'}>", + "Moved user text.", + "</S>", + "</S>", + "", +].join("\n"); + +const B7_MOVE_ARGV = [ + "move", + "specs/Origin.mdx#org.mv", + "specs/Target.mdx#mv", +] as const; + +const T6_5_7 = defineProductTest({ + id: "T6.5-7", + title: + "operation-side rewrite bytes for the real move: import-edit extents and reference-conversion spellings byte-asserted against independently composed expected files, staged so no import is added (the one rewrite direction free of implementation latitude) — the own-line target-module import's line dropped with its terminator, the shared-line declaration's own characters alone deleted with the retained third-module import kept byte-for-byte on its kept line, the moved references converted to local form as double-quoted string literals, and the single-quoted local reference re-identified by prefix replacement with its quote spelling preserved (SPEC 6.5, 6.4, 3, 2.1; H-4, normalizing nothing)", + run: async (product) => { + await withWorkspace( + SPECS_ONLY_CONFIG, + { + [B7_ORIGIN]: B7_ORIGIN_BEFORE, + [B7_TARGET]: B7_TARGET_BEFORE, + [B7_KEEP]: B7_KEEP_SOURCE, + }, + async (workspace) => { + // Premise: the staging is valid — most acutely, the shared line's + // two import declarations parse as two bindings (SPEC 2.1), so a + // later failure is the move's, not the staging's. + await buildOk(product, workspace, "T6.5-7 `build` over the staging"); + + await expectExit( + product, + workspace, + [...B7_MOVE_ARGV], + 0, + "T6.5-7 `move specs/Origin.mdx#org.mv specs/Target.mdx#mv`", + ); + + await assertFileBytes( + workspace.path(B7_ORIGIN), + B7_ORIGIN_AFTER, + "T6.5-7: the origin after the move — both target-module imports " + + "left unreferenced are removed with 6.5's exact extent: the " + + "own-line declaration's line dropped with its terminator, the " + + "shared-line declaration's own characters alone deleted, the " + + "retained import (its `;` and the separating space included) " + + "kept byte-for-byte on its kept line (SPEC 6.5, 2.1, 3; H-4, " + + "normalizing nothing)", + ); + await assertFileBytes( + workspace.path(B7_TARGET), + B7_TARGET_AFTER, + "T6.5-7: the target after the move — the moved references " + + "convert to local form as double-quoted string literals " + + '(`d={"hub"}`, `{text("aux")}`), the local reference is ' + + "re-identified by prefix replacement with its single-quote " + + "spelling preserved (`d={'mv.leaf'}`), and the insertion adds " + + "exactly the rewritten moved text plus U+000A at end of file " + + "(SPEC 6.5, 6.4; H-4, normalizing nothing)", + ); + await assertFileBytes( + workspace.path(B7_KEEP), + B7_KEEP_SOURCE, + "T6.5-7: the retained third module's own file is an uninvolved " + + "bystander — beyond the stated edits, the identity and " + + "reference rewrites, and the finishing regeneration, a move " + + "changes no bytes (SPEC 6.5)", + ); + + // Soundness guard on the composed expectation itself: everything + // resolves after the move — if the product's bytes matched the + // expected bytes yet a reference or import failed to resolve, the + // COMPOSITION was defective, and it must fail loud rather than + // certify a broken rewrite (SPEC 6.5, 12.2). + await expectExit( + product, + workspace, + ["check"], + 0, + "T6.5-7 `check` immediately after the move — every converted and " + + "re-identified reference resolves and no staleness remains " + + "(SPEC 6.5, 12.2, 14.10)", + ); + }, + ); + }, +}); + /** TEST-SPEC §6.5, in canonical ID order (SUITE-25). */ export const section65Tests: readonly ProductTestEntry[] = [ T6_5_1, @@ -2586,4 +2816,5 @@ export const section65Tests: readonly ProductTestEntry[] = [ T6_5_4, T6_5_5, T6_5_6, + T6_5_7, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index e67f34a..2a76711 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -214,6 +214,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.5-4": ["6.5", "14"], "T6.5-5": ["6.5", "12.0"], "T6.5-6": ["6.5", "14"], + "T6.5-7": ["6.5"], "T6.7-1": ["6.7"], "T7-1": ["7", "14"], "T7-2": ["7", "14"], From 6304869ed1375e48b4654f6a96e74fcb8a6b3807 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 20:20:51 +0000 Subject: [PATCH 073/226] =?UTF-8?q?sdg(phase-9):=20implement=20T6.6-2=20?= =?UTF-8?q?=E2=80=94=20preview=20is=20inert=20and=20predictive=20(FP-028)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New section-6.6 registry module and suite wrapper registering T6.6-2: a rename --preview and a section-form move --preview on proceeding workspaces exit 0 with findings [], modify nothing (whole-root compare around all preview runs, journal-absent premise), are byte-deterministic across repeated runs (H-6, --json and bare forms alike), and decode as the form-exact 12.7 preview document; a subsequent real run's applied mapping equals the preview's mapping as complete sets. Adds the 12.7 preview-document decode to the form-exact layer (forms.ts decodePreviewReport + model types: the ten edit classes, pinned orders, all-or-none nullity of mapping/files/delta, delta via the three-state datum decode) with its S-5 DECODERS entry (4 positive controls, 30 targeted rejections). Traceability maps "6.6"; T6.6-2 is CERTIFICATIONS.md-excluded. Red-as-diagnosed at the first preview invocation (exit 2 "unknown flag '--preview'"); satisfiability and teeth proven via a shim-product probe (conforming green end-to-end through both arms; writes/partial-mapping/ jitter deviations each red at their diagnosed assertion). test:self: the 4 planned mid-loop reds (certification-document x3 -> FP-091; S-1 unmapped keys now 7), S-5 and certification green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 54 +++- test/helpers/adapters/forms.ts | 234 +++++++++++++++++ test/helpers/adapters/model.ts | 74 ++++++ test/self/s5-output-adapters.test.ts | 321 +++++++++++++++++++++++ test/suite/registry/index.ts | 2 + test/suite/registry/section-6.6.ts | 369 +++++++++++++++++++++++++++ test/suite/registry/traceability.ts | 1 + test/suite/section-6.6.test.ts | 9 + 8 files changed, 1063 insertions(+), 1 deletion(-) create mode 100644 test/suite/registry/section-6.6.ts create mode 100644 test/suite/section-6.6.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 4e5d3f9..aff3046 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1029,13 +1029,65 @@ A "new test T<x>" task always means, in one change: → FP-091; S-1's 8 unmapped keys → stages E/G), S-5 green, certification 17/17.] -- [ ] FP-028 — Implement T6.6-2: preview is inert and predictive. [R1 #7; +- [x] FP-028 — Implement T6.6-2: preview is inert and predictive. [R1 #7; TEST-SPEC §6.6 (new preview section)] Preview modifies nothing; a subsequent real run's applied mapping equals the preview's `mapping`; byte-determinism; form-exact 12.7 preview document under `--json`. New-§6.6 tests live beside the renamed T6.7-1 (FP-003) — keep registry module naming coherent with `test/suite/registry/index.ts` imports; map `"6.6"`. + [Done 2026-08-11: new registry module section-6.6.ts (wrapper + section-6.6.test.ts, spread into index.ts) registering T6.6-2 — two arms, + each its own workspace under a specs+markdown-emit config so the premise + `build` materializes every derived-file kind under the compare: rename + (`core.mid` → `core.hub`, mid-tree with a descendant and sibling local + references) and section-form move (`org.mv` → existing-target `tm` with an + internal DOWNWARD local reference — the shim probe caught the first + draft's child-to-ancestor `d` as a 14.9 cycle in the combined + contains/depends graph, SPEC 5.3 — plus a staying reference the real move + converts to imported form). Each arm: journal-absent premise (6.1), then + inside ONE whole-root assertLeavesUnchanged all four preview invocations — + `--preview --json` twice and bare `--preview` twice, each pair through + assertRunTwiceDeterministic (H-6: byte-identical stdout/stderr/exit and + workspace state) — exit 0, stdout decoded through the NEW form-exact 12.7 + preview-document layer (forms.ts `decodePreviewReport` + model.ts types: + exactly {"findings","mapping","files","delta"}; mapping + `from`-byte-ordered one-per-identity; files path-byte-ordered + one-per-file, edits {"class","range"} drawn from the ten 12.7 class names + ordered by start/end/class-name-bytes — the + import-addition-before-target-insertion coincidence decode-admitted; + delta {"generated","removed"} with byte-ordered distinct paths per + direction, or the unavailability marker via the three-state datum decode; + mapping/files/delta null all-together-or-none — the refusal encoding, + mixed nullity rejects), findings asserted exactly [] and the plan members + non-null; then the subsequent real run with `--json` (exit 0, T6.4-1's + H-3 applied-mapping adapter) asserting applied mapping == preview mapping + as complete sets (assertAppliedMapping — the equality IS the TEST-SPEC + operationalization; mapping/files/delta CONTENT stays T6.6-4's/T6.6-5's). + S-5 gains the preview decoder's DECODERS entry (positive controls: full + plan incl. nested deletion geometry and the zero-length tie-break pair, + refused all-null, delta-unavailable-beside-full-plan, empty lists; 30 + targeted rejections incl. mixed nullity both ways, a replacement-text + edit member, tie-break order violation, duplicate mapped + identity/file/delta path). Traceability "T6.6-2": ["6.6"] (no numbered + condition asserted; 6.4/6.5/6.1/12.0 carriage context per precedent); + CERTIFICATIONS.md names T6.6-2 in the Exclusions — no fixture scope (its + compare-around machinery is certified via VIOL-CORE-CHATTYREADS). + Verified: red-as-diagnosed exactly at the first preview invocation (exit + 2 "unknown flag '--preview'" — the whole 6.6 surface is patch-new) with + premises green; satisfiability and teeth proven by running the registered + body via a scratch binding against a shim product (real product + + conforming preview/applied-mapping answers computed from the staged + bytes) — green through BOTH arms end-to-end, the real product performing + the actual rename and move (both stagings proceed, exit 0, validating + "the real operation would proceed" against the real product) — and + against three deviations: preview-writes-a-file (red at the + modifies-nothing compare), preview-mapping-missing-descendants (red at + the applied-mapping equality), nondeterministic bare-form output (red at + the H-6 byte compare). Typecheck/format clean; `npm run test:self` 4 + planned mid-loop reds (certification-document ×3 → FP-091; S-1 unmapped + keys now the 7-key set {11.2, 11.3, 11.4, 11.5, 11.6, 12.6, 12.7} — + "6.6" mapped by this task), S-5 (70 tests) and certification green.] - [ ] FP-029 — Implement T6.6-3: refusal/usage-error equivalence under `--preview`. [R1 #8; TEST-SPEC §6.6] diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts index 8aeb970..55e6b08 100644 --- a/test/helpers/adapters/forms.ts +++ b/test/helpers/adapters/forms.ts @@ -24,11 +24,14 @@ // {"unavailable": true} (11.4, 12.7) // - the occurrence-record form {"file","range","kind","source","target"} // and the occurrences document {"findings","occurrences"} (5.7, 11.3) +// - the rename/move preview document {"findings","mapping","files","delta"} +// (6.6) with the ten edit classes and the pinned orders // - the unavailability-marker structural walk T12.7-1 relies on: no object // of any form other than the marker carries a member named "unavailable" import { Buffer, isUtf8 } from "node:buffer"; import type { + AppliedMappingPair, ErrorDocument, Finding, FindingLocation, @@ -39,11 +42,17 @@ import type { OccurrenceSourceNode, OccurrencesReport, PathValue, + PreviewDelta, + PreviewDeltaDatum, + PreviewEdit, + PreviewFileEntry, + PreviewReport, SourceRange, } from "./model.js"; import { CONDITION_CODE_TOKENS, DEPENDENCY_EDGE_KINDS, + PREVIEW_EDIT_CLASSES, REFUSAL_CODE_TOKENS, conditionIdentityOf, } from "./model.js"; @@ -641,6 +650,231 @@ export function decodeOccurrencesReport( return { findings, occurrences }; } +// --- the rename/move preview document (6.6, 12.7) ----------------------------- + +/** One `mapping` entry: `{"from", "to"}` exactly, identities are strings. */ +function decodePreviewMappingPair( + value: unknown, + site: DecodeSite, +): AppliedMappingPair { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["from", "to"], site); + return { + from: expectNonEmptyString( + requiredKey(obj, "from", site), + at(site, "from"), + ), + to: expectNonEmptyString(requiredKey(obj, "to", site), at(site, "to")), + }; +} + +/** One edit: `{"class", "range"}` exactly — class-plus-range only (6.6). */ +function decodePreviewEdit(value: unknown, site: DecodeSite): PreviewEdit { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["class", "range"], site); + return { + class: expectToken( + requiredKey(obj, "class", site), + PREVIEW_EDIT_CLASSES, + at(site, "class"), + ), + range: decodeRangeForm(requiredKey(obj, "range", site), at(site, "range")), + }; +} + +/** + * The pinned edit order (SPEC 12.7): by range start, then range end, then + * class-name BYTES — the final tie-break `import-addition` before + * `target-insertion` on coinciding zero-length insertion points (T6.6-4). + * 12.7 states no collapse rule for edits, so equal keys are admitted by the + * order check (content is the tests' business). + */ +function comparePreviewEdits(a: PreviewEdit, b: PreviewEdit): number { + if (a.range.start !== b.range.start) return a.range.start - b.range.start; + if (a.range.end !== b.range.end) return a.range.end - b.range.end; + return compareStringBytes(a.class, b.class); +} + +/** One `files` entry: `{"file", "edits"}` exactly, edits in the 12.7 order. */ +function decodePreviewFileEntry( + value: unknown, + site: DecodeSite, +): PreviewFileEntry { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["file", "edits"], site); + const file = decodePathValue( + requiredKey(obj, "file", site), + at(site, "file"), + ); + const editsSite = at(site, "edits"); + const edits = expectArray(requiredKey(obj, "edits", site), editsSite).map( + (element, index) => decodePreviewEdit(element, at(editsSite, index)), + ); + for (let i = 1; i < edits.length; i += 1) { + if (comparePreviewEdits(edits[i - 1]!, edits[i]!) > 0) { + formFail( + at(editsSite, i), + "edits ordered by range start, then range end, then class-name " + + "bytes (SPEC 12.7)", + obj["edits"], + ); + } + } + return { file, edits }; +} + +/** One delta direction: 12.7 path values in byte order, one per path. */ +function decodeDeltaDirection(value: unknown, site: DecodeSite): PathValue[] { + const paths = expectArray(value, site).map((element, index) => + decodePathValue(element, at(site, index)), + ); + for (let i = 1; i < paths.length; i += 1) { + const order = Buffer.compare( + pathValueBytes(paths[i - 1]!), + pathValueBytes(paths[i]!), + ); + if (order === 0) { + formFail( + at(site, i), + "one entry per derived path — a direction of the delta is a set of " + + "paths (SPEC 6.6)", + value, + ); + } + if (order > 0) { + formFail( + at(site, i), + "the direction's paths in byte order (SPEC 12.7)", + value, + ); + } + } + return paths; +} + +/** The delta value form: `{"generated", "removed"}` exactly (6.6, 12.7). */ +function decodePreviewDelta(value: unknown, site: DecodeSite): PreviewDelta { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["generated", "removed"], site); + return { + generated: decodeDeltaDirection( + requiredKey(obj, "generated", site), + at(site, "generated"), + ), + removed: decodeDeltaDirection( + requiredKey(obj, "removed", site), + at(site, "removed"), + ), + }; +} + +/** + * The `rename`/`move` preview document (SPEC 6.6) — `{"findings", "mapping", + * "files", "delta"}` exactly (SPEC 12.7). Form-exact (H-3): `mapping` one + * `{"from", "to"}` per mapped identity, ordered by `from` bytes; `files` one + * `{"file", "edits"}` per file, ordered by file path bytes, each edit + * `{"class", "range"}` with one of the ten 12.7 class names, edits ordered + * by range start, then range end, then class-name bytes; `delta` + * `{"generated", "removed"}` with each direction's paths in byte order, or + * unavailable as one datum (14.23). On refusal `mapping`, `files`, and + * `delta` are `null` — all three together: a refused preview reports the + * refusal findings alone (6.6), so a document with some but not all of them + * `null` matches neither the refusal nor the success encoding and rejects. + */ +export function decodePreviewReport( + doc: unknown, + context?: string, +): PreviewReport { + const site = rootSite("12.7 preview document", context); + const obj = expectObject(doc, site); + expectOnlyMembers(obj, ["findings", "mapping", "files", "delta"], site); + const findings = decodeFindingsArray( + requiredKey(obj, "findings", site), + at(site, "findings"), + ); + + const mappingValue = requiredMember(obj, "mapping", site); + let mapping: AppliedMappingPair[] | null = null; + if (mappingValue !== null) { + const mappingSite = at(site, "mapping"); + mapping = expectArray(mappingValue, mappingSite).map((element, index) => + decodePreviewMappingPair(element, at(mappingSite, index)), + ); + for (let i = 1; i < mapping.length; i += 1) { + const order = compareStringBytes(mapping[i - 1]!.from, mapping[i]!.from); + if (order === 0) { + formFail( + at(mappingSite, i), + 'one {"from", "to"} per mapped identity (SPEC 12.7)', + mappingValue, + ); + } + if (order > 0) { + formFail( + at(mappingSite, i), + "mapping entries ordered by `from` bytes (SPEC 12.7)", + mappingValue, + ); + } + } + } + + const filesValue = requiredMember(obj, "files", site); + let files: PreviewFileEntry[] | null = null; + if (filesValue !== null) { + const filesSite = at(site, "files"); + files = expectArray(filesValue, filesSite).map((element, index) => + decodePreviewFileEntry(element, at(filesSite, index)), + ); + for (let i = 1; i < files.length; i += 1) { + const order = Buffer.compare( + pathValueBytes(files[i - 1]!.file), + pathValueBytes(files[i]!.file), + ); + if (order === 0) { + formFail( + at(filesSite, i), + 'one {"file", "edits"} per file (SPEC 12.7)', + filesValue, + ); + } + if (order > 0) { + formFail( + at(filesSite, i), + "file entries ordered by file path bytes (SPEC 12.7)", + filesValue, + ); + } + } + } + + const deltaDatum = decodeDatum( + obj["delta"], + at(site, "delta"), + (value, valueSite) => decodePreviewDelta(value, valueSite), + ); + const delta: PreviewDeltaDatum | null = + deltaDatum.state === "null" + ? null + : deltaDatum.state === "unavailable" + ? { unavailable: true as const } + : deltaDatum.value; + + const nullCount = [mapping, files, delta].filter( + (member) => member === null, + ).length; + if (nullCount !== 0 && nullCount !== 3) { + formFail( + site, + "`mapping`, `files`, and `delta` null together (the refusal " + + "encoding) or none of them null (a successful preview's plan) — " + + "SPEC 6.6, 12.7", + doc, + ); + } + return { findings, mapping, files, delta }; +} + // --- the unavailability-marker structural walk (T12.7-1) ----------------------- /** diff --git a/test/helpers/adapters/model.ts b/test/helpers/adapters/model.ts index 0ed20c4..bd75121 100644 --- a/test/helpers/adapters/model.ts +++ b/test/helpers/adapters/model.ts @@ -416,6 +416,80 @@ export interface AppliedMappingPair { readonly to: string; } +/** + * The ten preview edit class names, in the order SPEC.md 12.7 lists them + * (6.6 defines the classes; the list order is 12.7's presentation — the edit + * ORDER inside a file entry compares class-NAME bytes, not this list's + * positions). + */ +export const PREVIEW_EDIT_CLASSES = [ + "reference-rewrite", + "id-rewrite", + "import-specifier-rewrite", + "import-addition", + "import-removal", + "origin-deletion", + "target-insertion", + "target-parent-rewrite", + "file-relocation", + "file-creation", +] as const; +export type PreviewEditClass = (typeof PREVIEW_EDIT_CLASSES)[number]; + +/** + * One edit of a preview file entry — `{"class", "range"}` exactly (SPEC.md + * 12.7): class plus a source range in current, pre-operation coordinates and + * nothing else — an edit is reported without replacement text (6.6). + */ +export interface PreviewEdit { + readonly class: PreviewEditClass; + readonly range: SourceRange; +} + +/** + * One file the operation would rewrite, relocate, or create — `{"file", + * "edits"}` exactly (SPEC.md 12.7): `file` the file's current, pre-operation + * path (for target-file creation, the path the creation would occupy; 6.6), + * `edits` in the pinned order — range start, then range end, then class-name + * bytes. + */ +export interface PreviewFileEntry { + readonly file: PathValue; + readonly edits: readonly PreviewEdit[]; +} + +/** + * The derived-file delta, both directions one datum — `{"generated", + * "removed"}` exactly, each direction's paths in byte order (SPEC.md 6.6, + * 12.7). + */ +export interface PreviewDelta { + readonly generated: readonly PathValue[]; + readonly removed: readonly PathValue[]; +} + +/** + * The delta member's datum: the two-direction value, or explicitly + * unavailable as one datum where the recorded state cannot be read (14.23). + */ +export type PreviewDeltaDatum = PreviewDelta | { readonly unavailable: true }; + +/** + * The `rename`/`move` preview document (SPEC.md 6.6) — `{"findings", + * "mapping", "files", "delta"}` exactly, a form-exact 12.7 surface (H-3): + * `mapping` one `{"from", "to"}` per mapped identity ordered by `from` + * bytes; `files` one entry per file ordered by file path bytes; `delta` the + * two-direction datum or unavailable. On refusal `mapping`, `files`, and + * `delta` are `null` — together: a refused preview reports the refusal + * findings alone (6.6), so mixed nullity is no 12.7 form. + */ +export interface PreviewReport { + readonly findings: readonly Finding[]; + readonly mapping: readonly AppliedMappingPair[] | null; + readonly files: readonly PreviewFileEntry[] | null; + readonly delta: PreviewDeltaDatum | null; +} + /** `review list` (T10.7-5): sessions in byte order of name. */ export interface SessionListReport { readonly sessions: readonly SessionListEntry[]; diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index a1d897c..9d94986 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -51,6 +51,7 @@ import { decodeNodeSummary, decodeNodeSummaryRowsReport, decodeNodeTextSummary, + decodePreviewReport, decodeReachableReport, decodeSessionListReport, decodeSessionStatusReport, @@ -378,6 +379,61 @@ const GOOD_APPLIED_MAPPING = { ], }; +// A successful rename/move preview in the literal SPEC 12.7 form (a +// form-exact surface, H-3): `{"findings", "mapping", "files", "delta"}` — +// mapping ordered by `from` bytes, file entries by file path bytes, edits by +// range start, then range end, then class-name bytes (the zero-length +// insertion coincidence deliberately staged: `import-addition` sorts before +// `target-insertion` at one offset, T6.6-4's tie-break), delta directions in +// path byte order. +const GOOD_PREVIEW = { + findings: [], + mapping: [ + { from: "specs/A.mdx#login", to: "specs/B.mdx#login" }, + { from: "specs/A.mdx#login.form", to: "specs/B.mdx#login.form" }, + ], + files: [ + { + file: "specs/A.mdx", + edits: [ + { class: "origin-deletion", range: { start: 40, end: 160 } }, + // Nested inside the deletion range — containment is geometry, each + // edit under its own class (SPEC 6.6). + { class: "id-rewrite", range: { start: 48, end: 58 } }, + { class: "reference-rewrite", range: { start: 200, end: 216 } }, + ], + }, + { + file: "specs/B.mdx", + edits: [ + { class: "import-addition", range: { start: 90, end: 90 } }, + { class: "target-insertion", range: { start: 90, end: 90 } }, + ], + }, + ], + delta: { + generated: ["specs/B.md", "specs/B.xspec.ts"], + removed: ["specs/A.md", "specs/A.xspec.ts"], + }, +}; + +// A refused preview keeps the preview document form: the refusal findings +// alone, `mapping`, `files`, and `delta` null together (SPEC 6.6, 12.7). +const REFUSED_PREVIEW = { + findings: [ + { + code: "refused-identity-unchanged", + message: "the new identity equals the old", + locations: [], + path: null, + identities: ["specs/A.mdx#login"], + }, + ], + mapping: null, + files: null, + delta: null, +}; + const GOOD_SESSION_LIST = { sessions: [ { @@ -1387,6 +1443,271 @@ const DECODERS: readonly DecoderSpec[] = [ }, ], }, + { + name: "12.7 preview document", + decode: decodePreviewReport, + good: GOOD_PREVIEW, + verify: (decoded: ReturnType<typeof decodePreviewReport>) => { + expect(decoded.findings).toEqual([]); + // The plan members decode literally (form-exact, H-3) … + expect(decoded.mapping).toEqual([ + { from: "specs/A.mdx#login", to: "specs/B.mdx#login" }, + { from: "specs/A.mdx#login.form", to: "specs/B.mdx#login.form" }, + ]); + expect(decoded.files).toHaveLength(2); + expect(decoded.files![0]).toEqual({ + file: "specs/A.mdx", + edits: [ + { class: "origin-deletion", range: { start: 40, end: 160 } }, + { class: "id-rewrite", range: { start: 48, end: 58 } }, + { class: "reference-rewrite", range: { start: 200, end: 216 } }, + ], + }); + // … the coinciding zero-length insertion points pass in class-byte + // order (import-addition before target-insertion, SPEC 12.7) … + expect(decoded.files![1]!.edits.map((edit) => edit.class)).toEqual([ + "import-addition", + "target-insertion", + ]); + // … and the delta is the two-direction datum. + expect(decoded.delta).toEqual({ + generated: ["specs/B.md", "specs/B.xspec.ts"], + removed: ["specs/A.md", "specs/A.xspec.ts"], + }); + }, + alsoGood: [ + { + label: + "a refused preview: refusal findings alone, mapping/files/delta " + + "null together (SPEC 6.6, 12.7)", + doc: REFUSED_PREVIEW, + verify: (decoded: ReturnType<typeof decodePreviewReport>): void => { + expect(decoded.findings).toHaveLength(1); + expect(decoded.findings[0]!.code).toBe("refused-identity-unchanged"); + expect(decoded.mapping).toBeNull(); + expect(decoded.files).toBeNull(); + expect(decoded.delta).toBeNull(); + }, + }, + { + label: + "delta explicitly unavailable as one datum beside a full plan " + + "(the unreadable-record state, SPEC 6.6, 14.23)", + doc: put(GOOD_PREVIEW, { unavailable: true }, "delta"), + verify: (decoded: ReturnType<typeof decodePreviewReport>): void => { + expect(decoded.delta).toEqual({ unavailable: true }); + expect(decoded.mapping).not.toBeNull(); + }, + }, + { + label: "empty plan lists ([] is emptiness, never null — SPEC 12.7)", + doc: { + findings: [], + mapping: [], + files: [], + delta: { generated: [], removed: [] }, + }, + verify: (decoded: ReturnType<typeof decodePreviewReport>): void => { + expect(decoded.mapping).toEqual([]); + expect(decoded.files).toEqual([]); + expect(decoded.delta).toEqual({ generated: [], removed: [] }); + }, + }, + ], + bad: [ + { + label: "missing findings member", + doc: omit(GOOD_PREVIEW, "findings"), + }, + { + label: "null findings (a list-valued member is [] when empty)", + doc: put(GOOD_PREVIEW, null, "findings"), + }, + { + label: "missing mapping member (null is never omitted, SPEC 12.7)", + doc: omit(GOOD_PREVIEW, "mapping"), + }, + { + label: "missing files member", + doc: omit(GOOD_PREVIEW, "files"), + }, + { + label: "missing delta member", + doc: omit(GOOD_PREVIEW, "delta"), + }, + { + label: + "an extra member on the document (12.7: exactly " + + "{findings, mapping, files, delta})", + doc: put(GOOD_PREVIEW, "rename", "operation"), + }, + { + label: + "mixed nullity: mapping null beside a present plan (null marks " + + "the refusal encoding, all three together — SPEC 6.6, 12.7)", + doc: put(GOOD_PREVIEW, null, "mapping"), + }, + { + label: "mixed nullity: a refusal document carrying a delta", + doc: put(REFUSED_PREVIEW, { generated: [], removed: [] }, "delta"), + }, + { + label: "mapping entries out of `from`-byte order", + doc: put( + GOOD_PREVIEW, + [...structuredClone(GOOD_PREVIEW.mapping)].reverse(), + "mapping", + ), + }, + { + label: + "two mapping entries for one identity (one {from, to} per " + + "mapped identity)", + doc: put( + GOOD_PREVIEW, + [ + { from: "specs/A.mdx#login", to: "specs/B.mdx#login" }, + { from: "specs/A.mdx#login", to: "specs/B.mdx#other" }, + ], + "mapping", + ), + }, + { + label: "mapping pair missing its to", + doc: omit(GOOD_PREVIEW, "mapping", 0, "to"), + }, + { + label: "mapping pair with an extra member", + doc: put(GOOD_PREVIEW, "rename", "mapping", 0, "via"), + }, + { + label: "empty from identity", + doc: put(GOOD_PREVIEW, "", "mapping", 0, "from"), + }, + { + label: "file entries out of path-byte order", + doc: put( + GOOD_PREVIEW, + [...structuredClone(GOOD_PREVIEW.files)].reverse(), + "files", + ), + }, + { + label: "two file entries for one path (one {file, edits} per file)", + doc: put( + GOOD_PREVIEW, + [ + structuredClone(GOOD_PREVIEW.files[0]), + structuredClone(GOOD_PREVIEW.files[0]), + ], + "files", + ), + }, + { + label: "file entry missing its edits", + doc: omit(GOOD_PREVIEW, "files", 0, "edits"), + }, + { + label: "null edits (a list-valued member is [] when empty)", + doc: put(GOOD_PREVIEW, null, "files", 0, "edits"), + }, + { + label: "file entry with an extra member", + doc: put(GOOD_PREVIEW, "hint", "files", 0, "note"), + }, + { + label: "an edit class outside the ten 12.7 names", + doc: put( + GOOD_PREVIEW, + "text-replacement", + "files", + 0, + "edits", + 0, + "class", + ), + }, + { + label: + "an edit carrying replacement text (class-plus-range only, " + + "SPEC 6.6, 12.7)", + doc: put(GOOD_PREVIEW, "new bytes", "files", 0, "edits", 0, "text"), + }, + { + label: "edit missing its range", + doc: omit(GOOD_PREVIEW, "files", 0, "edits", 0, "range"), + }, + { + label: "edits out of range-start order", + doc: put( + GOOD_PREVIEW, + [...structuredClone(GOOD_PREVIEW.files[0]!.edits)].reverse(), + "files", + 0, + "edits", + ), + }, + { + label: + "coinciding zero-length insertion points out of class-byte order " + + "(target-insertion may not precede import-addition, SPEC 12.7)", + doc: put( + GOOD_PREVIEW, + [...structuredClone(GOOD_PREVIEW.files[1]!.edits)].reverse(), + "files", + 1, + "edits", + ), + }, + { + label: "delta missing a direction (12.7: exactly {generated, removed})", + doc: omit(GOOD_PREVIEW, "delta", "removed"), + }, + { + label: "delta with an extra member", + doc: put(GOOD_PREVIEW, [], "delta", "changed"), + }, + { + label: "null delta direction (a list-valued member is [] when empty)", + doc: put(GOOD_PREVIEW, null, "delta", "generated"), + }, + { + label: "delta paths out of byte order", + doc: put( + GOOD_PREVIEW, + ["specs/B.xspec.ts", "specs/B.md"], + "delta", + "generated", + ), + }, + { + label: "one derived path listed twice in a direction", + doc: put( + GOOD_PREVIEW, + ["specs/B.md", "specs/B.md"], + "delta", + "generated", + ), + }, + { + label: + "a widened unavailability marker as delta (12.7: the marker is " + + 'exactly {"unavailable": true})', + doc: put(GOOD_PREVIEW, { unavailable: true, note: "x" }, "delta"), + }, + { + label: "findings out of the pinned order inside the document", + doc: put( + GOOD_PREVIEW, + [ + structuredClone(GOOD_FINDINGS.findings[1]), + structuredClone(GOOD_FINDINGS.findings[0]), + ], + "findings", + ), + }, + ], + }, { name: "12.7 error document", decode: decodeErrorDocument, diff --git a/test/suite/registry/index.ts b/test/suite/registry/index.ts index 1a7a1da..847040c 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -39,6 +39,7 @@ import { section62Tests } from "./section-6.2.js"; import { section63Tests } from "./section-6.3.js"; import { section64Tests } from "./section-6.4.js"; import { section65Tests } from "./section-6.5.js"; +import { section66Tests } from "./section-6.6.js"; import { section67Tests } from "./section-6.7.js"; import { section7BasicsTests } from "./section-7-basics.js"; import { section7DiscoveryTests } from "./section-7-discovery.js"; @@ -102,6 +103,7 @@ export const productTestSuite = new ProductTestSuite([ ...section63Tests, ...section64Tests, ...section65Tests, + ...section66Tests, ...section67Tests, ...section7BasicsTests, ...section7DiscoveryTests, diff --git a/test/suite/registry/section-6.6.ts b/test/suite/registry/section-6.6.ts new file mode 100644 index 0000000..6bf17af --- /dev/null +++ b/test/suite/registry/section-6.6.ts @@ -0,0 +1,369 @@ +// TEST-SPEC §6.6 (previews) — SUITE-24: T6.6-2. (T6.6-1 is retired; +// T6.6-3…T6.6-6 are staged by later plan tasks into this module.) +// +// Registered product-facing bodies (C-2 "one code path"): each builds its own +// fresh workspace (H-1), drives the product strictly as a subprocess (H-2), +// asserts exact exit codes (H-5), decodes output through the H-3 layer, and +// rejects a product only via diagnosed assertion failures (H-8). +// +// SPEC 6.6: `xspec rename … --preview` and `xspec move … --preview` perform +// the full validation and planning of the operation and report its +// consequences while modifying nothing — no sources, no journal, no derived +// files, no graph data. A preview succeeds exactly when the real operation +// would proceed, its output is byte-deterministic (12.0), and under `--json` +// it emits the preview document form of 12.7 — `{"findings", "mapping", +// "files", "delta"}`, a form-exact surface (H-3, adapters/forms.ts) — whose +// `mapping` is the complete identity mapping the operation would journal. +// +// Conservative operationalizations (noted per H-4): +// - T6.6-2 "every byte of the workspace identical afterward" is a +// whole-workspace-root byte snapshot compare around every preview +// invocation (assertLeavesUnchanged), run after a premise `build` so +// sources, generated modules, Markdown output, and graph data are all +// present under the compare — a preview that refreshes derived state or +// regenerates anything fails it. The journal premise (absent before the +// first journaled operation, SPEC 6.1) makes the same compare realize "an +// absent journal stays absent". +// - T6.6-2 "byte-deterministic across repeated runs" is H-6's +// same-command-twice protocol (assertRunTwiceDeterministic: +// byte-identical stdout, stderr, exit outcome, and workspace byte state +// across the two runs), applied to the `--json` form and to the bare +// (human) form alike — SPEC 6.6 pins determinism for preview output as +// such, not for one output form. Human-form content is otherwise +// unasserted (H-3: human reports are asserted only for required +// information; this test requires none of it). +// - T6.6-2 "a subsequent real run on the same state performs the previewed +// plan" is operationalized exactly as the TEST-SPEC entry states it: the +// real operation on the untouched workspace succeeds (exit 0, `--json`, +// a single JSON document as the entire stdout, 12.0) and its +// applied-mapping report (T6.4-1's protocol; H-3 adapter, report shape +// unpinned) carries exactly the preview's `mapping` pairs, compared as +// complete sets (assertAppliedMapping; the preview document's `from`-byte +// order is decode-enforced, SPEC 12.7). The mapping's fixture-expected +// CONTENT is T6.6-4's business — here the contract is the equality. +// - A successful preview's `mapping`, `files`, and `delta` are non-`null` +// (`null` is the refusal encoding, SPEC 6.6/12.7, and T6.6-2 stages +// workspaces where the real operation would proceed); `files` and `delta` +// content is T6.6-4's and T6.6-5's business. + +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import type { AppliedMappingPair } from "../../helpers/adapters/index.js"; +import { + decodeAppliedMappingReport, + decodePreviewReport, +} from "../../helpers/adapters/index.js"; +import { + assertExitCode, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; +import { assertRunTwiceDeterministic } from "../../helpers/determinism.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import type { ProductBinding } from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { + assertAppliedMapping, + assertSameJson, + buildOk, + runJson, +} from "./support.js"; + +// One spec group with Markdown emission (SPEC 7, 7.3), so the premise +// `build` materializes every derived-file kind — generated modules, Markdown +// output, and graph data — and the modifies-nothing compare covers them all. +const SPECS_MD_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + markdown: { emit: true } +}) +`; + +const JOURNAL_PATH = ".xspec/journal"; + +/** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ +async function withWorkspace<T>( + config: string, + files: Readonly<Record<string, string>>, + body: (workspace: TestWorkspace) => Promise<T>, +): Promise<T> { + const workspace = await TestWorkspace.create({ + files: { "xspec.config.ts": config, ...files }, + }); + try { + return await body(workspace); + } finally { + await workspace.dispose(); + } +} + +/** + * The T6.6-2 preview protocol over one operation whose real run would + * proceed: inside one whole-root modifies-nothing compare (SPEC 6.6 — no + * sources, no journal, no derived files, no graph data), run the `--preview + * --json` invocation twice (H-6 byte determinism) asserting exit 0, decode + * the first run's stdout as the form-exact 12.7 preview document, assert + * `findings` is exactly `[]` and the plan members are non-`null`, then run + * the bare `--preview` form twice (H-6 again, exit 0). Returns the preview's + * `mapping` for the caller's real-run equality assertion. + */ +async function expectInertPreview( + product: ProductBinding, + workspace: TestWorkspace, + operationArgv: readonly string[], + context: string, +): Promise<readonly AppliedMappingPair[]> { + const jsonArgv = [...operationArgv, "--preview", "--json"]; + const bareArgv = [...operationArgv, "--preview"]; + return await assertLeavesUnchanged( + workspace.root, + async () => { + const { first } = await assertRunTwiceDeterministic({ + binding: product, + run: { cwd: workspace.root, argv: jsonArgv }, + context: + `${context}: \`${jsonArgv.join(" ")}\` byte determinism across ` + + `repeated runs (SPEC 6.6, 12.0; H-6)`, + }); + assertExitCode( + first, + 0, + `${context}: \`${jsonArgv.join(" ")}\` — the preview succeeds ` + + `exactly when the real operation would proceed, and this staging ` + + `is a valid operation on a valid workspace (SPEC 6.6)`, + ); + const report = decodePreviewReport( + parseJsonStdout( + first, + `${context}: \`${jsonArgv.join(" ")}\` — a single JSON document ` + + `as the entire stdout (SPEC 12.0)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: a preview whose real operation would proceed reports ` + + `findings [] (SPEC 6.6, 12.7)`, + ); + if ( + report.mapping === null || + report.files === null || + report.delta === null + ) { + fail( + `${context}: a successful preview reports its plan — \`mapping\`, ` + + `\`files\`, and \`delta\` are null exactly on refusal (SPEC 6.6, ` + + `12.7); got mapping ${report.mapping === null ? "null" : "present"}, ` + + `files ${report.files === null ? "null" : "present"}, delta ` + + `${report.delta === null ? "null" : "present"}`, + ); + } + const bare = await assertRunTwiceDeterministic({ + binding: product, + run: { cwd: workspace.root, argv: bareArgv }, + context: + `${context}: \`${bareArgv.join(" ")}\` byte determinism across ` + + `repeated runs (SPEC 6.6, 12.0; H-6 — determinism binds preview ` + + `output as such, the bare form included)`, + }); + assertExitCode( + bare.first, + 0, + `${context}: \`${bareArgv.join(" ")}\` — the bare-form preview of a ` + + `proceeding operation succeeds too (SPEC 6.6, 12.0)`, + ); + return report.mapping; + }, + `${context}: every preview invocation modifies nothing — sources, ` + + `journal (an absent journal stays absent), derived files, and graph ` + + `data untouched (SPEC 6.6)`, + ); +} + +/** + * The staging premises shared by both arms: the staged workspace builds + * (derived files and graph data now exist under the compare) and no journal + * exists before the first journaled operation (SPEC 6.1) — so the + * modifies-nothing compare around the previews realizes "an absent journal + * stays absent", and the real run at the end is the first journaled + * operation. + */ +async function assertPreviewPremises( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<void> { + await buildOk(product, workspace, `${context} premise \`build\``); + const journalKind = await workspace.kind(JOURNAL_PATH); + if (journalKind !== "absent") { + fail( + `${context}: staging premise — no journal file exists before the ` + + `first journaled operation (SPEC 6.1); found ${journalKind} at ` + + `${JOURNAL_PATH}`, + ); + } +} + +/** + * The subsequent real run on the same (untouched) state: exit 0 with + * `--json`, the applied-mapping report decoded through T6.4-1's H-3 adapter + * and asserted equal — as a complete set — to the preview's `mapping`. + */ +async function assertRealRunPerformsPlan( + product: ProductBinding, + workspace: TestWorkspace, + operationArgv: readonly string[], + previewMapping: readonly AppliedMappingPair[], + context: string, +): Promise<void> { + const argv = [...operationArgv, "--json"]; + const applied = decodeAppliedMappingReport( + await runJson( + product, + workspace, + argv, + `${context}: \`${argv.join(" ")}\``, + ), + context, + ); + assertAppliedMapping( + applied, + previewMapping, + `${context}: a subsequent real run on the same state performs the ` + + `previewed plan — its applied mapping (T6.4-1's report) equals the ` + + `preview's \`mapping\`, pair for pair (SPEC 6.6, 6.4, 6.5)`, + ); +} + +// --------------------------------------------------------------------------- +// T6.6-2 — modifies nothing +// --------------------------------------------------------------------------- + +// Rename arm: `core.mid` is mid-tree with a descendant (the mapping holds +// two pairs by prefix replacement) and is referenced by a sibling's local +// `d` and `text(...)` (SPEC 6.4 rewrites them), so the previewed plan spans +// several edits while the workspace stays a single file — the real rename is +// unambiguously valid: `core.hub` collides with nothing, its parent `core` +// exists, and the workspace has no findings. +const P1_CORE = "specs/Core.mdx"; +const P1_CORE_SOURCE = [ + '<S id="core">', + "Core holder text.", + "", + '<S id="core.mid" d={"core.plain"}>', + "Mid text.", + "", + '<S id="core.mid.leaf">', + "Leaf text.", + "</S>", + "</S>", + "", + '<S id="core.sib" d={"core.mid"}>', + 'Sib embeds: {text("core.mid.leaf")}', + "</S>", + "", + '<S id="core.plain">', + "Plain text.", + "</S>", + "</S>", + "", +].join("\n"); +const P1_RENAME_ARGV = ["rename", P1_CORE, "core.mid", "core.hub"] as const; + +// Section-form move arm: `org.mv` moves into the existing Target.mdx as +// top-level `tm`. The subtree carries an internal local reference — on the +// moved root, pointing down at its own child, so the combined +// contains/depends graph stays acyclic (SPEC 5.3) — re-identified in place +// by the move, and is referenced from the staying `org.stay` (converted to +// imported form by the real move, an import added), so the previewed plan +// again spans several files — and the move is unambiguously valid: `tm` +// collides with nothing in Target.mdx, a single-segment `<new-id>` needs no +// target parent, and no cycle arises (Target.mdx imports nothing). +const P2_ORIGIN = "specs/Origin.mdx"; +const P2_TARGET = "specs/Target.mdx"; +const P2_ORIGIN_SOURCE = [ + '<S id="org">', + "Origin holder text.", + "", + '<S id="org.mv" d={"org.mv.k1"}>', + "Moved root text.", + "", + '<S id="org.mv.k1">', + "Moved kid.", + "</S>", + "</S>", + "", + '<S id="org.stay" d={"org.mv.k1"}>', + "Stays behind.", + "</S>", + "</S>", + "", +].join("\n"); +const P2_TARGET_SOURCE = ['<S id="tgt">', "Target text.", "</S>", ""].join( + "\n", +); +const P2_MOVE_ARGV = [ + "move", + `${P2_ORIGIN}#org.mv`, + `${P2_TARGET}#tm`, +] as const; + +const T6_6_2 = defineProductTest({ + id: "T6.6-2", + title: + "modifies nothing: a rename `--preview` and a section-form move `--preview` on workspaces where the real operation would proceed exit 0 with findings [] and leave every byte of the workspace identical — sources, journal (an absent journal stays absent), derived files, and graph data untouched; a subsequent real run on the same state performs the previewed plan, its applied mapping (T6.4-1's report) equal to the preview's `mapping`; preview output is byte-deterministic across repeated runs (H-6) and, under `--json`, the form-exact 12.7 preview document (SPEC 6.6, 6.4, 6.5, 6.1, 12.0, 12.7; H-3)", + run: async (product) => { + // Arm 1 — rename preview. + await withWorkspace( + SPECS_MD_CONFIG, + { [P1_CORE]: P1_CORE_SOURCE }, + async (workspace) => { + await assertPreviewPremises(product, workspace, "T6.6-2 rename arm"); + const previewMapping = await expectInertPreview( + product, + workspace, + P1_RENAME_ARGV, + "T6.6-2 rename arm", + ); + await assertRealRunPerformsPlan( + product, + workspace, + P1_RENAME_ARGV, + previewMapping, + "T6.6-2 rename arm", + ); + }, + ); + + // Arm 2 — section-form move preview. + await withWorkspace( + SPECS_MD_CONFIG, + { + [P2_ORIGIN]: P2_ORIGIN_SOURCE, + [P2_TARGET]: P2_TARGET_SOURCE, + }, + async (workspace) => { + await assertPreviewPremises(product, workspace, "T6.6-2 move arm"); + const previewMapping = await expectInertPreview( + product, + workspace, + P2_MOVE_ARGV, + "T6.6-2 move arm", + ); + await assertRealRunPerformsPlan( + product, + workspace, + P2_MOVE_ARGV, + previewMapping, + "T6.6-2 move arm", + ); + }, + ); + }, +}); + +export const section66Tests: readonly ProductTestEntry[] = [T6_6_2]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 2a76711..d93a171 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -215,6 +215,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.5-5": ["6.5", "12.0"], "T6.5-6": ["6.5", "14"], "T6.5-7": ["6.5"], + "T6.6-2": ["6.6"], "T6.7-1": ["6.7"], "T7-1": ["7", "14"], "T7-2": ["7", "14"], diff --git a/test/suite/section-6.6.test.ts b/test/suite/section-6.6.test.ts new file mode 100644 index 0000000..8a03aca --- /dev/null +++ b/test/suite/section-6.6.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §6.6 (SUITE-24): thin Vitest wrapper over the registered +// bodies — the identical bodies the certification runner executes against +// fixture products (C-2 "one code path"). Expected to fail as diagnosed +// assertion failures until the product exists (H-8). + +import { declareProductTests } from "./declare.js"; +import { section66Tests } from "./registry/section-6.6.js"; + +declareProductTests(section66Tests); From 12cf7b270a60a80eecd2f19f524a5487da61563a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 21:02:45 +0000 Subject: [PATCH 074/226] =?UTF-8?q?sdg(phase-9):=20implement=20T6.6-3=20?= =?UTF-8?q?=E2=80=94=20preview=20refusal=20and=20scheduling=20equivalence?= =?UTF-8?q?=20(FP-029)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refusal equivalence over T6.4-3's and T6.5-4's complete case tables, staged identically via new module-scope exports of section-6.4.ts and section-6.5.ts (derived-path and precondition arms included): real refusal --json exit 1 (form-exact 12.7 findings, per-arm counts), then --preview --json exit 1 as the 12.7 preview document with mapping/ files/delta null and the same findings (all members but message), nothing modified. Usage equivalence: every T6.4-4/T6.5-5 usage error real-then-preview, exit 2 with the 12.7 error document either way, on the ordering-shaped stagings. Scheduling: a preview runs to completion exit 0 while another mutating command is held (T13.5-2's staging; section-13.5.ts exports the hold choreography), and --test-hold with --preview is exit 2, no hold file, nothing modified. Traceability T6.6-3 -> [6.6, 14]. Red-as-diagnosed against the stub; soundness and teeth proven by a scratch conforming-shim probe and four deviations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 62 ++- test/suite/registry/section-13.5.ts | 18 +- test/suite/registry/section-6.4.ts | 293 ++++++----- test/suite/registry/section-6.5.ts | 707 ++++++++++++++++----------- test/suite/registry/section-6.6.ts | 729 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 3 +- 6 files changed, 1383 insertions(+), 429 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index aff3046..f508471 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1089,12 +1089,72 @@ A "new test T<x>" task always means, in one change: keys now the 7-key set {11.2, 11.3, 11.4, 11.5, 11.6, 12.6, 12.7} — "6.6" mapped by this task), S-5 (70 tests) and certification green.] -- [ ] FP-029 — Implement T6.6-3: refusal/usage-error equivalence under +- [x] FP-029 — Implement T6.6-3: refusal/usage-error equivalence under `--preview`. [R1 #8; TEST-SPEC §6.6] Same stable codes as the real operation; exit 1 with `mapping`/`files`/ `delta` null; exit 2 identically; preview runs to completion under `--test-hold` held by another command; `--test-hold` + `--preview` is a usage error. + [Done 2026-08-11: T6.6-3 registered in section-6.6.ts. Refusal + equivalence runs over T6.4-3's and T6.5-4's COMPLETE case tables staged + identically — the stagings and expectation tables are now module-scope + exports of section-6.4.ts (RENAME_REFUSAL_*) and section-6.5.ts + (MOVE_REFUSAL_* + stageMoveRefusalOccupants, MOVE_DERIVED_PATH_*, + MOVE_PRECONDITION_* — the derived-path and invalid-workspace-precondition + arms included), consumed by the source tests and T6.6-3 alike so "staged + identically" holds by construction (behavior-preserving refactor: + section-6.4 unchanged 4 failed/3 passed, section-6.5 unchanged + 5 failed/2 passed, section-13.5 7/7 green). Each of the 24 refusal arms, + inside one whole-root modifies-nothing compare: real invocation `--json` + exit 1, form-exact 12.7 findings decode, per-arm code counts re-pinned + (the arm still isolates its staged cause; concerned-data assertions stay + in T6.4-3/T6.5-4), then `--preview --json` exit 1 decoded as the preview + document (refused encoding: mapping/files/delta all null; mixed nullity + already decode-rejected) with findings compared element-wise to the real + report over every member except message — code, locations, path, + identities, the contractual members (message composition unpinned, H-4; + both sides decode-validated in 12.7's total order whose keys precede the + message tie-break on exactly those members). Usage equivalence: every + T6.4-4/T6.5-5 usage-error invocation (exported tables RENAME_USAGE_CASES, + MOVE_USAGE_CASES, MOVE_WRONG_KIND_CASES, MOVE_MIXED_SYNOPSIS_CASES, + MOVE_NON_UTF8_ARGV Linux-gated, both solo argvs) runs real-then-preview + on the ordering-shaped staging (failing-build premise pinned, realizing + "argument checks precede either way"), each side exit 2 with the single + 12.7 error document and stderr presence, each sweep inside a + modifies-nothing compare; the spells-no-identity arms re-pin their + exactly-one-14.17 premise first. Scheduling: the runs-while-held arm + reuses T13.5-2's staging and choreography (section-13.5.ts now exports + CORE_DECL/holdPathFor/awaitHoldFile/runBounded/describeExit — the + CERTIFICATIONS.md Exclusions note binds exactly this sharing): while the + real `rename a a2 --test-hold` is held, `rename g g2 --preview --json` — + T13.5-2's refused second command, previewed — runs to completion exit 0 + (bounded run: a blocking product fails diagnosed) with findings [] and a + non-null plan, command 1 still running, held-baseline snapshot unchanged; + release → command 1 exits 0. `--test-hold`+`--preview` asserted for both + operations and both flag orders: exit 2, error document, stderr, no hold + file created, nothing modified. Traceability "T6.6-3": ["6.6", "14"] + (TEST-SPEC 14's refusal-reason staging record names T6.6-3; map comment + updated); CERTIFICATIONS.md keeps T6.6-3 in the Exclusions — no fixture + scope. No new adapter (decodePreviewReport/decodeFindingsReport/ + decodeErrorDocument are S-5-guarded already; the projection is a local + assertion). Verified: T6.6-3 red-as-diagnosed at the FIRST refusal arm's + real-side form-exact findings decode (the stub emits `{"refused":…}` — + the pre-existing FP-001/FP-007-class product gap), the preview-side gap + behind it (direct probe: `--preview` is exit 2 "unknown flag '--preview'", + the whole 6.6 surface patch-new); satisfiability and teeth proven by + running the registered body via a scratch binding against a conforming + shim (real-product delegation for build/held commands, synthetic 12.7 + refusal/usage/preview answers generated FROM the exported expectation + tables, old→12.7 build-findings translation for the premises, non-UTF-8 + detected from raw /proc/self/cmdline) — green end-to-end through all + arms, the held choreography running against the real product's lock — + and against four deviations: preview-drops-a-finding (red at the + same-findings compare), refused-preview-reports-a-plan (red at the + null-plan assertion), preview-writes-a-file (red at the modifies-nothing + compare), preview-refuses-while-held (red at the runs-while-held exit-0 + assertion). Typecheck/format clean; `npm run test:self` unchanged 4 + planned mid-loop reds (certification-document ×3 → FP-091; S-1's 7 + unmapped keys → stages E/G), S-5 and certification green.] - [ ] FP-030 — Implement T6.6-4: preview report content — the ten 12.7 edit classes. [R1 #9; TEST-SPEC §6.6, SPEC 12.7] diff --git a/test/suite/registry/section-13.5.ts b/test/suite/registry/section-13.5.ts index dcf174d..e8c9129 100644 --- a/test/suite/registry/section-13.5.ts +++ b/test/suite/registry/section-13.5.ts @@ -146,7 +146,12 @@ const A_MDX = [ "", ].join("\n"); -const CORE_DECL: WorkspaceDecl = { +/** + * The CONF-CORE-shaped staging shared by the 13.5 lock tests — and by + * T6.6-3's runs-while-held arm, which per CERTIFICATIONS.md shares this + * drive-during-hold choreography (T13.5-2's staging). + */ +export const CORE_DECL: WorkspaceDecl = { files: { "xspec.config.ts": SPECS_ONLY_CONFIG, "specs/A.mdx": A_MDX }, }; @@ -173,9 +178,10 @@ async function withWorkspace<T>( /** * An absolute hold-file path in the workspace's temporary directory — beside * the workspace root, never inside it, so whole-root byte snapshots are - * unaffected and disposal cleans it up. + * unaffected and disposal cleans it up. Exported for T6.6-3, which shares + * this module's drive-during-hold choreography (CERTIFICATIONS.md). */ -function holdPathFor(workspace: TestWorkspace, name: string): string { +export function holdPathFor(workspace: TestWorkspace, name: string): string { return path.join(workspace.tempRoot, name); } @@ -184,7 +190,7 @@ function holdPathFor(workspace: TestWorkspace, name: string): string { * rejection (the process exited first, or the wait timed out) into a * diagnosed assertion failure (H-8). */ -async function awaitHoldFile( +export async function awaitHoldFile( running: RunningProduct, absPath: string, context: string, @@ -230,7 +236,7 @@ async function assertEmptyHoldFile( } /** One-line outcome of a settled run, for premature-exit diagnoses. */ -async function describeExit(running: RunningProduct): Promise<string> { +export async function describeExit(running: RunningProduct): Promise<string> { try { return summarizeResult(await running.waitForExit()); } catch (error) { @@ -244,7 +250,7 @@ async function describeExit(running: RunningProduct): Promise<string> { * a diagnosed assertion failure (H-8). The bound is a hang guard, never an * assertion input (H-10). */ -async function runBounded( +export async function runBounded( product: ProductBinding, cwd: string, argv: readonly string[], diff --git a/test/suite/registry/section-6.4.ts b/test/suite/registry/section-6.4.ts index c74a7b8..b73b8cc 100644 --- a/test/suite/registry/section-6.4.ts +++ b/test/suite/registry/section-6.4.ts @@ -342,9 +342,11 @@ function assertRewriteHappened( * What a refused rename's report must hold (SPEC 14, 12.7): the arm's one * finding — its exact stable code — plus whichever concern §14 assigns the * reason: a located bearer/spelling, a concerned identity, or nothing further - * where the concern's rendering is the reason's message alone. + * where the concern's rendering is the reason's message alone. Exported for + * T6.6-3, which stages T6.4-3's refusals identically and asserts the + * `--preview` invocation's refusal equivalence (TEST-SPEC §6.6). */ -interface RefusalExpectation { +export interface RefusalExpectation { /** * The finding's counting key (`assertConditionCounts` vocabulary): a * stable refusal code token (`refused-…`), or a `14.N` condition identity @@ -1093,14 +1095,100 @@ const V3_SIB_WINDOW = byteWindow( V3_SIB_CONSTRUCT, ); +/** + * One T6.4-3 refusal case: the full rename argv (without `--json`), the one + * refusal finding the staging isolates (SPEC 14), and its diagnosis context. + */ +export interface RenameRefusalCase { + readonly argv: readonly string[]; + readonly expected: RefusalExpectation; + readonly reason: string; +} + +/** + * T6.4-3's staging and complete refusal-case table, exported so T6.6-3 can + * stage each refusal identically and assert the `--preview` invocation's + * refusal equivalence over it (TEST-SPEC §6.6: "for each refusal of T6.4-3 + * and T6.5-4 — the invalid-workspace precondition included — staged + * identically"). Each case's argv runs against a fresh RENAME_REFUSAL_CONFIG + * + RENAME_REFUSAL_FILES workspace after a premise `build` (the T6.4-3 + * protocol: derived files sit under the modifies-nothing compares). + */ +export const RENAME_REFUSAL_CONFIG = SPECS_ONLY_CONFIG; +export const RENAME_REFUSAL_FILES: Readonly<Record<string, string>> = { + [V3_FILE]: V3_SOURCE, +}; + +// Each arm's expected refusal finding (SPEC 14): the exact stable code, with +// the concerned identity (`refused-invalid-id` and `refused-structural-parent` +// concern the offending identity; `refused-identity-unchanged` concerns the +// unchanged one) or the located remaining colliding bearer +// (`refused-id-collision` locates every colliding bearer). The final case is +// the top-level structural arm: a top-level section's ID is checked against +// the empty prefix — exactly one segment (SPEC 1.3). +export const RENAME_REFUSAL_CASES: readonly RenameRefusalCase[] = [ + { + argv: ["rename", V3_FILE, "a.mid", "a.then"], + expected: { + finding: "refused-invalid-id", + identity: { file: V3_FILE, id: "a.then" }, + }, + reason: "new ID invalid per 1.4 — its segment is the forbidden name `then`", + }, + { + argv: ["rename", V3_FILE, "a.mid", "a.mi d"], + expected: { + finding: "refused-invalid-id", + identity: { file: V3_FILE, id: "a.mi d" }, + }, + reason: "new ID invalid per 1.4 — its segment contains whitespace", + }, + { + argv: ["rename", V3_FILE, "a.mid", "a.mid"], + expected: { + finding: "refused-identity-unchanged", + identity: { file: V3_FILE, id: "a.mid" }, + }, + reason: "new ID equal to the old ID", + }, + { + argv: ["rename", V3_FILE, "a.mid", "a.sib"], + expected: { + finding: "refused-id-collision", + locatedAt: { file: V3_FILE, window: V3_SIB_WINDOW }, + }, + reason: "new ID colliding with an existing ID in the file", + }, + { + argv: ["rename", V3_FILE, "a.mid", "x.mid"], + expected: { + finding: "refused-structural-parent", + identity: { file: V3_FILE, id: "x.mid" }, + }, + reason: + "new ID violating the structural parent rules — the node is nested " + + "inside `a`, so its ID must be `a` plus one segment (1.3)", + }, + { + argv: ["rename", V3_FILE, "a", "b.c"], + expected: { + finding: "refused-structural-parent", + identity: { file: V3_FILE, id: "b.c" }, + }, + reason: + "new ID violating the structural parent rules — a top-level section's " + + "ID has exactly one segment (1.3)", + }, +]; + const T6_4_3 = defineProductTest({ id: "T6.4-3", title: "validation refusals (exit 1): a new ID that is invalid (1.4), equal to the old ID, colliding with an existing ID, or violating structural parent rules each refuses the rename and modifies nothing (workspace byte-compare) — each refusal reported as the form-exact 12.7 findings-only report holding exactly one finding with its exact stable refusal code (refused-invalid-id, refused-identity-unchanged, refused-id-collision, refused-structural-parent) and the concerned identity or located colliding bearer (SPEC 6.4, 1.4, 1.3, 12.0, 12.7, 14)", run: async (product) => { await withWorkspace( - SPECS_ONLY_CONFIG, - { [V3_FILE]: V3_SOURCE }, + RENAME_REFUSAL_CONFIG, + RENAME_REFUSAL_FILES, async (workspace) => { // Build first, so the modifies-nothing compares include intact // derived files (module header, H-4). @@ -1109,82 +1197,17 @@ const T6_4_3 = defineProductTest({ workspace, "T6.4-3 `build` over the staged workspace", ); - - // Each arm's expected refusal finding (SPEC 14): the exact stable - // code, with the concerned identity (`refused-invalid-id` and - // `refused-structural-parent` concern the offending identity; - // `refused-identity-unchanged` concerns the unchanged one) or the - // located remaining colliding bearer (`refused-id-collision` - // locates every colliding bearer). - const cases: readonly (readonly [ - string, - RefusalExpectation, - string, - ])[] = [ - [ - "a.then", - { - finding: "refused-invalid-id", - identity: { file: V3_FILE, id: "a.then" }, - }, - "new ID invalid per 1.4 — its segment is the forbidden name `then`", - ], - [ - "a.mi d", - { - finding: "refused-invalid-id", - identity: { file: V3_FILE, id: "a.mi d" }, - }, - "new ID invalid per 1.4 — its segment contains whitespace", - ], - [ - "a.mid", - { - finding: "refused-identity-unchanged", - identity: { file: V3_FILE, id: "a.mid" }, - }, - "new ID equal to the old ID", - ], - [ - "a.sib", - { - finding: "refused-id-collision", - locatedAt: { file: V3_FILE, window: V3_SIB_WINDOW }, - }, - "new ID colliding with an existing ID in the file", - ], - [ - "x.mid", - { - finding: "refused-structural-parent", - identity: { file: V3_FILE, id: "x.mid" }, - }, - "new ID violating the structural parent rules — the node is nested " + - "inside `a`, so its ID must be `a` plus one segment (1.3)", - ], - ]; - for (const [newId, expected, reason] of cases) { + // The complete case table (module scope, shared with T6.6-3's + // preview-refusal equivalence — TEST-SPEC §6.6 "staged identically"). + for (const { argv, expected, reason } of RENAME_REFUSAL_CASES) { await expectRefusalModifiesNothing( product, workspace, - ["rename", V3_FILE, "a.mid", newId], + argv, expected, `T6.4-3 (${reason})`, ); } - // The top-level structural arm: a top-level section's ID is checked - // against the empty prefix — exactly one segment (SPEC 1.3). - await expectRefusalModifiesNothing( - product, - workspace, - ["rename", V3_FILE, "a", "b.c"], - { - finding: "refused-structural-parent", - identity: { file: V3_FILE, id: "b.c" }, - }, - "T6.4-3 (new ID violating the structural parent rules — a " + - "top-level section's ID has exactly one segment, 1.3)", - ); }, ); }, @@ -1276,6 +1299,55 @@ const U4_SOLO_SOURCE = [ "", ].join("\n"); +/** + * T6.4-4's usage-error invocations over the shared U4 staging (exit 2, + * checked before source validation), exported so T6.6-3 can assert each + * `--preview` variant exits 2 identically (TEST-SPEC §6.6: "for the usage + * errors of T6.4-4/T6.5-5 the preview exits 2 identically — argument checks + * precede either way"). They ride T6.4-4's base arm (valid workspace) and + * ordering arm (unrelated validation errors present) alike. + */ +export const RENAME_USAGE_CASES: readonly (readonly [ + readonly string[], + string, +])[] = [ + [["rename", "specs/Missing.mdx", "a", "a2"], "nonexistent <file>"], + [["rename", U4_FILE, "nope", "nope2"], "nonexistent old ID"], + [ + ["rename", U4_CODE_FILE, "a", "a2"], + "discovered code source as <file> — a code source bears no requirement " + + "IDs, so a code-source origin is a wrong-kind operand, judged like " + + "existence before any content question (SPEC 6.4, 12.0)", + ], +]; + +/** The ordering arm's staging (valid sources + a failing file + the code + * source), exported for T6.6-3: on it, exit 2 beside unrelated validation + * errors realizes "argument checks precede" — previewed or not. */ +export const RENAME_USAGE_CONFIG = SPEC_AND_CODE_CONFIG; +export const RENAME_USAGE_ORDERING_FILES: Readonly<Record<string, string>> = { + [U4_FILE]: U4_SOURCE, + [U4_BAD_FILE]: U4_BAD_SOURCE, + [U4_CODE_FILE]: U4_CODE_SOURCE, +}; + +/** + * T6.4-4's parse-local nonexistence staging (the sole would-be bearer spells + * no identity — its `id` attribute repeated): the rename is exit 2 even + * beside that file's findings. Exported for T6.6-3's preview variant; stage + * under RENAME_REFUSAL_CONFIG (the same specs-only configuration) and pin + * the one-14.17 premise before invoking. + */ +export const RENAME_SOLO_FILES: Readonly<Record<string, string>> = { + [U4_SOLO_FILE]: U4_SOLO_SOURCE, +}; +export const RENAME_SOLO_ARGV: readonly string[] = [ + "rename", + U4_SOLO_FILE, + "solo", + "solo2", +]; + const T6_4_4 = defineProductTest({ id: "T6.4-4", title: @@ -1288,38 +1360,21 @@ const T6_4_4 = defineProductTest({ async (workspace) => { const context = "T6.4-4 valid-workspace arm"; await buildOk(product, workspace, `${context}: \`build\``); - await expectRenameUsageError( - product, - workspace, - ["rename", "specs/Missing.mdx", "a", "a2"], - `${context}, nonexistent <file>`, - ); - await expectRenameUsageError( - product, - workspace, - ["rename", U4_FILE, "nope", "nope2"], - `${context}, nonexistent old ID`, - ); - await expectRenameUsageError( - product, - workspace, - ["rename", U4_CODE_FILE, "a", "a2"], - `${context}, discovered code source as <file> — a code source ` + - `bears no requirement IDs, so a code-source origin is a ` + - `wrong-kind operand, judged like existence before any content ` + - `question (SPEC 6.4, 12.0)`, - ); + for (const [argv, label] of RENAME_USAGE_CASES) { + await expectRenameUsageError( + product, + workspace, + argv, + `${context}, ${label}`, + ); + } }, ); // --- Ordering arm: the workspace also fails build validation --- await withWorkspace( - SPEC_AND_CODE_CONFIG, - { - [U4_FILE]: U4_SOURCE, - [U4_BAD_FILE]: U4_BAD_SOURCE, - [U4_CODE_FILE]: U4_CODE_SOURCE, - }, + RENAME_USAGE_CONFIG, + RENAME_USAGE_ORDERING_FILES, async (workspace) => { const context = "T6.4-4 ordering arm"; // Staging premise: the workspace really fails build validation, so @@ -1338,28 +1393,16 @@ const T6_4_4 = defineProductTest({ `at least one validation finding (SPEC 14)`, ); } - await expectRenameUsageError( - product, - workspace, - ["rename", "specs/Missing.mdx", "a", "a2"], - `${context}, nonexistent <file> with unrelated validation errors ` + - `present — the existence checks precede source validation (12.0)`, - ); - await expectRenameUsageError( - product, - workspace, - ["rename", U4_FILE, "nope", "nope2"], - `${context}, nonexistent old ID with unrelated validation errors ` + - `present — the existence checks precede source validation (12.0)`, - ); - await expectRenameUsageError( - product, - workspace, - ["rename", U4_CODE_FILE, "a", "a2"], - `${context}, wrong-kind (code-source) <file> with unrelated ` + - `validation errors present — the wrong-kind operand is judged ` + - `like existence, before source validation (SPEC 6.4, 12.0)`, - ); + for (const [argv, label] of RENAME_USAGE_CASES) { + await expectRenameUsageError( + product, + workspace, + argv, + `${context}, ${label}, with unrelated validation errors present ` + + `— the existence and wrong-kind checks precede source ` + + `validation (SPEC 6.4, 12.0)`, + ); + } }, ); @@ -1452,8 +1495,8 @@ const T6_4_4 = defineProductTest({ // --- Parse-local nonexistence: a would-be bearer spelling no // identity --- await withWorkspace( - SPECS_ONLY_CONFIG, - { [U4_SOLO_FILE]: U4_SOLO_SOURCE }, + RENAME_REFUSAL_CONFIG, + RENAME_SOLO_FILES, async (workspace) => { const context = "T6.4-4 spells-no-identity arm"; // Staging premise: the repeated-`id` bearer leaves the file with @@ -1480,7 +1523,7 @@ const T6_4_4 = defineProductTest({ await expectRenameUsageError( product, workspace, - ["rename", U4_SOLO_FILE, "solo", "solo2"], + RENAME_SOLO_ARGV, `${context}: an old ID whose only would-be bearer spells no ` + `identity (its \`id\` attribute repeated on the tag) is ` + `nonexistent — exit 2 even beside that file's findings ` + diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index 00493fe..44990c1 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -462,8 +462,10 @@ function renderArgv(argv: readonly ArgvValue[]): string { * further where no pre-operation construct renders the concern. An arm * staging several applicable reasons passes one expectation per reason * (SPEC 14: every applicable reason reports together, one finding each). + * Exported for T6.6-3, which stages T6.5-4's refusals identically and + * asserts the `--preview` invocation's refusal equivalence (TEST-SPEC §6.6). */ -interface RefusalExpectation { +export interface RefusalExpectation { /** * The finding's counting key (`assertConditionCounts` vocabulary): a * stable refusal code token (`refused-…`), or a `14.N` condition identity @@ -1724,6 +1726,276 @@ export default defineConfig({ const V4_SOLO = "specs/Solo.mdx"; const V4_SOLO_SOURCE = ['<S id="solo">', "Solo text.", "</S>", ""].join("\n"); const V4_MDOUT_OCCUPANT = "mdout/new"; +const V4_MDOUT_OCCUPANT_CONTENT = "not a directory\n"; + +/** + * One T6.5-4 refusal case: the full move argv (without `--json`), the + * expected refusal finding — or one expectation per applicable reason where + * the staging carries several (SPEC 14) — and its diagnosis context. + */ +export interface MoveRefusalCase { + readonly argv: readonly string[]; + readonly expected: RefusalExpectation | readonly RefusalExpectation[]; + readonly reason: string; +} + +/** + * T6.5-4's main-workspace staging and complete refusal-case table, exported + * so T6.6-3 can stage each refusal identically and assert the `--preview` + * invocation's refusal equivalence over it (TEST-SPEC §6.6: "for each + * refusal of T6.4-3 and T6.5-4 — the invalid-workspace precondition included + * — staged identically"). The workspace is MOVE_REFUSAL_CONFIG + + * MOVE_REFUSAL_FILES with the destination occupants staged by + * `stageMoveRefusalOccupants` BEFORE the premise `build` (which must still + * pass — the staging note above the V4 fixtures). + */ +export const MOVE_REFUSAL_CONFIG = REFUSAL_CONFIG; +export const MOVE_REFUSAL_FILES: Readonly<Record<string, string>> = { + [V4_A]: V4_A_SOURCE, + [V4_B]: V4_B_SOURCE, + [V4_OCC]: V4_OCC_SOURCE, +}; + +/** + * Destination occupants (the V4 staging note): non-file occupants at + * in-group `.mdx` paths discovery ignores, staged before the pre-refusal + * `build` — a directory is no source file and discovery never yields a + * symbolic link (SPEC 7), so the build stays valid and each occupant arm + * refuses on exactly its staged ground. + */ +export async function stageMoveRefusalOccupants( + workspace: TestWorkspace, +): Promise<void> { + await workspace.dir(V4_DIR_TARGET); + await workspace.symlink(V4_SYM_DEST, "B.mdx"); + await workspace.symlink(V4_LINK_TARGET, "B.mdx"); + await workspace.symlink(V4_GONE_DEST, "missing-target.mdx"); +} + +// Each case's expected refusal finding (SPEC 14): the exact stable code with +// the concern §14 assigns the reason — identity, path, or located +// participant (the module header's T6.5-4 note walks the per-reason +// choices). No `#`-containing and no non-UTF-8 destination case: those 6.5 +// destination clauses are dead letters as refusals (T6.5-4's note) — every +// spelling that would present either is an exit-2 usage error first, staged +// in T6.5-5. +export const MOVE_REFUSAL_CASES: readonly MoveRefusalCase[] = [ + { + argv: ["move", "specs/A.mdx#mv", "specs/B.mdx#bmv"], + // The would-be spec import cycle's participating import declarations + // exist in no pre-operation source (the move would add both), so no + // concern window is assertable: the case pins the exact code and the + // 12.7 form alone. + expected: { finding: "refused-cycle" }, + reason: + "spec import cycle — the moved node's local `d` on `keep` needs " + + "B.mdx to import A.mdx while `user`'s reference to the moved " + + "node needs A.mdx to import B.mdx (SPEC 6.5, 2.1)", + }, + { + argv: ["move", "specs/A.mdx#mv", "specs/A.mdx#keep.mv"], + expected: { + finding: "refused-cycle", + locatedAt: { file: V4_A, window: V4_KEEP_WINDOW }, + }, + reason: + "dependency cycle — the moved node depends on `keep` and would " + + "become its child, a dependency on its own ancestor (SPEC 6.5, 5.3)", + }, + { + argv: ["move", "specs/A.mdx", "specs/B.mdx"], + expected: { finding: "refused-destination-exists", path: V4_B }, + reason: "file form whose destination file already exists (SPEC 6.5)", + }, + { + argv: ["move", "specs/A.mdx", V4_SYM_DEST], + expected: { finding: "refused-destination-exists", path: V4_SYM_DEST }, + reason: + "file form whose destination path is occupied by a symbolic link — " + + "whatever kind of filesystem object occupies it, a symbolic link " + + "included (SPEC 6.5)", + }, + { + argv: ["move", "specs/A.mdx", V4_GONE_DEST], + expected: { finding: "refused-destination-exists", path: V4_GONE_DEST }, + reason: + "file form whose destination path is occupied by a broken symbolic " + + "link, target absent — a product probing existence through " + + "link-following stat sees the path absent and proceeds (SPEC 6.5)", + }, + { + argv: ["move", "specs/A.mdx#x", `${V4_DIR_TARGET}#tdir`], + expected: { finding: "refused-destination-exists", path: V4_DIR_TARGET }, + reason: + "section form whose target path is occupied by a directory — not a " + + "discovered spec source: neither an insertion target nor an absent " + + "path to create (SPEC 6.5)", + }, + { + argv: ["move", "specs/A.mdx#x", `${V4_LINK_TARGET}#tlink`], + expected: { finding: "refused-destination-exists", path: V4_LINK_TARGET }, + reason: + "section form whose target path is occupied by a symbolic link " + + "resolving to a discovered spec source — discovery never yields a " + + "symlink (SPEC 6.5, 7): a product resolving the target path through " + + "the filesystem finds a spec source there and inserts through the " + + "link into B.mdx", + }, + { + argv: ["move", "specs/A.mdx#x", `${V4_OCC}#tocc`], + expected: [ + { finding: "refused-destination-exists", path: V4_OCC }, + { finding: "refused-invalid-destination", path: V4_OCC }, + ], + reason: + "section form whose target path is occupied by an existing `.mdx` " + + "file outside every configured spec group — present, right " + + "extension, still no discovered spec source — refusing under both " + + "applicable reasons, one finding per reason (SPEC 6.5, 14)", + }, + { + argv: ["move", "specs/A.mdx#keep", "specs/B.mdx#then"], + expected: { + finding: "refused-invalid-id", + identity: { file: V4_B, id: "then" }, + }, + reason: + "section form whose <new-id> is invalid per 1.4 — the forbidden " + + "name `then` (the mirrored new-ID-is-valid check, SPEC 6.5)", + }, + { + argv: ["move", "specs/A.mdx#keep", "specs/B.mdx#ha lf"], + expected: { + finding: "refused-invalid-id", + identity: { file: V4_B, id: "ha lf" }, + }, + reason: + "section form whose <new-id> is invalid per 1.4 — a " + + "whitespace-bearing segment (SPEC 6.5)", + }, + { + argv: ["move", "specs/A.mdx#keep", "specs/B.mdx#"], + expected: { + finding: "refused-invalid-id", + identity: { file: V4_B, id: "" }, + }, + reason: + "section form whose <new-id> is empty — the destination operand " + + "`specs/B.mdx#` holds one `#`, a well-formed 12.0 split whose id " + + "part has zero segments, refused as an invalid intrinsic ID (one or " + + "more segments, SPEC 14) — never the exit-2 malformed-value " + + "treatment a product gets by generalizing 11.3's `--to` spelling " + + "rule to move operands (SPEC 6.5, 12.0)", + }, + { + argv: ["move", "specs/A.mdx#x", "specs/B.mdx#y"], + expected: { + finding: "refused-id-collision", + locatedAt: { file: V4_B, window: V4_Y_WINDOW }, + }, + reason: + "the ordinary cross-file collision — <new-id> `y` collides with the " + + "section `y` already present in the distinct target file (SPEC 6.5)", + }, + { + argv: ["move", "specs/A.mdx#keep", "specs/B.mdx#nope.k"], + expected: { + finding: "refused-missing-target-parent", + identity: { file: V4_B, id: "nope" }, + }, + reason: + "section form whose target parent (`nope`, the <new-id> minus its " + + "final segment) is missing from the target file (SPEC 6.5)", + }, + { + argv: ["move", "specs/A.mdx#x", "specs/A.mdx#x.sub.q"], + expected: { + finding: "refused-missing-target-parent", + identity: { file: V4_A, id: "x.sub" }, + }, + reason: + "section form whose target parent (`x.sub`) lies within the moved " + + "subtree, leaving no insertion point after the removal (SPEC 6.5)", + }, + { + argv: ["move", "specs/A.mdx", "docs/Out.mdx"], + expected: { finding: "refused-invalid-destination", path: "docs/Out.mdx" }, + reason: + "destination path belonging to no configured spec group — a move " + + "never takes a node out of the workspace (SPEC 6.5)", + }, + { + argv: ["move", "specs/A.mdx", "specs/dual/Out.mdx"], + expected: { + finding: "refused-invalid-destination", + path: "specs/dual/Out.mdx", + }, + reason: + "destination path belonging to a code group as well (SPEC 6.5, 14.14)", + }, + { + argv: ["move", "specs/A.mdx", "specs/plain/Out.md"], + expected: { + finding: "refused-invalid-destination", + path: "specs/plain/Out.md", + }, + reason: + "destination path lacking the `.mdx` extension — it matches the " + + "`specs/plain/**` spec glob, isolating 14.19's extension rule " + + "(SPEC 6.5, 7.1, 14.19)", + }, +]; + +/** + * T6.5-4's derived-path arm (its own workspace; the V4_OUTDIR_CONFIG staging + * note), exported for T6.6-3: the otherwise-valid destination's emit + * destination has its directory component occupied by a plain file lying + * under no current source's write path, so the premise `build` passes and + * the refusal is the move's own — refused-invalid-destination concerning the + * destination path, never 14.22. + */ +export const MOVE_DERIVED_PATH_CONFIG = V4_OUTDIR_CONFIG; +export const MOVE_DERIVED_PATH_FILES: Readonly<Record<string, string>> = { + [V4_SOLO]: V4_SOLO_SOURCE, + [V4_MDOUT_OCCUPANT]: V4_MDOUT_OCCUPANT_CONTENT, +}; +export const MOVE_DERIVED_PATH_CASE: MoveRefusalCase = { + argv: ["move", V4_SOLO, "new/b.mdx"], + expected: { finding: "refused-invalid-destination", path: "new/b.mdx" }, + reason: + "derived-path arm — a workspace-relative directory component of a " + + "derived path the destination would generate, the emit destination " + + "mdout/new/b.md under markdown.outDir, is occupied by a plain file: " + + "refused refused-invalid-destination concerning the destination path, " + + "never 14.22 — a product vetting only the destination path's own " + + "components sees new/ absent and proceeds (SPEC 6.5, 7.3, 13.1, 13.2, 14)", +}; + +/** + * T6.5-4's valid-workspace precondition arm (as T6.4-6), exported for + * T6.6-3: stage MOVE_PRECONDITION_FILES under MOVE_REFUSAL_CONFIG, `build` + * (exit 0), then overwrite MOVE_PRECONDITION_BREAK_FILE with + * MOVE_PRECONDITION_BREAK_SOURCE — the pre-existing validation error + * elsewhere (14.5) — and the otherwise-valid move refuses reporting the + * workspace's numbered findings alone. + */ +export const MOVE_PRECONDITION_FILES: Readonly<Record<string, string>> = { + [V4_A]: V4_A_SOURCE, + [V4_B]: V4_B_SOURCE, + [V4_OTHER]: V4_OTHER_VALID, +}; +export const MOVE_PRECONDITION_BREAK_FILE = V4_OTHER; +export const MOVE_PRECONDITION_BREAK_SOURCE = V4_OTHER_INVALID; +export const MOVE_PRECONDITION_CASE: MoveRefusalCase = { + argv: ["move", "specs/A.mdx#keep", "specs/B.mdx#kp"], + expected: { finding: "14.5", locatedAt: { file: V4_OTHER } }, + reason: + "valid-workspace precondition as T6.4-6 — the workspace fails the " + + "validations of `xspec build` through an unresolved d reference in " + + "specs/Other.mdx (SPEC 14.5), so the move refuses before modifying " + + "anything, reporting the workspace's numbered findings alone " + + "(SPEC 6.5, 6.4, 12.1, 14)", +}; const T6_5_4 = defineProductTest({ id: "T6.5-4", @@ -1731,18 +2003,14 @@ const T6_5_4 = defineProductTest({ "refusals (exit 1, nothing modified): a move creating a spec import cycle or a dependency cycle (refused-cycle, the dependency arm locating the participating `d` spelling); file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link with its target absent, one arm each, the broken-link arm discriminating a product probing existence through link-following stat (refused-destination-exists, concerning that path); section form whose target path is occupied by anything other than a discovered spec source — a directory; a symbolic link resolving to a discovered spec source (discovery never yields a symlink); and an existing `.mdx` file outside every configured spec group, the latter refusing under refused-destination-exists and refused-invalid-destination together, one finding per applicable reason; section form with a 1.4-invalid `<new-id>` (forbidden name `then`; whitespace-bearing segment; the empty `<new-id>` of destination operand `specs/B.mdx#`, a well-formed 12.0 split with zero id segments, never the exit-2 generalization of 11.3's `--to` spelling rule — refused-invalid-id, concerning that identity); the ordinary cross-file `<new-id>` collision (refused-id-collision, locating the remaining bearer); a missing target parent and a target parent within the moved subtree (refused-missing-target-parent, concerning the target-parent identity); destination paths in no configured spec group, in a code group as well, or lacking `.mdx`, and the derived-path arm — emission enabled under `markdown.outDir`, the otherwise-valid destination's emit-destination directory component `mdout/new` occupied by a plain file lying under no current source's write path, refused never 14.22 (refused-invalid-destination, concerning the destination path) — each refusal the form-exact 12.7 findings-only report holding exactly one finding per applicable reason with its exact stable code; the `#`-containing and non-UTF-8 destination clauses admit no refusal staging (the dead-letter note): every such operand spelling is an exit-2 usage error first, staged in T6.5-5; plus the valid-workspace precondition as T6.4-6, reporting the workspace's numbered findings alone (SPEC 6.5, 7, 7.3, 5.3, 2.1, 1.4, 1.3, 13.1, 13.2, 13.4, 14.14, 14.19, 14.22, 12.0, 12.7, 14)", run: async (product) => { await withWorkspace( - REFUSAL_CONFIG, - { [V4_A]: V4_A_SOURCE, [V4_B]: V4_B_SOURCE, [V4_OCC]: V4_OCC_SOURCE }, + MOVE_REFUSAL_CONFIG, + MOVE_REFUSAL_FILES, async (workspace) => { // Destination occupants (the staging note above): staged before the - // pre-refusal `build`, which must still pass — a directory is no - // source file and discovery never yields a symbolic link (SPEC 7), - // so each occupant arm refuses on exactly its staged ground, not - // the invalid-workspace precondition. - await workspace.dir(V4_DIR_TARGET); - await workspace.symlink(V4_SYM_DEST, "B.mdx"); - await workspace.symlink(V4_LINK_TARGET, "B.mdx"); - await workspace.symlink(V4_GONE_DEST, "missing-target.mdx"); + // pre-refusal `build`, which must still pass, so each occupant arm + // refuses on exactly its staged ground, not the invalid-workspace + // precondition. + await stageMoveRefusalOccupants(workspace); // Build first, so the modifies-nothing compares include intact // derived files (the T6.4-3 protocol). await buildOk( @@ -1751,177 +2019,11 @@ const T6_5_4 = defineProductTest({ "T6.5-4 `build` over the staged workspace", ); - // Each arm's expected refusal finding (SPEC 14): the exact stable - // code with the concern §14 assigns the reason — identity, path, or - // located participant (the module header's T6.5-4 note walks the - // per-reason choices). - const cases: readonly (readonly [ - readonly string[], - RefusalExpectation | readonly RefusalExpectation[], - string, - ])[] = [ - [ - ["move", "specs/A.mdx#mv", "specs/B.mdx#bmv"], - // The would-be spec import cycle's participating import - // declarations exist in no pre-operation source (the move would - // add both), so no concern window is assertable: the arm pins - // the exact code and the 12.7 form alone. - { finding: "refused-cycle" }, - "spec import cycle — the moved node's local `d` on `keep` needs " + - "B.mdx to import A.mdx while `user`'s reference to the moved " + - "node needs A.mdx to import B.mdx (SPEC 6.5, 2.1)", - ], - [ - ["move", "specs/A.mdx#mv", "specs/A.mdx#keep.mv"], - { - finding: "refused-cycle", - locatedAt: { file: V4_A, window: V4_KEEP_WINDOW }, - }, - "dependency cycle — the moved node depends on `keep` and would " + - "become its child, a dependency on its own ancestor (SPEC 6.5, " + - "5.3)", - ], - [ - ["move", "specs/A.mdx", "specs/B.mdx"], - { finding: "refused-destination-exists", path: V4_B }, - "file form whose destination file already exists (SPEC 6.5)", - ], - [ - ["move", "specs/A.mdx", V4_SYM_DEST], - { finding: "refused-destination-exists", path: V4_SYM_DEST }, - "file form whose destination path is occupied by a symbolic " + - "link — whatever kind of filesystem object occupies it, a " + - "symbolic link included (SPEC 6.5)", - ], - [ - ["move", "specs/A.mdx", V4_GONE_DEST], - { finding: "refused-destination-exists", path: V4_GONE_DEST }, - "file form whose destination path is occupied by a broken " + - "symbolic link, target absent — a product probing existence " + - "through link-following stat sees the path absent and " + - "proceeds (SPEC 6.5)", - ], - [ - ["move", "specs/A.mdx#x", `${V4_DIR_TARGET}#tdir`], - { finding: "refused-destination-exists", path: V4_DIR_TARGET }, - "section form whose target path is occupied by a directory — " + - "not a discovered spec source: neither an insertion target " + - "nor an absent path to create (SPEC 6.5)", - ], - [ - ["move", "specs/A.mdx#x", `${V4_LINK_TARGET}#tlink`], - { finding: "refused-destination-exists", path: V4_LINK_TARGET }, - "section form whose target path is occupied by a symbolic link " + - "resolving to a discovered spec source — discovery never " + - "yields a symlink (SPEC 6.5, 7): a product resolving the " + - "target path through the filesystem finds a spec source " + - "there and inserts through the link into B.mdx", - ], - [ - ["move", "specs/A.mdx#x", `${V4_OCC}#tocc`], - [ - { finding: "refused-destination-exists", path: V4_OCC }, - { finding: "refused-invalid-destination", path: V4_OCC }, - ], - "section form whose target path is occupied by an existing " + - "`.mdx` file outside every configured spec group — present, " + - "right extension, still no discovered spec source — refusing " + - "under both applicable reasons, one finding per reason " + - "(SPEC 6.5, 14)", - ], - [ - ["move", "specs/A.mdx#keep", "specs/B.mdx#then"], - { - finding: "refused-invalid-id", - identity: { file: V4_B, id: "then" }, - }, - "section form whose <new-id> is invalid per 1.4 — the forbidden " + - "name `then` (the mirrored new-ID-is-valid check, SPEC 6.5)", - ], - [ - ["move", "specs/A.mdx#keep", "specs/B.mdx#ha lf"], - { - finding: "refused-invalid-id", - identity: { file: V4_B, id: "ha lf" }, - }, - "section form whose <new-id> is invalid per 1.4 — a " + - "whitespace-bearing segment (SPEC 6.5)", - ], - [ - ["move", "specs/A.mdx#keep", "specs/B.mdx#"], - { - finding: "refused-invalid-id", - identity: { file: V4_B, id: "" }, - }, - "section form whose <new-id> is empty — the destination " + - "operand `specs/B.mdx#` holds one `#`, a well-formed 12.0 " + - "split whose id part has zero segments, refused as an " + - "invalid intrinsic ID (one or more segments, SPEC 14) — " + - "never the exit-2 malformed-value treatment a product gets " + - "by generalizing 11.3's `--to` spelling rule to move " + - "operands (SPEC 6.5, 12.0)", - ], - [ - ["move", "specs/A.mdx#x", "specs/B.mdx#y"], - { - finding: "refused-id-collision", - locatedAt: { file: V4_B, window: V4_Y_WINDOW }, - }, - "the ordinary cross-file collision — <new-id> `y` collides with " + - "the section `y` already present in the distinct target file " + - "(SPEC 6.5)", - ], - [ - ["move", "specs/A.mdx#keep", "specs/B.mdx#nope.k"], - { - finding: "refused-missing-target-parent", - identity: { file: V4_B, id: "nope" }, - }, - "section form whose target parent (`nope`, the <new-id> minus " + - "its final segment) is missing from the target file (SPEC 6.5)", - ], - [ - ["move", "specs/A.mdx#x", "specs/A.mdx#x.sub.q"], - { - finding: "refused-missing-target-parent", - identity: { file: V4_A, id: "x.sub" }, - }, - "section form whose target parent (`x.sub`) lies within the " + - "moved subtree, leaving no insertion point after the removal " + - "(SPEC 6.5)", - ], - [ - ["move", "specs/A.mdx", "docs/Out.mdx"], - { finding: "refused-invalid-destination", path: "docs/Out.mdx" }, - "destination path belonging to no configured spec group — a " + - "move never takes a node out of the workspace (SPEC 6.5)", - ], - [ - ["move", "specs/A.mdx", "specs/dual/Out.mdx"], - { - finding: "refused-invalid-destination", - path: "specs/dual/Out.mdx", - }, - "destination path belonging to a code group as well (SPEC 6.5, " + - "14.14)", - ], - [ - ["move", "specs/A.mdx", "specs/plain/Out.md"], - { - finding: "refused-invalid-destination", - path: "specs/plain/Out.md", - }, - "destination path lacking the `.mdx` extension — it matches the " + - "`specs/plain/**` spec glob, isolating 14.19's extension rule " + - "(SPEC 6.5, 7.1, 14.19)", - ], - ]; - // No `#`-containing and no non-UTF-8 destination arm here: those 6.5 - // destination clauses are dead letters as refusals (T6.5-4's note) — - // a destination path exists only as an operand spelling, and every - // spelling that would present either is an exit-2 usage error before - // any refusal is evaluated. T6.5-5 stages both. - for (const [argv, expected, reason] of cases) { + // The complete case table (module scope, shared with T6.6-3's + // preview-refusal equivalence — TEST-SPEC §6.6 "staged identically"; + // the dead-letter destination spellings stay in T6.5-5, the module + // header's note). + for (const { argv, expected, reason } of MOVE_REFUSAL_CASES) { await expectRefusalModifiesNothing( product, workspace, @@ -1941,11 +2043,8 @@ const T6_5_4 = defineProductTest({ // (SPEC 13.2, 7.3) has its directory component `mdout/new` occupied by // a plain file. await withWorkspace( - V4_OUTDIR_CONFIG, - { - [V4_SOLO]: V4_SOLO_SOURCE, - [V4_MDOUT_OCCUPANT]: "not a directory\n", - }, + MOVE_DERIVED_PATH_CONFIG, + MOVE_DERIVED_PATH_FILES, async (workspace) => { await buildOk( product, @@ -1958,50 +2057,38 @@ const T6_5_4 = defineProductTest({ await expectRefusalModifiesNothing( product, workspace, - ["move", V4_SOLO, "new/b.mdx"], - { finding: "refused-invalid-destination", path: "new/b.mdx" }, - "T6.5-4 (derived-path arm — a workspace-relative directory " + - "component of a derived path the destination would generate, " + - "the emit destination mdout/new/b.md under markdown.outDir, is " + - "occupied by a plain file: refused refused-invalid-destination " + - "concerning the destination path, never 14.22 — a product " + - "vetting only the destination path's own components sees new/ " + - "absent and proceeds; SPEC 6.5, 7.3, 13.1, 13.2, 14)", + MOVE_DERIVED_PATH_CASE.argv, + MOVE_DERIVED_PATH_CASE.expected, + `T6.5-4 (${MOVE_DERIVED_PATH_CASE.reason})`, ); }, ); // Valid-workspace precondition, as T6.4-6: with a pre-existing // validation error elsewhere, the move's own arguments being valid, the - // move refuses (exit 1) before modifying anything. + // move refuses (exit 1) before modifying anything. The invalid-workspace + // refusal reports the workspace's findings themselves — exactly the one + // 14.5 finding located in the offending file, no refusal reason + // evaluated or reported beside it (SPEC 6.5, 6.4, 14). await withWorkspace( - REFUSAL_CONFIG, - { - [V4_A]: V4_A_SOURCE, - [V4_B]: V4_B_SOURCE, - [V4_OTHER]: V4_OTHER_VALID, - }, + MOVE_REFUSAL_CONFIG, + MOVE_PRECONDITION_FILES, async (workspace) => { await buildOk( product, workspace, "T6.5-4 precondition arm `build` over the staged workspace", ); - await workspace.file(V4_OTHER, V4_OTHER_INVALID); - // The invalid-workspace refusal reports the workspace's findings - // themselves — exactly the one 14.5 finding located in the offending - // file, no refusal reason evaluated or reported beside it (SPEC 6.5, - // 6.4, 14). + await workspace.file( + MOVE_PRECONDITION_BREAK_FILE, + MOVE_PRECONDITION_BREAK_SOURCE, + ); await expectRefusalModifiesNothing( product, workspace, - ["move", "specs/A.mdx#keep", "specs/B.mdx#kp"], - { finding: "14.5", locatedAt: { file: V4_OTHER } }, - "T6.5-4 (valid-workspace precondition as T6.4-6 — the workspace " + - "fails the validations of `xspec build` through an unresolved d " + - "reference in specs/Other.mdx, SPEC 14.5, so the move refuses " + - "before modifying anything: no source rewrite, no journal " + - "entry, no derived-file change; SPEC 6.5, 6.4, 12.1)", + MOVE_PRECONDITION_CASE.argv, + MOVE_PRECONDITION_CASE.expected, + `T6.5-4 (${MOVE_PRECONDITION_CASE.reason})`, ); }, ); @@ -2112,7 +2199,15 @@ const U5_NON_UTF8_DESTINATION: Uint8Array = Buffer.concat([ Buffer.from(".mdx", "utf8"), ]); -const U5_USAGE_CASES: readonly (readonly [readonly string[], string])[] = [ +// The T6.5-5 usage tables below are exported so T6.6-3 can assert each +// `--preview` variant exits 2 identically (TEST-SPEC §6.6: "for the usage +// errors of T6.4-4/T6.5-5 the preview exits 2 identically — argument checks +// precede either way"). + +export const MOVE_USAGE_CASES: readonly (readonly [ + readonly string[], + string, +])[] = [ [ ["move", "specs/Missing.mdx", "specs/New.mdx"], "file form, nonexistent origin file", @@ -2135,7 +2230,10 @@ const U5_USAGE_CASES: readonly (readonly [readonly string[], string])[] = [ // in move origins is discriminated. These cases ride the base arm (inside // modifies-nothing compares) and the ordering arm (the wrong-kind check // precedes source validation, as T6.4-4). -const U5_WRONG_KIND_CASES: readonly (readonly [readonly string[], string])[] = [ +export const MOVE_WRONG_KIND_CASES: readonly (readonly [ + readonly string[], + string, +])[] = [ [ ["move", U5_CODE, "specs/New.mdx"], "file form, discovered code source as origin", @@ -2146,6 +2244,80 @@ const U5_WRONG_KIND_CASES: readonly (readonly [readonly string[], string])[] = [ ], ]; +// The three mixed-synopsis invocations (SPEC 6.5: a move operand is +// classified by spelling alone — an operand containing `#` is a +// `<file>#<id>` pair under the 12.0 split, one without is a file — so an +// invocation mixing the two synopses' forms matches neither). Every operand +// names staged content (`specs/A.mdx`, its section `a`, `specs/B.mdx`), so a +// product accepting a mixed form would perform a move — each case runs +// inside a whole-root modifies-nothing compare. The third, the +// `#`-containing file-form destination, is also the staging T6.5-4's +// dead-letter note sets aside: exit 2, never the 6.5 destination refusal +// (exit 1) it would be were the operand a path. +export const MOVE_MIXED_SYNOPSIS_CASES: readonly (readonly [ + readonly string[], + string, +])[] = [ + [ + ["move", U5_A, "specs/B.mdx#y"], + "mixed synopsis `a.mdx b.mdx#y` — a bare-file origin with a pair " + + "destination matches neither form (SPEC 6.5, 12.0)", + ], + [ + ["move", `${U5_A}#a`, U5_B], + "mixed synopsis `a.mdx#x b.mdx` — a pair origin with a bare-file " + + "destination matches neither form (SPEC 6.5, 12.0)", + ], + [ + ["move", "specs/A.mdx", "specs/Ha#sh.mdx"], + "mixed synopsis `a.mdx b#c.mdx` — the `#`-containing file-form " + + "destination classifies as a `<file>#<id>` pair by spelling alone, " + + "so the invocation mixes the two synopses' forms and matches " + + "neither (SPEC 6.5, 12.0; the staging T6.5-4's dead-letter note " + + "sets aside)", + ], +]; + +/** + * The non-UTF-8 destination operand invocation (raw argv bytes; the other + * dead-letter staging) — Linux leg only: Linux argv is a byte channel, so + * the destination is passed as raw bytes via the subprocess driver's + * raw-argv support; other platforms cannot carry the argument at all + * (T1.5-2's platform note). Callers gate on `process.platform === "linux"`. + */ +export const MOVE_NON_UTF8_ARGV: readonly ArgvValue[] = [ + "move", + U5_A, + U5_NON_UTF8_DESTINATION, +]; + +/** The base/ordering staging shared by the usage cases (T6.4-4's mirror): + * valid sources, the discovered code source, and — in the ordering variant — + * the failing Bad.mdx, exported for T6.6-3's preview sweep. */ +export const MOVE_USAGE_CONFIG = SPEC_AND_CODE_CONFIG; +export const MOVE_USAGE_ORDERING_FILES: Readonly<Record<string, string>> = { + [U5_A]: U5_A_SOURCE, + [U5_B]: U5_B_SOURCE, + [U5_BAD]: U5_BAD_SOURCE, + [U5_CODE]: U5_CODE_SOURCE, +}; + +/** + * T6.5-5's parse-local nonexistence staging (the sole would-be bearer + * spells no identity — its `id` attribute repeated): the move is exit 2 even + * beside that file's findings. Exported for T6.6-3's preview variant; stage + * under MOVE_SOLO_CONFIG and pin the one-14.17 premise before invoking. + */ +export const MOVE_SOLO_CONFIG = SPECS_ONLY_CONFIG; +export const MOVE_SOLO_FILES: Readonly<Record<string, string>> = { + [U5_SOLO]: U5_SOLO_SOURCE, +}; +export const MOVE_SOLO_ARGV: readonly string[] = [ + "move", + `${U5_SOLO}#solo`, + "specs/New.mdx#solo2", +]; + const T6_5_5 = defineProductTest({ id: "T6.5-5", title: @@ -2162,7 +2334,7 @@ const T6_5_5 = defineProductTest({ async (workspace) => { const context = "T6.5-5 valid-workspace arm"; await buildOk(product, workspace, `${context}: \`build\``); - for (const [argv, label] of U5_USAGE_CASES) { + for (const [argv, label] of MOVE_USAGE_CASES) { await expectMoveUsageError( product, workspace, @@ -2177,7 +2349,7 @@ const T6_5_5 = defineProductTest({ // discovered code file and its real exported unit, so a product // accepting a code-source origin would relocate the file (file // form) or act on the named unit (section form). - for (const [argv, label] of U5_WRONG_KIND_CASES) { + for (const [argv, label] of MOVE_WRONG_KIND_CASES) { await assertLeavesUnchanged( workspace.root, async () => { @@ -2196,64 +2368,24 @@ const T6_5_5 = defineProductTest({ ); } - // The three mixed-synopsis invocations (SPEC 6.5: a move operand is - // classified by spelling alone — an operand containing `#` is a - // `<file>#<id>` pair under the 12.0 split, one without is a file — - // so an invocation mixing the two synopses' forms matches neither): - // each asserted with a whole-root modifies-nothing snapshot compare - // around the command, because every operand names staged content - // (`specs/A.mdx`, its section `a`, `specs/B.mdx`) — a product - // accepting a mixed form would perform a move. The third, the - // `#`-containing file-form destination, is also the staging - // T6.5-4's dead-letter note sets aside: exit 2, never the 6.5 - // destination refusal (exit 1) it would be were the operand a path. - await assertLeavesUnchanged( - workspace.root, - async () => { - await expectMoveUsageError( - product, - workspace, - ["move", U5_A, "specs/B.mdx#y"], - `${context}, mixed synopsis \`a.mdx b.mdx#y\` — a bare-file ` + - `origin with a pair destination matches neither form ` + - `(SPEC 6.5, 12.0)`, - ); - }, - `${context}: \`move ${U5_A} specs/B.mdx#y\` — the usage error ` + - `modifies nothing (SPEC 6.5, 12.0)`, - ); - await assertLeavesUnchanged( - workspace.root, - async () => { - await expectMoveUsageError( - product, - workspace, - ["move", `${U5_A}#a`, U5_B], - `${context}, mixed synopsis \`a.mdx#x b.mdx\` — a pair ` + - `origin with a bare-file destination matches neither form ` + - `(SPEC 6.5, 12.0)`, - ); - }, - `${context}: \`move ${U5_A}#a ${U5_B}\` — the usage error ` + - `modifies nothing (SPEC 6.5, 12.0)`, - ); - await assertLeavesUnchanged( - workspace.root, - async () => { - await expectMoveUsageError( - product, - workspace, - ["move", "specs/A.mdx", "specs/Ha#sh.mdx"], - `${context}, mixed synopsis \`a.mdx b#c.mdx\` — the ` + - `\`#\`-containing file-form destination classifies as a ` + - `\`<file>#<id>\` pair by spelling alone, so the invocation ` + - `mixes the two synopses' forms and matches neither ` + - `(SPEC 6.5, 12.0)`, - ); - }, - `${context}: \`move specs/A.mdx specs/Ha#sh.mdx\` — the usage ` + - `error modifies nothing (SPEC 6.5, 12.0)`, - ); + // The three mixed-synopsis invocations (the module-scope table's + // note): each asserted with a whole-root modifies-nothing snapshot + // compare around the command. + for (const [argv, label] of MOVE_MIXED_SYNOPSIS_CASES) { + await assertLeavesUnchanged( + workspace.root, + async () => { + await expectMoveUsageError( + product, + workspace, + argv, + `${context}, ${label}`, + ); + }, + `${context}, ${label}: the usage error modifies nothing ` + + `(SPEC 6.5, 12.0)`, + ); + } // Non-UTF-8 destination operand — the other staging T6.5-4's // dead-letter note sets aside (SPEC 6.5's non-UTF-8 destination @@ -2269,7 +2401,7 @@ const T6_5_5 = defineProductTest({ await expectMoveUsageError( product, workspace, - ["move", U5_A, U5_NON_UTF8_DESTINATION], + MOVE_NON_UTF8_ARGV, `${context}, non-UTF-8 destination operand (raw argv ` + `bytes, Linux leg) — a non-UTF-8 argument value is a ` + `usage error (SPEC 12.0)`, @@ -2284,13 +2416,8 @@ const T6_5_5 = defineProductTest({ // --- Ordering arm: the workspace also fails build validation --- await withWorkspace( - SPEC_AND_CODE_CONFIG, - { - [U5_A]: U5_A_SOURCE, - [U5_B]: U5_B_SOURCE, - [U5_BAD]: U5_BAD_SOURCE, - [U5_CODE]: U5_CODE_SOURCE, - }, + MOVE_USAGE_CONFIG, + MOVE_USAGE_ORDERING_FILES, async (workspace) => { const context = "T6.5-5 ordering arm"; // Staging premise: the workspace really fails build validation, so @@ -2309,7 +2436,7 @@ const T6_5_5 = defineProductTest({ `at least one validation finding (SPEC 14)`, ); } - for (const [argv, label] of U5_USAGE_CASES) { + for (const [argv, label] of MOVE_USAGE_CASES) { await expectMoveUsageError( product, workspace, @@ -2318,7 +2445,7 @@ const T6_5_5 = defineProductTest({ `— the existence checks precede source validation (SPEC 12.0)`, ); } - for (const [argv, label] of U5_WRONG_KIND_CASES) { + for (const [argv, label] of MOVE_WRONG_KIND_CASES) { await expectMoveUsageError( product, workspace, @@ -2427,8 +2554,8 @@ const T6_5_5 = defineProductTest({ // --- Parse-local nonexistence: a would-be bearer spelling no // identity --- await withWorkspace( - SPECS_ONLY_CONFIG, - { [U5_SOLO]: U5_SOLO_SOURCE }, + MOVE_SOLO_CONFIG, + MOVE_SOLO_FILES, async (workspace) => { const context = "T6.5-5 spells-no-identity arm"; // Staging premise: the repeated-`id` bearer leaves the file with @@ -2455,7 +2582,7 @@ const T6_5_5 = defineProductTest({ await expectMoveUsageError( product, workspace, - ["move", `${U5_SOLO}#solo`, "specs/New.mdx#solo2"], + MOVE_SOLO_ARGV, `${context}: an origin ID whose only would-be bearer spells no ` + `identity (its \`id\` attribute repeated on the tag) is ` + `nonexistent — exit 2 even beside that file's findings ` + diff --git a/test/suite/registry/section-6.6.ts b/test/suite/registry/section-6.6.ts index 6bf17af..50454b9 100644 --- a/test/suite/registry/section-6.6.ts +++ b/test/suite/registry/section-6.6.ts @@ -1,5 +1,5 @@ -// TEST-SPEC §6.6 (previews) — SUITE-24: T6.6-2. (T6.6-1 is retired; -// T6.6-3…T6.6-6 are staged by later plan tasks into this module.) +// TEST-SPEC §6.6 (previews) — SUITE-24: T6.6-2, T6.6-3. (T6.6-1 is retired; +// T6.6-4…T6.6-6 are staged by later plan tasks into this module.) // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -45,12 +45,51 @@ // (`null` is the refusal encoding, SPEC 6.6/12.7, and T6.6-2 stages // workspaces where the real operation would proceed); `files` and `delta` // content is T6.6-4's and T6.6-5's business. +// - T6.6-3 "the same findings (same stable codes, locations, identities; +// 14)": the real refused invocation runs first on the identical staging — +// the refusal-case stagings and expectation tables are imported from +// section-6.4.ts/section-6.5.ts (TEST-SPEC §6.6 "staged identically"), its +// per-arm code counts re-pinned (the arm still isolates its staged +// cause(s); the concerned-data assertions stay T6.4-3's/T6.5-4's) — and +// the `--preview` invocation's findings are compared to it element-wise +// over every finding member except `message`: code, locations, concerned +// path, identities — the members SPEC 14/12.7 make contractual. Message +// composition is deterministic but unpinned (12.0/12.7), and the preview +// and the real run are distinct invocations, so equal wording is not +// contract (H-4). Both arrays come out of the form-exact decode in 12.7's +// total findings order, whose keys precede the message tie-break exactly +// on the compared members, so element-wise comparison is exact. +// - T6.6-3 usage errors "exit 2 identically (argument checks precede either +// way)": each T6.4-4/T6.5-5 usage-error invocation runs once — the real +// invocation, then the `--preview` one — on the ordering-shaped staging +// (unrelated validation errors present) where its source test stages one, +// so exit 2 across the pair realizes the precedence clause; the +// parse-local spells-no-identity arms re-pin their one-14.17 premise +// first (T6.4-4's protocol), and every sweep sits inside a whole-root +// modifies-nothing compare (SPEC 12.0). +// - T6.6-3 scheduling: the runs-while-held arm shares T13.5-2's staging and +// the 13.5 suite's drive-during-hold choreography (section-13.5.ts +// exports; CERTIFICATIONS.md's Exclusions note binds exactly this +// sharing) — the same second command T13.5-2 asserts is refused exit 2 +// without `--preview` here runs to completion exit 0 with it while +// command 1 is held. "Takes no exclusivity" is operationalized as that +// observable (SPEC 6.6: completes while another mutating command holds +// exclusivity — never the mutual-exclusion refusal, never blocked; a +// blocking product is killed at the hang bound and fails diagnosed, +// H-8/H-10), plus the held-baseline snapshot equality (the preview writes +// nothing while held). `--test-hold` + `--preview` is asserted for both +// operations and both flag orders: exit 2, the 12.7 error document under +// --json, no hold file created, nothing modified. import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; -import type { AppliedMappingPair } from "../../helpers/adapters/index.js"; +import type { + AppliedMappingPair, + Finding, +} from "../../helpers/adapters/index.js"; import { decodeAppliedMappingReport, + decodeFindingsReport, decodePreviewReport, } from "../../helpers/adapters/index.js"; import { @@ -59,13 +98,71 @@ import { parseJsonStdout, } from "../../helpers/assertions.js"; import { assertRunTwiceDeterministic } from "../../helpers/determinism.js"; -import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; -import type { ProductBinding } from "../../helpers/subprocess.js"; +import { + assertLeavesUnchanged, + assertSnapshotsEqual, + snapshotDirectory, +} from "../../helpers/snapshot.js"; +import type { + ArgvValue, + ProductBinding, + RunResult, +} from "../../helpers/subprocess.js"; +import { + pathExists, + releaseHoldFile, + runProduct, + startProduct, +} from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import { + RENAME_REFUSAL_CASES, + RENAME_REFUSAL_CONFIG, + RENAME_REFUSAL_FILES, + RENAME_SOLO_ARGV, + RENAME_SOLO_FILES, + RENAME_USAGE_CASES, + RENAME_USAGE_CONFIG, + RENAME_USAGE_ORDERING_FILES, +} from "./section-6.4.js"; +import type { RefusalExpectation } from "./section-6.5.js"; +import { + MOVE_DERIVED_PATH_CASE, + MOVE_DERIVED_PATH_CONFIG, + MOVE_DERIVED_PATH_FILES, + MOVE_MIXED_SYNOPSIS_CASES, + MOVE_NON_UTF8_ARGV, + MOVE_PRECONDITION_BREAK_FILE, + MOVE_PRECONDITION_BREAK_SOURCE, + MOVE_PRECONDITION_CASE, + MOVE_PRECONDITION_FILES, + MOVE_REFUSAL_CASES, + MOVE_REFUSAL_CONFIG, + MOVE_REFUSAL_FILES, + MOVE_SOLO_ARGV, + MOVE_SOLO_CONFIG, + MOVE_SOLO_FILES, + MOVE_USAGE_CASES, + MOVE_USAGE_CONFIG, + MOVE_USAGE_ORDERING_FILES, + MOVE_WRONG_KIND_CASES, + stageMoveRefusalOccupants, +} from "./section-6.5.js"; +import { + CORE_DECL, + awaitHoldFile, + describeExit, + holdPathFor, + runBounded, +} from "./section-13.5.js"; import { assertAppliedMapping, + assertConditionCounts, assertSameJson, + buildFindings, buildOk, + expectErrorDocument, + expectExit, runJson, } from "./support.js"; @@ -366,4 +463,624 @@ const T6_6_2 = defineProductTest({ }, }); -export const section66Tests: readonly ProductTestEntry[] = [T6_6_2]; +// --------------------------------------------------------------------------- +// T6.6-3 — refusal and scheduling equivalence +// --------------------------------------------------------------------------- + +/** Normalize a case's expected refusal finding(s) to a list (SPEC 14: an arm + * staging several applicable reasons expects one finding per reason). */ +function expectationsOf( + expected: RefusalExpectation | readonly RefusalExpectation[], +): readonly RefusalExpectation[] { + const expectations: readonly RefusalExpectation[] = Array.isArray(expected) + ? expected + : [expected]; + return expectations; +} + +/** + * The comparable projection of one 12.7 finding for T6.6-3's same-findings + * assertion: every member except `message` — code, locations, concerned + * path, identities (module header, H-4). + */ +function comparableFinding(finding: Finding): unknown { + return { + code: finding.code, + locations: finding.locations, + path: finding.path, + identities: finding.identities, + }; +} + +/** + * One T6.6-3 refusal-equivalence arm over a staging where the real operation + * is refused (T6.4-3/T6.5-4, staged identically): inside one whole-root + * modifies-nothing compare, run the real invocation with `--json` — exit 1, + * the form-exact 12.7 findings-only report, its per-arm code counts re-pinned + * — then the `--preview --json` invocation: exit 1, the 12.7 preview document + * form kept with `mapping`, `files`, and `delta` null (the refusal encoding), + * and the same findings (module header's projection) as the real refusal + * (SPEC 6.6, 12.7, 14). + */ +async function expectRefusedPreviewEquivalence( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + expected: RefusalExpectation | readonly RefusalExpectation[], + context: string, +): Promise<void> { + const expectations = expectationsOf(expected); + const counts: Record<string, number> = {}; + for (const expectation of expectations) { + counts[expectation.finding] = (counts[expectation.finding] ?? 0) + 1; + } + const command = argv.join(" "); + await assertLeavesUnchanged( + workspace.root, + async () => { + // The real operation on this state — the reference report. + const real = await expectExit( + product, + workspace, + [...argv, "--json"], + 1, + `${context}: \`${command} --json\` — the real operation is refused ` + + `on this staging, exit 1 (SPEC 6.4, 6.5, 12.0; T6.4-3/T6.5-4)`, + ); + const realFindings = decodeFindingsReport( + parseJsonStdout(real, `${context}: \`${command} --json\``), + `${context}: \`${command} --json\` — a refused operation's report ` + + `is the form-exact 12.7 findings-only report (SPEC 12.7, H-3)`, + ).findings; + assertConditionCounts( + realFindings, + counts, + `${context}: staging premise — the arm still isolates exactly its ` + + `staged refusal cause(s), one finding per applicable reason ` + + `(SPEC 14; the concerned-data assertions live in T6.4-3/T6.5-4)`, + ); + + // The `--preview` invocation on the identical state: refused exactly + // when — reporting what, and exiting as — the real operation is + // refused (SPEC 6.6). + const previewArgv = [...argv, "--preview", "--json"]; + const previewCommand = previewArgv.join(" "); + const preview = await expectExit( + product, + workspace, + previewArgv, + 1, + `${context}: \`${previewCommand}\` — a preview is refused exactly ` + + `when, and exits as, the real operation would be refused ` + + `(SPEC 6.6, 12.0)`, + ); + const report = decodePreviewReport( + parseJsonStdout(preview, `${context}: \`${previewCommand}\``), + `${context}: \`${previewCommand}\` — a refused preview keeps the ` + + `12.7 preview document form (SPEC 12.7, H-3)`, + ); + if ( + report.mapping !== null || + report.files !== null || + report.delta !== null + ) { + fail( + `${context}: a refused preview reports the refusal findings ` + + `alone — its \`mapping\`, \`files\`, and \`delta\` are null ` + + `(SPEC 6.6, 12.7); got mapping ` + + `${report.mapping === null ? "null" : "present"}, files ` + + `${report.files === null ? "null" : "present"}, delta ` + + `${report.delta === null ? "null" : "present"}`, + ); + } + assertSameJson( + report.findings.map(comparableFinding), + realFindings.map(comparableFinding), + `${context}: the refused preview reports the same findings as the ` + + `real refusal — same stable codes, locations, concerned paths, ` + + `and identities, element-wise in 12.7's total findings order ` + + `(message composition unpinned, H-4) (SPEC 6.6, 14, 12.7)`, + ); + }, + `${context}: \`${command}\` — neither the refused operation nor its ` + + `refused \`--preview\` modifies anything (SPEC 6.4, 6.5, 6.6)`, + ); +} + +/** + * One T6.6-3 usage-error-equivalence pair (T6.4-4/T6.5-5, staged + * identically): the real invocation and then the `--preview` one, each with + * `--json` — exit 2 exactly, the single 12.7 error document as the entire + * stdout (12.0, H-5), and a usage error message on stderr (presence, not + * wording). Argument checks precede either way (SPEC 6.6, 12.0). Accepts + * raw-byte argv elements for the Linux-leg non-UTF-8 destination case. + */ +async function expectUsageErrorEitherWay( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly ArgvValue[], + context: string, +): Promise<void> { + const invocations: readonly (readonly [readonly ArgvValue[], string])[] = [ + [[...argv, "--json"], "real invocation"], + [[...argv, "--preview", "--json"], "`--preview` invocation"], + ]; + for (const [fullArgv, what] of invocations) { + const label = `${context} (${what})`; + const result = await runProduct(product, { + cwd: workspace.root, + argv: fullArgv, + }); + assertExitCode( + result, + 2, + `${label}: the usage error is exit 2 with \`--preview\` exactly as ` + + `without it — argument checks precede either way (SPEC 6.6, 12.0)`, + ); + expectErrorDocument( + result, + `${label}: under --json, the exit-2 error document is the entire ` + + `stdout — no report, no validation findings (SPEC 12.0, 12.7, H-5)`, + ); + if (result.stderrBytes.length === 0) { + fail( + `${label}: usage error messages are standard-error content ` + + `(SPEC 12.0), but stderr is empty`, + ); + } + } +} + +/** + * The spells-no-identity usage arms (T6.4-4/T6.5-5's parse-local + * nonexistence, staged identically): pin the one-14.17 premise — a repeated + * `id` is condition 17, never 14.1, and spells no identity (SPEC 11.2, 14) + * — then assert the operation and its preview are exit 2 even beside that + * file's findings, modifying nothing. + */ +async function runSoloUsageArm( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + context: string, +): Promise<void> { + const findings = await buildFindings( + product, + workspace, + `${context}: \`build --json\` premise — the staged workspace fails ` + + `build validation (repeated \`id\` attribute, SPEC 14.17)`, + ); + assertConditionCounts( + findings, + { "14.17": 1 }, + `${context}: staging premise — the repeated-\`id\` bearer is the ` + + `file's one finding (SPEC 14: a repeated prop is condition 17, never ` + + `condition 1)`, + ); + await assertLeavesUnchanged( + workspace.root, + async () => { + await expectUsageErrorEitherWay( + product, + workspace, + argv, + `${context} — the origin ID's only would-be bearer spells no ` + + `identity, so the ID is nonexistent: exit 2 even beside that ` + + `file's findings (SPEC 6.4, 6.5, 11.2, 12.0)`, + ); + }, + `${context}: the usage errors modify nothing, previewed or not ` + + `(SPEC 12.0)`, + ); +} + +const T6_6_3 = defineProductTest({ + id: "T6.6-3", + title: + "refusal and scheduling equivalence: each refusal of T6.4-3 and T6.5-4 — the invalid-workspace precondition included — staged identically, the `--preview` invocation exits 1 reporting the same findings (same stable codes, locations, concerned paths, identities) in the form-exact 12.7 preview document with `mapping`, `files`, and `delta` null, modifying nothing; each usage error of T6.4-4/T6.5-5 exits 2 identically under `--preview` (argument checks precede either way — asserted beside unrelated validation errors and beside a spells-no-identity origin's findings, nothing modified); the equivalence is over workspace state, never scheduling: while another mutating command is held (`--test-hold`, T13.5-2's staging), a `--preview` invocation runs to completion with its full successful report — it takes no exclusivity and never meets the mutual-exclusion refusal — and `--test-hold` combined with `--preview` is a usage error, exit 2, creating no hold file (SPEC 6.6, 6.4, 6.5, 13.5, 12.0, 12.7, 14)", + run: async (product) => { + // --- Refusal equivalence: T6.4-3's cases, staged identically --- + await withWorkspace( + RENAME_REFUSAL_CONFIG, + RENAME_REFUSAL_FILES, + async (workspace) => { + await buildOk( + product, + workspace, + "T6.6-3 rename-refusal staging `build` (the T6.4-3 protocol: " + + "derived files sit under the modifies-nothing compares)", + ); + for (const { argv, expected, reason } of RENAME_REFUSAL_CASES) { + await expectRefusedPreviewEquivalence( + product, + workspace, + argv, + expected, + `T6.6-3 rename refusal (${reason})`, + ); + } + }, + ); + + // --- Refusal equivalence: T6.5-4's cases, staged identically --- + await withWorkspace( + MOVE_REFUSAL_CONFIG, + MOVE_REFUSAL_FILES, + async (workspace) => { + // Occupants before the premise `build`, which must still pass + // (T6.5-4's staging note). + await stageMoveRefusalOccupants(workspace); + await buildOk( + product, + workspace, + "T6.6-3 move-refusal staging `build` (occupants staged before it; " + + "T6.5-4's protocol)", + ); + for (const { argv, expected, reason } of MOVE_REFUSAL_CASES) { + await expectRefusedPreviewEquivalence( + product, + workspace, + argv, + expected, + `T6.6-3 move refusal (${reason})`, + ); + } + }, + ); + + // T6.5-4's derived-path arm, staged identically on its own workspace. + await withWorkspace( + MOVE_DERIVED_PATH_CONFIG, + MOVE_DERIVED_PATH_FILES, + async (workspace) => { + await buildOk( + product, + workspace, + "T6.6-3 derived-path staging `build` — the occupant lies under no " + + "current source's write path (T6.5-4's derived-path arm), so " + + "the refusal previewed below is the move's own", + ); + await expectRefusedPreviewEquivalence( + product, + workspace, + MOVE_DERIVED_PATH_CASE.argv, + MOVE_DERIVED_PATH_CASE.expected, + `T6.6-3 move refusal (${MOVE_DERIVED_PATH_CASE.reason})`, + ); + }, + ); + + // T6.5-4's valid-workspace precondition arm, staged identically: the + // invalid-workspace refusal previews as it refuses — the workspace's + // numbered findings alone (SPEC 6.6, 6.4, 6.5, 14). + await withWorkspace( + MOVE_REFUSAL_CONFIG, + MOVE_PRECONDITION_FILES, + async (workspace) => { + await buildOk( + product, + workspace, + "T6.6-3 precondition staging `build` over the staged workspace", + ); + await workspace.file( + MOVE_PRECONDITION_BREAK_FILE, + MOVE_PRECONDITION_BREAK_SOURCE, + ); + await expectRefusedPreviewEquivalence( + product, + workspace, + MOVE_PRECONDITION_CASE.argv, + MOVE_PRECONDITION_CASE.expected, + `T6.6-3 move refusal (${MOVE_PRECONDITION_CASE.reason})`, + ); + }, + ); + + // --- Usage-error equivalence: T6.4-4's usage errors on its + // ordering-shaped staging --- + await withWorkspace( + RENAME_USAGE_CONFIG, + RENAME_USAGE_ORDERING_FILES, + async (workspace) => { + const context = "T6.6-3 rename usage"; + const findings = await buildFindings( + product, + workspace, + `${context}: \`build --json\` premise — the staged workspace ` + + `fails build validation (unresolved d reference, SPEC 14.5), ` + + `so exit 2 across each pair realizes "argument checks precede ` + + `either way" (T6.4-4's ordering arm)`, + ); + if (findings.length === 0) { + fail( + `${context}: staging premise — the failing \`build\` must ` + + `report at least one validation finding (SPEC 14)`, + ); + } + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, label] of RENAME_USAGE_CASES) { + await expectUsageErrorEitherWay( + product, + workspace, + argv, + `${context}, ${label}`, + ); + } + }, + `${context}: the usage errors modify nothing, previewed or not ` + + `(SPEC 12.0)`, + ); + }, + ); + await withWorkspace( + RENAME_REFUSAL_CONFIG, // the same specs-only configuration (T6.4-4) + RENAME_SOLO_FILES, + async (workspace) => { + await runSoloUsageArm( + product, + workspace, + RENAME_SOLO_ARGV, + "T6.6-3 rename usage, spells-no-identity arm", + ); + }, + ); + + // --- Usage-error equivalence: T6.5-5's usage errors on its + // ordering-shaped staging --- + await withWorkspace( + MOVE_USAGE_CONFIG, + MOVE_USAGE_ORDERING_FILES, + async (workspace) => { + const context = "T6.6-3 move usage"; + const findings = await buildFindings( + product, + workspace, + `${context}: \`build --json\` premise — the staged workspace ` + + `fails build validation (unresolved d reference, SPEC 14.5), ` + + `so exit 2 across each pair realizes "argument checks precede ` + + `either way" (T6.5-5's ordering arm)`, + ); + if (findings.length === 0) { + fail( + `${context}: staging premise — the failing \`build\` must ` + + `report at least one validation finding (SPEC 14)`, + ); + } + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, label] of [ + ...MOVE_USAGE_CASES, + ...MOVE_WRONG_KIND_CASES, + ...MOVE_MIXED_SYNOPSIS_CASES, + ]) { + await expectUsageErrorEitherWay( + product, + workspace, + argv, + `${context}, ${label}`, + ); + } + // The non-UTF-8 destination operand (raw argv bytes) — Linux + // leg only, as staged in T6.5-5. + if (process.platform === "linux") { + await expectUsageErrorEitherWay( + product, + workspace, + MOVE_NON_UTF8_ARGV, + `${context}, non-UTF-8 destination operand (raw argv ` + + `bytes, Linux leg — T6.5-5's staging)`, + ); + } + }, + `${context}: the usage errors modify nothing, previewed or not ` + + `(SPEC 12.0)`, + ); + }, + ); + await withWorkspace( + MOVE_SOLO_CONFIG, + MOVE_SOLO_FILES, + async (workspace) => { + await runSoloUsageArm( + product, + workspace, + MOVE_SOLO_ARGV, + "T6.6-3 move usage, spells-no-identity arm", + ); + }, + ); + + // --- Scheduling: the equivalence is over workspace state, never + // scheduling (SPEC 6.6) — T13.5-2's staging and choreography --- + const workspace = await TestWorkspace.create(CORE_DECL); + try { + await buildOk(product, workspace, "T6.6-3 scheduling staging `build`"); + + const hold = holdPathFor(workspace, "hold-t663-primary.tmp"); + const context1 = + "T6.6-3 held command 1 `rename specs/A.mdx a a2 --test-hold <path>` " + + "(T13.5-2's staging)"; + const running = await startProduct(product, { + cwd: workspace.root, + argv: ["rename", "specs/A.mdx", "a", "a2", "--test-hold", hold], + }); + try { + await awaitHoldFile(running, hold, context1); + const heldBaseline = await snapshotDirectory(workspace.root); + + // The same second command T13.5-2 asserts is refused exit 2 without + // `--preview` runs to completion with it (SPEC 6.6, 13.5). + const previewArgv = [ + "rename", + "specs/A.mdx", + "g", + "g2", + "--preview", + "--json", + ]; + const context = `T6.6-3 \`${previewArgv.join(" ")}\` while command 1 is held`; + const result = await runBounded( + product, + workspace.root, + previewArgv, + context, + ); + assertExitCode( + result, + 0, + `${context}: a preview invocation is a non-mutating command under ` + + `13.5 — it takes no exclusivity, so while another mutating ` + + `command is held it runs to completion, never meeting the ` + + `mutual-exclusion refusal (exit 2) T13.5-2 asserts for the same ` + + `second command without --preview, and never blocking ` + + `(SPEC 6.6, 13.5)`, + ); + const report = decodePreviewReport( + parseJsonStdout(result, context), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the preview runs to completion with its full ` + + `successful report — findings [] (SPEC 6.6)`, + ); + if ( + report.mapping === null || + report.files === null || + report.delta === null + ) { + fail( + `${context}: the completed preview reports its plan — ` + + `\`mapping\`, \`files\`, and \`delta\` non-null (SPEC 6.6, ` + + `12.7)`, + ); + } + if (running.hasExited()) { + fail( + `${context}: command 1 must still be held when the preview ` + + `completes — otherwise the completion is not attributable to ` + + `the preview's taking no exclusivity (SPEC 6.6, 13.5) — ` + + `${await describeExit(running)}`, + ); + } + assertSnapshotsEqual( + heldBaseline, + await snapshotDirectory(workspace.root), + `${context}: the preview modifies nothing while another command ` + + `is held (SPEC 6.6)`, + ); + await releaseHoldFile(hold); + let result1: RunResult; + try { + result1 = await running.waitForExit(); + } catch (error) { + return fail( + `${context1}: command 1 must complete normally once the hold ` + + `file is deleted (SPEC 13.5) — ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + assertExitCode( + result1, + 0, + `${context1}: completes normally after release — it really held ` + + `workspace exclusivity throughout the preview's run (SPEC 13.5)`, + ); + } finally { + running.kill(); + await releaseHoldFile(hold); + } + + // `--test-hold` combined with `--preview` is a usage error (SPEC 6.6: + // a preview acquires no exclusivity and does not take the + // acquisition-tied test seam; 12.0): exit 2, the 12.7 error document + // under --json, no hold file created, nothing modified. Both + // operations, both flag orders; the operands stay valid (command 1's + // rename completed above, leaving `a2` and the untouched `g`), so the + // exit 2 is attributable to the flag combination alone. + const combinedArms: readonly { + readonly name: string; + readonly build: (holdPath: string) => readonly string[]; + }[] = [ + { + name: "rename, `--preview --test-hold`", + build: (holdPath) => [ + "rename", + "specs/A.mdx", + "g", + "g2", + "--preview", + "--test-hold", + holdPath, + ], + }, + { + name: "move, `--test-hold … --preview`", + build: (holdPath) => [ + "move", + "specs/A.mdx", + "specs/Moved.mdx", + "--test-hold", + holdPath, + "--preview", + ], + }, + ]; + let combinedIndex = 0; + for (const arm of combinedArms) { + combinedIndex += 1; + const holdPath = holdPathFor( + workspace, + `hold-t663-combined-${String(combinedIndex)}.tmp`, + ); + const argv = arm.build(holdPath); + const context = `T6.6-3 (${arm.name}) \`${argv.join(" ")} --json\``; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await runBounded( + product, + workspace.root, + [...argv, "--json"], + context, + ); + assertExitCode( + result, + 2, + `${context}: supplying --test-hold together with --preview is ` + + `a usage error — a preview acquires no exclusivity and does ` + + `not take the acquisition-tied test seam (SPEC 6.6, 13.5, ` + + `12.0)`, + ); + expectErrorDocument( + result, + `${context}: under --json, the exit-2 error document is the ` + + `entire stdout (SPEC 12.0, 12.7, H-5)`, + ); + if (result.stderrBytes.length === 0) { + fail( + `${context}: usage error messages are standard-error ` + + `content (SPEC 12.0), but stderr is empty`, + ); + } + if (await pathExists(holdPath)) { + fail( + `${context}: no hold file may be created at the path — the ` + + `flag combination is refused, not honored (SPEC 6.6, 13.5)`, + ); + } + }, + `${context}: the usage error modifies nothing (SPEC 12.0)`, + ); + } + } finally { + await workspace.dispose(); + } + }, +}); + +export const section66Tests: readonly ProductTestEntry[] = [T6_6_2, T6_6_3]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index d93a171..8626fdc 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -42,7 +42,7 @@ // here at H-7's passage granularity, the T7-1..T7.5-1 range resolved to // the entries that assert a condition (T7-5 asserts none) and the refusal // reasons' staging record resolved to its implemented tests (T6.4-3, -// T6.5-4, T6.5-6; T6.6-3 joins when implemented). +// T6.5-4, T6.5-6, T6.6-3). // - Alias entries: TEST-SPEC's pointer-only tests are not separately // implemented, so their coverage rides on the implementing tests — // T12.0-10's rename/move and baseline arms ride on T6.4-4/T6.5-5 and @@ -216,6 +216,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.5-6": ["6.5", "14"], "T6.5-7": ["6.5"], "T6.6-2": ["6.6"], + "T6.6-3": ["6.6", "14"], "T6.7-1": ["6.7"], "T7-1": ["7", "14"], "T7-2": ["7", "14"], From 5512c3f91655efb1c4a8bffbd7308e1c93cf5c8c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 21:43:51 +0000 Subject: [PATCH 075/226] =?UTF-8?q?sdg(phase-9):=20implement=20T6.6-4=20?= =?UTF-8?q?=E2=80=94=20preview=20report=20content,=20the=20ten=2012.7=20ed?= =?UTF-8?q?it=20classes=20(FP-030)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five arms in section-6.6.ts, expected mapping and files composed as complete exact lists from the staged fixture bytes: (a) rename id-rewrites plus all four 5.7 occurrence kinds across MDX and TS; (b) existing-target section move — contiguous origin-deletion with nested rewrites, import-removal with its adjunct drop, self-closing target-parent-rewrite with the insertion point at the tag's end, and the import addition's latitude offset pinned by reconstructing the real run's bytes; (c) file move relocation and specifier rewrites; (d) created target's single file-creation edit subsuming a needed import addition; (e) the coincidence-capable tie-break staging. Red-as-diagnosed at the preview flag; conforming-shim probe green end-to-end, five deviations red. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 58 +- test/suite/registry/section-6.6.ts | 1299 ++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 1354 insertions(+), 4 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index f508471..56cf0a0 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1156,10 +1156,66 @@ A "new test T<x>" task always means, in one change: planned mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys → stages E/G), S-5 and certification green.] -- [ ] FP-030 — Implement T6.6-4: preview report content — the ten 12.7 edit +- [x] FP-030 — Implement T6.6-4: preview report content — the ten 12.7 edit classes. [R1 #9; TEST-SPEC §6.6, SPEC 12.7] All ten edit classes with byte-precise pre-operation ranges, class-plus-range only, tie-break comparator. + [Done 2026-08-11: T6.6-4 registered in section-6.6.ts, five arms. Expected + `mapping` and `files` are complete exact lists composed from the staged + fixture bytes by locator helpers (unique fragments, container-scoped for + recurring spellings; multi-byte text before every located construct), the + edit lists in 12.7's pinned order — the order itself enforced by the + already-S-5-guarded decodePreviewReport on every document, including the + full comparator's class-bytes tie-break. (a) rename across MDX and TS: + two id-rewrites (attribute's own characters — the construct-spelling + reading, H-4-noted in the module header) plus all four 5.7 occurrence + kinds as reference-rewrites (d entries, MDX embedding braces-included, + TS call callee-through-paren, TS marker sans terminator), with + unaffected-reference/id/specifier controls; (b) section move into an + existing target: origin-deletion one contiguous range over the indented + construct plus leftover indentation and merged-line terminator, + id-rewrites and the moved text's reference rewrites nested inside it + (compose-time containment self-checks), import-removal spanning the + declaration plus its dropped terminator, target-parent-rewrite spanning + the self-closing tag with target-insertion zero-length at the tag's end + (the one stable pre-operation anchor, H-4), and Third.mdx's + import-addition via a latitude slot (exactly one, zero-length, in-file; + offset captured) pinned by the real run on the preview-pinned state + (assertLeavesUnchanged realizes "on a copy"): the rewritten file must + equal pre-op bytes + known reference rewrite + one added-import line + spliced at exactly the previewed offset, fresh binding read from the one + added declaration, then `check` exit 0 (T6.5-7's soundness-guard + precedent); (c) file move: file-relocation spanning the whole file under + its pre-op path beside its own specifier rewrite, importer's specifier + rewrite, chains as controls, root pair in the mapping (T6.5-1 + precedent); (d) created target: exactly one file-creation edit at + {0,0} under the creation path — the staged rewrite NEEDS an import + addition there, so subsumption has teeth — moved text's rewrites in the + origin deletion; (e) the tie-break geometry: top-level `<new-id>` into an + existing file whose rewrite needs an import addition in that same file + (target-insertion at EOF, addition free to coincide). Traceability + "T6.6-4": ["6.6"] (12.7/5.7 carriage context, home coverage at + T12.7-*/T5.7-*); CERTIFICATIONS.md keeps T6.6-4 in the Exclusions. + Verified: red-as-diagnosed at arm (a)'s first preview invocation (exit 2 + "unknown flag '--preview'", the whole 6.6 surface patch-new; section-6.6 + suite 3 failed — T6.6-2/T6.6-3 keep their pre-existing FP-001/FP-028-class + reds); staging probes against the built product show all five premise + builds exit 0 and all five real operations proceeding with clean `check` + (the preview-succeeds premise is sound; the real move's Third.mdx + insertion observed at offset 33, binding `Target`, matching the + reconstruction). Satisfiability and teeth proven by running the + registered body via a scratch suite binding against a conforming shim + (real-product delegation for build/real-move/check, canned 12.7 preview + documents computed from the same fixture strings, arm-b addition offset + 33) — green end-to-end through all five arms — and against five + deviations: span-off (red at (a)'s byte-precise edit equality), + not-subsumed (red at (d)'s exactly-one-edit equality), tie-misorder on + the coinciding zero-length pair (red at the decode's comparator), + preview-writes-a-file (red at (b)'s modifies-nothing compare), + offset-lie (red at (b)'s real-run reconstruction). Typecheck/format + clean; `npm run test:self` unchanged 4 planned mid-loop reds + (certification-document ×3 → FP-091; S-1's 7 unmapped keys → stages + E/G), S-5 and certification green.] - [ ] FP-031 — Implement T6.6-5: derived-file delta both directions, record-based. [R1 #10; TEST-SPEC §6.6] diff --git a/test/suite/registry/section-6.6.ts b/test/suite/registry/section-6.6.ts index 50454b9..75fb8a7 100644 --- a/test/suite/registry/section-6.6.ts +++ b/test/suite/registry/section-6.6.ts @@ -1,5 +1,5 @@ -// TEST-SPEC §6.6 (previews) — SUITE-24: T6.6-2, T6.6-3. (T6.6-1 is retired; -// T6.6-4…T6.6-6 are staged by later plan tasks into this module.) +// TEST-SPEC §6.6 (previews) — SUITE-24: T6.6-2, T6.6-3, T6.6-4. (T6.6-1 is +// retired; T6.6-5…T6.6-6 are staged by later plan tasks into this module.) // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -80,17 +80,58 @@ // nothing while held). `--test-hold` + `--preview` is asserted for both // operations and both flag orders: exit 2, the 12.7 error document under // --json, no hold file created, nothing modified. +// - T6.6-4 asserts the preview REPORT's content byte-precisely: expected +// `mapping` and `files` are composed as complete exact lists from the +// staged fixture bytes alone — locator helpers compute byte offsets from +// the same strings the workspace stages (never from product output), and +// multi-byte characters sit before every located construct so byte +// offsets diverge from code-point and UTF-16 counts — and compared +// list-for-list: an extra file entry, a missing edit, a phantom class, or +// a one-byte range drift each fail. Judgment calls pinned here (H-4): an +// `id`-attribute rewrite spans the attribute's own characters (`id="…"`, +// name through closing quote — SPEC 6.6 "the `id` attribute's own +// characters", the construct-spelling reading its sibling clauses use for +// the self-closing tag and the specifier literal, quotes included); the +// self-closing target parent's insertion point maps to the tag's END in +// pre-operation coordinates (every byte the operation adds — the appended +// paired closing tag and the inserted text alike — attaches at that +// offset, the only stable pre-operation anchor); an import addition's +// offset is implementation latitude (SPEC 6.5), so the preview asserts it +// structurally (exactly one such edit, zero-length, within the file) and +// arm (b) pins it against the real operation's bytes by reconstruction: +// the preview runs inside a whole-root modifies-nothing compare, the real +// operation then executes on that pinned pre-operation state (TEST-SPEC's +// "running the operation on a copy", H-4), and the rewritten file must +// equal the pre-operation bytes with the known reference rewrite applied +// and one added-import line — `\n`-preceded exactly when the offset is +// mid-line (SPEC 6.5) — spliced in at exactly the previewed offset. The +// 12.7 edit comparator (range start, then range end, then class-name +// bytes) is enforced by decodePreviewReport on every decoded document; +// arm (e) stages the one geometry where the final tie-break can become +// observable — a top-level `<new-id>` moved into an existing target file +// whose rewrite requires an import addition in that same file, the +// addition's implementation-chosen offset then free to coincide with the +// end-of-file target insertion (`import-addition` ordering before +// `target-insertion` on coincidence; TEST-SPEC T6.6-4). Delta content is +// T6.6-5's business — asserted here only as the decode's success +// encoding (non-null beside `mapping` and `files`). +import { Buffer } from "node:buffer"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import type { AppliedMappingPair, Finding, + PreviewEdit, + PreviewEditClass, + PreviewReport, + SourceRange, } from "../../helpers/adapters/index.js"; import { decodeAppliedMappingReport, decodeFindingsReport, decodePreviewReport, + renderPathValue, } from "../../helpers/adapters/index.js"; import { assertExitCode, @@ -1083,4 +1124,1256 @@ const T6_6_3 = defineProductTest({ }, }); -export const section66Tests: readonly ProductTestEntry[] = [T6_6_2, T6_6_3]; +// --------------------------------------------------------------------------- +// T6.6-4 — report content: the ten 12.7 edit classes, byte-precise +// --------------------------------------------------------------------------- + +/** Byte length of `text` in UTF-8 — fixture offsets are byte offsets (1.7). */ +function utf8Length(text: string): number { + return Buffer.byteLength(text, "utf8"); +} + +/** + * Character index of exactly one occurrence of `fragment` in `haystack`. + * Absent or ambiguous fragments fail loud as staging defects (harness + * errors, never product failures): every located construct must be unique + * in its container, or a precomputed offset could silently name the wrong + * bytes. + */ +function uniqueCharIndex( + haystack: string, + fragment: string, + where: string, +): number { + const first = haystack.indexOf(fragment); + if (first === -1) { + throw new Error( + `T6.6-4 staging locator (${where}): fragment ${JSON.stringify(fragment)} not found`, + ); + } + if (haystack.indexOf(fragment, first + 1) !== -1) { + throw new Error( + `T6.6-4 staging locator (${where}): fragment ${JSON.stringify(fragment)} is ambiguous`, + ); + } + return first; +} + +/** Byte span of the unique `fragment` within `source` (SPEC 1.7). */ +function uniqueSpan( + source: string, + fragment: string, + where: string, +): SourceRange { + const start = utf8Length( + source.slice(0, uniqueCharIndex(source, fragment, where)), + ); + return { start, end: start + utf8Length(fragment) }; +} + +/** + * Byte span of `fragment` within the unique `container` within `source` — + * for constructs whose own spelling recurs in the file (a `d` entry equal to + * an `id` attribute's quoted value), located unambiguously through their + * containing construct. + */ +function spanWithin( + source: string, + container: string, + fragment: string, + where: string, +): SourceRange { + const containerIndex = uniqueCharIndex( + source, + container, + `${where} (container)`, + ); + const inner = uniqueCharIndex(container, fragment, `${where} (fragment)`); + const start = + utf8Length(source.slice(0, containerIndex)) + + utf8Length(container.slice(0, inner)); + return { start, end: start + utf8Length(fragment) }; +} + +/** The zero-length insertion-point range at a byte offset (SPEC 6.6, 12.7). */ +function insertionPoint(offset: number): SourceRange { + return { start: offset, end: offset }; +} + +/** One expected preview edit — same information as the decoded form. */ +interface ExpectedEdit { + readonly class: PreviewEditClass; + readonly range: SourceRange; +} + +/** + * The pinned 12.7 edit order — range start, then range end, then class-name + * bytes — applied to composed EXPECTED lists so they meet the product's + * decode-enforced order; the order assertion itself lives in + * decodePreviewReport (form-exact, H-3), so sorting the expectation is + * composition, not tautology. + */ +function editsInPinnedOrder( + edits: readonly ExpectedEdit[], +): readonly ExpectedEdit[] { + return [...edits].sort( + (a, b) => + a.range.start - b.range.start || + a.range.end - b.range.end || + Buffer.compare( + Buffer.from(a.class, "utf8"), + Buffer.from(b.class, "utf8"), + ), + ); +} + +/** Readable projection for exact edit-list comparison diagnoses. */ +function projectEdits(edits: readonly (PreviewEdit | ExpectedEdit)[]): unknown { + return edits.map((edit) => ({ + class: edit.class, + start: edit.range.start, + end: edit.range.end, + })); +} + +/** + * Staging self-check: every claimed-nested expected edit lies inside the + * origin deletion's range — the containment geometry SPEC 6.6 states for the + * moved text's own rewrites. A violation is a defect in THIS fixture's + * arithmetic, never a product failure, so it throws a plain error. + */ +function assertComposedWithin( + outer: SourceRange, + nested: readonly ExpectedEdit[], + where: string, +): void { + for (const edit of nested) { + if (edit.range.start < outer.start || edit.range.end > outer.end) { + throw new Error( + `T6.6-4 staging self-check (${where}): composed ${edit.class} edit ` + + `[${String(edit.range.start)}, ${String(edit.range.end)}) must nest inside ` + + `the origin deletion [${String(outer.start)}, ${String(outer.end)}) ` + + `(SPEC 6.6: containment is geometry)`, + ); + } + } +} + +/** + * One expected `files` entry. When `importAdditionLatitude` is set, the + * entry must carry — beyond the exact `edits` — exactly one + * `import-addition` edit whose offset is the product's own choice (SPEC 6.5 + * implementation latitude, exercised deterministically): asserted + * zero-length and within the file, its offset captured for the caller. + */ +interface ExpectedPreviewFile { + readonly file: string; + readonly edits: readonly ExpectedEdit[]; + readonly importAdditionLatitude?: { readonly sourceByteLength: number }; +} + +interface ExpectedPreviewPlan { + readonly mapping: readonly AppliedMappingPair[]; + readonly files: readonly ExpectedPreviewFile[]; +} + +/** + * Assert a successful preview's plan content exactly (T6.6-4): findings + * `[]`; `mapping` equal to the complete expected identity mapping, pair for + * pair in the decode-enforced `from`-byte order; `files` equal entry for + * entry — same files, same edits, byte-precise ranges against the + * precomputed pre-operation offsets, in the decode-enforced 12.7 edit order + * — with the import-addition latitude slots handled per + * {@link ExpectedPreviewFile}. Returns the captured import-addition offsets + * by file. Delta content is T6.6-5's business (non-null is the success + * encoding, asserted here). + */ +function assertPreviewPlanContent( + report: PreviewReport, + expected: ExpectedPreviewPlan, + context: string, +): ReadonlyMap<string, number> { + assertSameJson( + report.findings, + [], + `${context}: a preview whose real operation would proceed reports ` + + `findings [] (SPEC 6.6, 12.7)`, + ); + if ( + report.mapping === null || + report.files === null || + report.delta === null + ) { + fail( + `${context}: a successful preview reports its plan — \`mapping\`, ` + + `\`files\`, and \`delta\` are null exactly on refusal (SPEC 6.6, 12.7); ` + + `got mapping ${report.mapping === null ? "null" : "present"}, files ` + + `${report.files === null ? "null" : "present"}, delta ` + + `${report.delta === null ? "null" : "present"}`, + ); + } + assertSameJson( + report.mapping, + expected.mapping, + `${context}: \`mapping\` is the complete identity mapping the operation ` + + `would journal — the renamed/moved ID and every descendant (file-form: ` + + `every node of the file, the implicit root included), one {"from", ` + + `"to"} per mapped identity in \`from\`-byte order, nothing else ` + + `(SPEC 6.6, 6.4, 6.5, 12.7)`, + ); + const files = report.files; + if (files.length !== expected.files.length) { + fail( + `${context}: \`files\` must hold one {"file", "edits"} entry per file ` + + `the operation would rewrite, relocate, or create — expected ` + + `[${expected.files.map((f) => f.file).join(", ")}], got ` + + `[${files.map((f) => renderPathValue(f.file)).join(", ")}] (SPEC 6.6, 12.7)`, + ); + } + const captured = new Map<string, number>(); + for (let i = 0; i < expected.files.length; i += 1) { + const want = expected.files[i]!; + const got = files[i]!; + if (got.file !== want.file) { + fail( + `${context}: files[${String(i)}] must be ${JSON.stringify(want.file)} ` + + `— entries under current, pre-operation paths (target-file ` + + `creation under the path the creation would occupy), ordered by ` + + `file path bytes (SPEC 6.6, 12.7); got ${renderPathValue(got.file)}`, + ); + } + const latitude = want.importAdditionLatitude; + if (latitude === undefined) { + assertSameJson( + projectEdits(got.edits), + projectEdits(want.edits), + `${context}: ${want.file} — every edit the operation would make ` + + `there, class-plus-range only, byte-precise against the ` + + `precomputed pre-operation offsets, in 12.7's pinned edit order ` + + `(SPEC 6.6, 12.7)`, + ); + continue; + } + const additions = got.edits.filter( + (edit) => edit.class === "import-addition", + ); + const rest = got.edits.filter((edit) => edit.class !== "import-addition"); + if (additions.length !== 1) { + fail( + `${context}: ${want.file} — the rewrite requires exactly one added ` + + `import here, so the entry carries exactly one import-addition ` + + `edit (SPEC 6.5, 6.6); got ${String(additions.length)} ` + + `(edits: ${JSON.stringify(projectEdits(got.edits))})`, + ); + } + const addition = additions[0]!; + if (addition.range.start !== addition.range.end) { + fail( + `${context}: ${want.file} — an import addition is a zero-length ` + + `range at the insertion offset (SPEC 6.6, 12.7); got ` + + `[${String(addition.range.start)}, ${String(addition.range.end)})`, + ); + } + if ( + addition.range.start < 0 || + addition.range.start > latitude.sourceByteLength + ) { + fail( + `${context}: ${want.file} — the import addition's offset is ` + + `implementation latitude (SPEC 6.5) but must lie within the ` + + `file's ${String(latitude.sourceByteLength)} pre-operation bytes; ` + + `got ${String(addition.range.start)}`, + ); + } + assertSameJson( + projectEdits(rest), + projectEdits(want.edits), + `${context}: ${want.file} — the edits beside the ` + + `implementation-latitude import addition, class-plus-range only, ` + + `byte-precise in 12.7's pinned order (SPEC 6.6, 12.7)`, + ); + captured.set(want.file, addition.range.start); + } + return captured; +} + +/** + * Run `<operation> --preview --json`: exit 0 (the staging's premise `build` + * passed, so the real operation would proceed and the preview succeeds with + * it, SPEC 6.6), a single JSON document as the entire stdout (12.0), decoded + * as the form-exact 12.7 preview document (H-3) — the decode also enforcing + * the full 12.7 edit comparator, range start, then range end, then + * class-name bytes, over whatever edits are emitted (T6.6-4's tie-break + * assertion). + */ +async function runPreviewJson( + product: ProductBinding, + workspace: TestWorkspace, + operationArgv: readonly string[], + context: string, +): Promise<PreviewReport> { + const argv = [...operationArgv, "--preview", "--json"]; + const result = await expectExit( + product, + workspace, + argv, + 0, + `${context}: \`${argv.join(" ")}\` — the preview succeeds exactly when ` + + `the real operation would proceed, and this staging's premise build ` + + `passed (SPEC 6.6, 12.0)`, + ); + return decodePreviewReport( + parseJsonStdout( + result, + `${context}: \`${argv.join(" ")}\` — a single JSON document as the ` + + `entire stdout (SPEC 12.0)`, + ), + context, + ); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Arm (b)'s real-run pin on the import addition (TEST-SPEC T6.6-4: "the + * exact offset the real operation then uses … byte-asserted by running the + * operation on a copy"): after the real operation runs on the + * preview-pinned pre-operation state, the rewritten file's bytes must equal + * the pre-operation bytes with (1) the known reference rewrite applied over + * its precomputed span — the fresh binding is the product's choice (SPEC + * 6.5), read out of the one added import declaration — and (2) one + * added-import segment spliced in at exactly the previewed offset: the + * declaration's characters followed by U+000A, preceded by one exactly when + * the offset is not at the start of a line (SPEC 6.5). Any other insertion + * point, extent, or byte change fails the reconstruction. + */ +async function assertRealRunInsertsImportAtPreviewedOffset( + product: ProductBinding, + workspace: TestWorkspace, + options: { + readonly operationArgv: readonly string[]; + readonly file: string; + readonly preSource: string; + /** The one reference-rewrite span in `file` (pre-operation bytes). */ + readonly referenceSpan: SourceRange; + /** Rewritten chain minus its root binding, e.g. `.tp.nw.kid` (6.4). */ + readonly rewrittenChainSuffix: string; + /** The added import's specifier, e.g. `./Target.xspec` (2.1, 6.5). */ + readonly importSpecifier: string; + /** The previewed import-addition offset (pre-operation bytes). */ + readonly additionOffset: number; + }, + context: string, +): Promise<void> { + const { + operationArgv, + file, + preSource, + referenceSpan, + rewrittenChainSuffix, + importSpecifier, + additionOffset, + } = options; + const declarationPattern = new RegExp( + `import[ \\t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \\t]+from[ \\t]+(["'])${escapeRegExp(importSpecifier)}\\2`, + "g", + ); + if (preSource.match(declarationPattern) !== null) { + throw new Error( + `T6.6-4 staging self-check: ${file} must import ${importSpecifier} ` + + `nowhere before the operation, so the one post-operation match is ` + + `the added declaration`, + ); + } + + await expectExit( + product, + workspace, + operationArgv, + 0, + `${context}: \`${operationArgv.join(" ")}\` — the real operation on the ` + + `preview-pinned state proceeds (SPEC 6.5; the premise build passed ` + + `and the preview above modified nothing)`, + ); + + const postBytes = await workspace.readBytes(file); + let postText: string; + try { + postText = new TextDecoder("utf-8", { fatal: true }).decode(postBytes); + } catch { + return fail( + `${context}: the rewritten ${file} must remain valid UTF-8 ` + + `(SPEC 1.6, 6.5)`, + ); + } + const matches = [...postText.matchAll(declarationPattern)]; + if (matches.length !== 1) { + return fail( + `${context}: the rewrite leaves ${file} needing exactly one module ` + + `binding for ${importSpecifier}, added as one import declaration ` + + `(SPEC 6.5, 2.1); found ${String(matches.length)} in the rewritten file`, + ); + } + const binding = matches[0]![1]!; + const rewrittenReference = `${binding}${rewrittenChainSuffix}`; + + const preBytes = Buffer.from(preSource, "utf8"); + const expectedWithReference = Buffer.concat([ + preBytes.subarray(0, referenceSpan.start), + Buffer.from(rewrittenReference, "utf8"), + preBytes.subarray(referenceSpan.end), + ]); + if ( + additionOffset > referenceSpan.start && + additionOffset < referenceSpan.end + ) { + return fail( + `${context}: the previewed import-addition offset ` + + `${String(additionOffset)} lies inside the rewritten reference ` + + `[${String(referenceSpan.start)}, ${String(referenceSpan.end)}) — no ` + + `file grammar permits an import declaration inside a reference ` + + `(SPEC 6.5, 2.1)`, + ); + } + const adjustedOffset = + additionOffset <= referenceSpan.start + ? additionOffset + : additionOffset + + (utf8Length(rewrittenReference) - + (referenceSpan.end - referenceSpan.start)); + + const head = expectedWithReference.subarray(0, adjustedOffset); + const tail = expectedWithReference.subarray(adjustedOffset); + const insertedLength = postBytes.length - expectedWithReference.length; + const describePost = (): string => + `rewritten ${file}: ${JSON.stringify(postText)}`; + if (insertedLength <= 0) { + return fail( + `${context}: the real operation must add one import line to ${file} ` + + `beyond the reference rewrite (SPEC 6.5); the rewritten file is not ` + + `longer than the reference-rewritten pre-operation bytes — ${describePost()}`, + ); + } + if ( + Buffer.compare(postBytes.subarray(0, head.length), head) !== 0 || + Buffer.compare(postBytes.subarray(postBytes.length - tail.length), tail) !== + 0 + ) { + return fail( + `${context}: the real operation must insert the added import at ` + + `exactly the previewed offset ${String(additionOffset)} ` + + `(pre-operation coordinates; SPEC 6.5: in a file existing before ` + + `the operation the offset is exactly the one the preview reports, ` + + `6.6) and change no other byte of ${file} beyond the reference ` + + `rewrite — ${describePost()}`, + ); + } + const inserted = postBytes.subarray( + head.length, + head.length + insertedLength, + ); + const atLineStart = + additionOffset === 0 || preBytes[additionOffset - 1] === 0x0a; + const insertedPattern = new RegExp( + `^${atLineStart ? "" : "\\n"}import[ \\t]+${escapeRegExp(binding)}[ \\t]+from[ \\t]+(["'])${escapeRegExp(importSpecifier)}\\1;?\\n$`, + ); + const insertedText = Buffer.from(inserted).toString("utf8"); + if (!insertedPattern.test(insertedText)) { + fail( + `${context}: the added import is inserted as a line of its own — the ` + + `declaration's characters followed by U+000A, preceded by one ` + + `exactly when the insertion point is not at the start of a line ` + + `(here it ${atLineStart ? "is" : "is not"}; SPEC 6.5); the bytes at ` + + `the previewed offset are ${JSON.stringify(insertedText)}`, + ); + } +} + +// One spec group, no Markdown emission, no code group — arms (b)–(e) rewrite +// MDX alone, and the derived-file delta's content is T6.6-5's business. +const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`; + +// Arm (a) adds a code group: the rename's reference rewrites span MDX and TS +// (TEST-SPEC T6.6-4(a)). +const SPECS_AND_CODE_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + } +}) +`; + +// --- Arm (a): rename preview — id-rewrites and the four 5.7 occurrence +// kinds across MDX and TS. `core.mid` (with descendant `core.mid.leaf`) is +// renamed to `core.hub`; affected references: two `d` entries and one MDX +// embedding in the origin file (string form), a `d` chain and an embedding +// in a second MDX file (external form), and a marker plus a `text(...)` call +// in a TS file. Controls that must produce NO edit: `d={"core.plain"}` (its +// target keeps its identity), every unaffected `id` attribute, and the +// unrelocated `./Core.xspec` import specifiers. Multi-byte text ("hölder", +// "ünicode", "Δ") precedes every located construct. +const A4_CORE = "specs/Core.mdx"; +const A4_OTHER = "specs/Other.mdx"; +const A4_USE = "src/use.ts"; +const A4_CORE_SOURCE = [ + '<S id="core">', + "Core hölder text.", + "", + '<S id="core.mid" d={"core.plain"}>', + "Mid text.", + "", + '<S id="core.mid.leaf">', + "Leaf text.", + "</S>", + "</S>", + "", + '<S id="core.sib" d={["core.mid", "core.mid.leaf"]}>', + 'Sib embeds: {text("core.mid.leaf")}', + "</S>", + "", + '<S id="core.plain">', + "Plain text.", + "</S>", + "</S>", + "", +].join("\n"); +const A4_OTHER_SOURCE = [ + 'import CORE from "./Core.xspec"', + "", + '<S id="oth">', + "Other ünicode text.", + "", + '<S id="oth.dep" d={CORE.core.mid}>', + "Dep text.", + "", + "{text(CORE.core.mid.leaf)}", + "</S>", + "</S>", + "", +].join("\n"); +const A4_USE_SOURCE = [ + "// Δ byte offsets in this file diverge from code-point counts.", + 'import SPEC, { text } from "../specs/Core.xspec"', + "", + "export function useMid(): string {", + " SPEC.core.mid.leaf;", + " return text(SPEC.core.mid);", + "}", + "", +].join("\n"); +const A4_RENAME_ARGV = ["rename", A4_CORE, "core.mid", "core.hub"] as const; + +function armAPlan(): ExpectedPreviewPlan { + const core = A4_CORE_SOURCE; + const dArray = 'd={["core.mid", "core.mid.leaf"]}'; + return { + mapping: [ + { from: "specs/Core.mdx#core.mid", to: "specs/Core.mdx#core.hub" }, + { + from: "specs/Core.mdx#core.mid.leaf", + to: "specs/Core.mdx#core.hub.leaf", + }, + ], + files: [ + { + file: A4_CORE, + edits: editsInPinnedOrder([ + // The renamed bearer's and its descendant's `id` attributes — the + // attribute's own characters (SPEC 6.6, 6.4). + { + class: "id-rewrite", + range: uniqueSpan(core, 'id="core.mid"', "a: core.mid id"), + }, + { + class: "id-rewrite", + range: uniqueSpan(core, 'id="core.mid.leaf"', "a: leaf id"), + }, + // Each `d` array entry is its own occurrence spanning that one + // reference's own expression (SPEC 5.7) — located through the + // array (the string spelling recurs inside `id="…"` attributes). + { + class: "reference-rewrite", + range: spanWithin(core, dArray, '"core.mid"', "a: d core.mid"), + }, + { + class: "reference-rewrite", + range: spanWithin( + core, + dArray, + '"core.mid.leaf"', + "a: d core.mid.leaf", + ), + }, + // An MDX embedding spans the entire `{text(...)}` container, + // opening brace through closing brace (SPEC 5.7). + { + class: "reference-rewrite", + range: uniqueSpan(core, '{text("core.mid.leaf")}', "a: embedding"), + }, + ]), + }, + { + file: A4_OTHER, + edits: editsInPinnedOrder([ + { + class: "reference-rewrite", + range: spanWithin( + A4_OTHER_SOURCE, + "d={CORE.core.mid}", + "CORE.core.mid", + "a: external d chain", + ), + }, + { + class: "reference-rewrite", + range: uniqueSpan( + A4_OTHER_SOURCE, + "{text(CORE.core.mid.leaf)}", + "a: external embedding", + ), + }, + ]), + }, + { + file: A4_USE, + edits: editsInPinnedOrder([ + // A TS marker occurrence spans the bare reference chain alone, + // exclusive of the statement terminator (SPEC 5.7). + { + class: "reference-rewrite", + range: uniqueSpan(A4_USE_SOURCE, "SPEC.core.mid.leaf", "a: marker"), + }, + // A TS `text(...)` occurrence spans the entire call expression, + // callee through closing parenthesis (SPEC 5.7). + { + class: "reference-rewrite", + range: uniqueSpan( + A4_USE_SOURCE, + "text(SPEC.core.mid)", + "a: text call", + ), + }, + ]), + }, + ], + }; +} + +// --- Arm (b): section move into an existing target file. The moved +// construct is indented two spaces and closes on an indented line, so the +// origin edit's line-drop rule leaves exactly one merged whitespace-only +// line — the deletion range extends over that leftover whitespace and its +// terminator, contiguous with the construct (SPEC 6.5, 3). The origin's +// `TGT` import is referenced only inside the moved subtree (import-removal: +// the declaration plus its dropped line terminator); the target parent `tp` +// is self-closing (target-parent-rewrite spanning the tag, the insertion +// point at the tag's end); Third.mdx keeps a reference to a moved node and +// lacks a Target binding (import-addition — offset latitude, pinned by the +// real run) beside a control reference (`ORG.org.stay`) that keeps its ORG +// import referenced (no removal there). +const B4_ORIGIN = "specs/Origin.mdx"; +const B4_TARGET = "specs/Target.mdx"; +const B4_THIRD = "specs/Third.mdx"; +const B4_IMPORT_DECL = 'import TGT from "./Target.xspec"'; +const B4_MOVED_CONSTRUCT = [ + '<S id="org.mv" d={[TGT.base, "org.mv.kid"]}>', + "Moved head text.", + "", + '<S id="org.mv.kid">', + "Moved kid text.", + "</S>", + " </S>", +].join("\n"); +const B4_ORIGIN_SOURCE = [ + B4_IMPORT_DECL, + "", + '<S id="org">', + "Origin hölder text.", + "", + " " + B4_MOVED_CONSTRUCT, + "", + '<S id="org.stay">', + "Staying text.", + "</S>", + "</S>", + "", +].join("\n"); +const B4_TARGET_PARENT_TAG = '<S id="tp" />'; +const B4_TARGET_SOURCE = [ + '<S id="base">', + "Base ünicode text.", + "</S>", + "", + B4_TARGET_PARENT_TAG, + "", +].join("\n"); +const B4_THIRD_SOURCE = [ + 'import ORG from "./Origin.xspec"', + "", + '<S id="t">', + "Third ünicode text.", + "", + '<S id="t.use" d={[ORG.org.mv.kid, ORG.org.stay]}>', + "Use text.", + "</S>", + "</S>", + "", +].join("\n"); +const B4_MOVE_ARGV = [ + "move", + `${B4_ORIGIN}#org.mv`, + `${B4_TARGET}#tp.nw`, +] as const; + +function armBPlan(): ExpectedPreviewPlan & { + readonly thirdReferenceSpan: SourceRange; +} { + const origin = B4_ORIGIN_SOURCE; + // Staging self-checks on the adjunct geometry the ranges extend over + // (violations are fixture-arithmetic defects, never product failures). + if (!origin.startsWith(B4_IMPORT_DECL + "\n")) { + throw new Error( + "T6.6-4 staging self-check (b): the removed import must open the " + + "origin on a line of its own", + ); + } + const constructChar = uniqueCharIndex( + origin, + B4_MOVED_CONSTRUCT, + "b: moved construct", + ); + if ( + origin.slice(constructChar - 3, constructChar) !== "\n " || + origin.charAt(constructChar + B4_MOVED_CONSTRUCT.length) !== "\n" + ) { + throw new Error( + "T6.6-4 staging self-check (b): the moved construct must sit behind " + + "exactly two spaces of indentation and close before a line " + + "terminator, so the deletion leaves one whitespace-only merged line", + ); + } + const construct = uniqueSpan(origin, B4_MOVED_CONSTRUCT, "b: construct"); + // One range spanning every byte the origin edit removes: the construct's + // own characters extended over the leftover indentation before it and the + // merged line's terminator after it — contiguous bytes, the adjunct drop + // inside this class's range (SPEC 6.5, 3, 6.6). + const originDeletion: SourceRange = { + start: construct.start - 2, + end: construct.end + 1, + }; + const dArray = 'd={[TGT.base, "org.mv.kid"]}'; + const nestedEdits: readonly ExpectedEdit[] = [ + { + class: "id-rewrite", + range: uniqueSpan(origin, 'id="org.mv"', "b: org.mv id"), + }, + { + class: "id-rewrite", + range: uniqueSpan(origin, 'id="org.mv.kid"', "b: kid id"), + }, + { + class: "reference-rewrite", + range: spanWithin(origin, dArray, "TGT.base", "b: TGT.base"), + }, + { + class: "reference-rewrite", + range: spanWithin(origin, dArray, '"org.mv.kid"', "b: local ref"), + }, + ]; + assertComposedWithin(originDeletion, nestedEdits, "b: origin"); + const parentTag = uniqueSpan( + B4_TARGET_SOURCE, + B4_TARGET_PARENT_TAG, + "b: target parent", + ); + const thirdReferenceSpan = spanWithin( + B4_THIRD_SOURCE, + "d={[ORG.org.mv.kid, ORG.org.stay]}", + "ORG.org.mv.kid", + "b: third ref", + ); + return { + thirdReferenceSpan, + mapping: [ + { from: "specs/Origin.mdx#org.mv", to: "specs/Target.mdx#tp.nw" }, + { + from: "specs/Origin.mdx#org.mv.kid", + to: "specs/Target.mdx#tp.nw.kid", + }, + ], + files: [ + { + file: B4_ORIGIN, + edits: editsInPinnedOrder([ + // The unreferenced-after-rewrite import: the declaration plus its + // adjunct drop — the emptied line's terminator (SPEC 6.5). + { + class: "import-removal", + range: { start: 0, end: utf8Length(B4_IMPORT_DECL) + 1 }, + }, + { class: "origin-deletion", range: originDeletion }, + // The re-identification's id-rewrites and the moved text's own + // reference rewrites nest inside the deletion range, each under + // its own class (SPEC 6.6: containment is geometry). + ...nestedEdits, + ]), + }, + { + file: B4_TARGET, + edits: editsInPinnedOrder([ + // The self-closing target parent's rewrite spans the tag; the + // insertion point is the tag's end in pre-operation coordinates + // (module header, H-4). + { class: "target-parent-rewrite", range: parentTag }, + { class: "target-insertion", range: insertionPoint(parentTag.end) }, + ]), + }, + { + file: B4_THIRD, + edits: [{ class: "reference-rewrite", range: thirdReferenceSpan }], + importAdditionLatitude: { + sourceByteLength: utf8Length(B4_THIRD_SOURCE), + }, + }, + ], + }; +} + +// --- Arm (c): file-form move. `specs/Mv.mdx` relocates into a subdirectory, +// so its own `./Pal.xspec` specifier and the importer's `./Mv.xspec` +// specifier both rewrite (import-specifier-rewrite spanning the specifier +// literal's characters, quotes included) while the reference chains +// (`PAL.pal`, `MV.mv`) are untouched controls — IDs are unchanged, only the +// file part of each identity moves (SPEC 6.5). +const C4_MV = "specs/Mv.mdx"; +const C4_PAL = "specs/Pal.mdx"; +const C4_USER = "specs/User.mdx"; +const C4_MV_SOURCE = [ + 'import PAL from "./Pal.xspec"', + "", + '<S id="mv" d={PAL.pal}>', + "Mv ünicode text.", + "</S>", + "", +].join("\n"); +const C4_PAL_SOURCE = ['<S id="pal">', "Pal text.", "</S>", ""].join("\n"); +const C4_USER_SOURCE = [ + 'import MV from "./Mv.xspec"', + "", + '<S id="user" d={MV.mv}>', + "User text.", + "</S>", + "", +].join("\n"); +const C4_MOVE_ARGV = ["move", C4_MV, "specs/sub/Mv2.mdx"] as const; + +function armCPlan(): ExpectedPreviewPlan { + return { + mapping: [ + // Every node of the moved file, the implicit root included (its + // identity is the path alone, SPEC 1.2, 1.5; T6.5-1's precedent). + { from: "specs/Mv.mdx", to: "specs/sub/Mv2.mdx" }, + { from: "specs/Mv.mdx#mv", to: "specs/sub/Mv2.mdx#mv" }, + ], + files: [ + { + file: C4_MV, + edits: editsInPinnedOrder([ + // The relocation spans the entire moved file, its entry under the + // current, pre-operation path (SPEC 6.6, 12.7). + { + class: "file-relocation", + range: { start: 0, end: utf8Length(C4_MV_SOURCE) }, + }, + { + class: "import-specifier-rewrite", + range: uniqueSpan( + C4_MV_SOURCE, + '"./Pal.xspec"', + "c: own specifier", + ), + }, + ]), + }, + { + file: C4_USER, + edits: [ + { + class: "import-specifier-rewrite", + range: uniqueSpan( + C4_USER_SOURCE, + '"./Mv.xspec"', + "c: importer specifier", + ), + }, + ], + }, + ], + }; +} + +// --- Arm (d): section move whose target file does not exist. The moved +// section references a staying node (`"hold.keep"`), so the created file +// needs an added Origin import — subsumed, with the insertion, by the one +// file-creation edit (a product reporting a target-insertion or +// import-addition under the created path fails the exactly-one-edit +// equality); the moved text's own rewrites are reported inside the origin +// deletion's range. +const D4_SOLO = "specs/Solo.mdx"; +const D4_MADE = "specs/Made.mdx"; +const D4_MOVED_CONSTRUCT = [ + '<S id="hold.out" d={"hold.keep"}>', + "Out text.", + "</S>", +].join("\n"); +const D4_SOLO_SOURCE = [ + '<S id="hold">', + "Hold ünicode text.", + "", + D4_MOVED_CONSTRUCT, + "", + '<S id="hold.keep">', + "Keep text.", + "</S>", + "</S>", + "", +].join("\n"); +const D4_MOVE_ARGV = [ + "move", + `${D4_SOLO}#hold.out`, + `${D4_MADE}#made`, +] as const; + +function armDPlan(): ExpectedPreviewPlan { + const solo = D4_SOLO_SOURCE; + const constructChar = uniqueCharIndex( + solo, + D4_MOVED_CONSTRUCT, + "d: moved construct", + ); + if ( + solo.charAt(constructChar - 1) !== "\n" || + solo.charAt(constructChar + D4_MOVED_CONSTRUCT.length) !== "\n" + ) { + throw new Error( + "T6.6-4 staging self-check (d): the moved construct must occupy whole " + + "lines, so the deletion's adjunct drop is exactly the merged line's " + + "terminator", + ); + } + const construct = uniqueSpan(solo, D4_MOVED_CONSTRUCT, "d: construct"); + const originDeletion: SourceRange = { + start: construct.start, + end: construct.end + 1, + }; + const nestedEdits: readonly ExpectedEdit[] = [ + { + class: "id-rewrite", + range: uniqueSpan(solo, 'id="hold.out"', "d: id"), + }, + { + class: "reference-rewrite", + range: spanWithin(solo, 'd={"hold.keep"}', '"hold.keep"', "d: ref"), + }, + ]; + assertComposedWithin(originDeletion, nestedEdits, "d: origin"); + return { + mapping: [{ from: "specs/Solo.mdx#hold.out", to: "specs/Made.mdx#made" }], + files: [ + { + // The created file's entry, under the path the creation would + // occupy: exactly one file-creation edit at the start of the new + // file — the only reported location without pre-operation + // coordinates (SPEC 6.6, 12.7). + file: D4_MADE, + edits: [{ class: "file-creation", range: insertionPoint(0) }], + }, + { + file: D4_SOLO, + edits: editsInPinnedOrder([ + { class: "origin-deletion", range: originDeletion }, + ...nestedEdits, + ]), + }, + ], + }; +} + +// --- Arm (e): the tie-break geometry. A top-level `<new-id>` moves into an +// existing target file (target insertion at end of file) whose rewrite +// requires an import addition in that same file (the moved section +// references a staying origin node) — the one staging where the addition's +// implementation-chosen offset (SPEC 6.5) can coincide with the target +// insertion; whatever the product chooses, decodePreviewReport enforces the +// full 12.7 comparator (`import-addition` before `target-insertion` on +// coincidence — class-name bytes after equal range starts and ends). +const E4_SRC = "specs/Src.mdx"; +const E4_DST = "specs/Dst.mdx"; +const E4_MOVED_CONSTRUCT = [ + '<S id="roam" d={"anchor"}>', + "Roam text.", + "</S>", +].join("\n"); +const E4_SRC_SOURCE = [ + '<S id="anchor">', + "Anchor ünicode text.", + "</S>", + "", + E4_MOVED_CONSTRUCT, + "", +].join("\n"); +const E4_DST_SOURCE = ['<S id="dst">', "Dst ünicode text.", "</S>", ""].join( + "\n", +); +const E4_MOVE_ARGV = ["move", `${E4_SRC}#roam`, `${E4_DST}#roamed`] as const; + +function armEPlan(): ExpectedPreviewPlan { + const src = E4_SRC_SOURCE; + const constructChar = uniqueCharIndex( + src, + E4_MOVED_CONSTRUCT, + "e: moved construct", + ); + if ( + src.charAt(constructChar - 1) !== "\n" || + src.charAt(constructChar + E4_MOVED_CONSTRUCT.length) !== "\n" + ) { + throw new Error( + "T6.6-4 staging self-check (e): the moved construct must occupy whole " + + "lines, so the deletion's adjunct drop is exactly the merged line's " + + "terminator", + ); + } + const construct = uniqueSpan(src, E4_MOVED_CONSTRUCT, "e: construct"); + const originDeletion: SourceRange = { + start: construct.start, + end: construct.end + 1, + }; + const nestedEdits: readonly ExpectedEdit[] = [ + { class: "id-rewrite", range: uniqueSpan(src, 'id="roam"', "e: id") }, + { + class: "reference-rewrite", + range: spanWithin(src, 'd={"anchor"}', '"anchor"', "e: ref"), + }, + ]; + assertComposedWithin(originDeletion, nestedEdits, "e: origin"); + return { + mapping: [{ from: "specs/Src.mdx#roam", to: "specs/Dst.mdx#roamed" }], + files: [ + { + file: E4_DST, + // A top-level `<new-id>`'s insertion point is the end of the file + // (SPEC 6.5, 6.6); the required import addition rides the latitude + // slot, free to coincide with it. + edits: [ + { + class: "target-insertion", + range: insertionPoint(utf8Length(E4_DST_SOURCE)), + }, + ], + importAdditionLatitude: { + sourceByteLength: utf8Length(E4_DST_SOURCE), + }, + }, + { + file: E4_SRC, + edits: editsInPinnedOrder([ + { class: "origin-deletion", range: originDeletion }, + ...nestedEdits, + ]), + }, + ], + }; +} + +const T6_6_4 = defineProductTest({ + id: "T6.6-4", + title: + "report content: byte-precise fixtures asserted against precomputed pre-operation offsets, form-exact per 12.7 — (a) a rename preview reports the complete identity mapping (the renamed ID and every descendant) and, per rewritten file, `id-rewrite` edits spanning each rewritten `id` attribute's own characters and `reference-rewrite` edits spanning each affected occurrence's span (5.7) across MDX and TS; (b) a section-move preview into an existing target file reports the `origin-deletion` as one contiguous range (the construct's own characters extended over the adjunct-dropped leftover whitespace and line terminator), the re-identification's `id-rewrite` edits and the moved text's reference rewrites nested inside that range, `target-insertion` zero-length at the insertion offset, `target-parent-rewrite` spanning the self-closing target parent's tag, `import-addition` zero-length at the exact offset the real operation then uses (byte-asserted by running the operation on the preview-pinned state), and `import-removal` spanning the declaration plus its adjunct drops; (c) a file-form move preview reports `import-specifier-rewrite` edits spanning the specifier literals and `file-relocation` spanning the entire moved file under its pre-operation path; (d) a created-target section-move preview reports exactly one `file-creation` edit at the new file's start — the insertion and import additions there subsumed — with the moved text's own rewrites inside the origin deletion; every edit class-plus-range only, every class one of the ten 12.7 names, and the full 12.7 edit comparator asserted over whatever edits are emitted, staged (e) where an import addition can coincide with the end-of-file target insertion (SPEC 6.6, 12.7, 6.4, 6.5, 5.7, 1.7, 2.1, 3; H-3, H-4)", + run: async (product) => { + // --- Arm (a): rename preview across MDX and TS --- + await withWorkspace( + SPECS_AND_CODE_CONFIG, + { + [A4_CORE]: A4_CORE_SOURCE, + [A4_OTHER]: A4_OTHER_SOURCE, + [A4_USE]: A4_USE_SOURCE, + }, + async (workspace) => { + const context = "T6.6-4(a) rename preview"; + await buildOk( + product, + workspace, + `${context}: staging premise \`build\` — the workspace is valid, ` + + `so the rename would proceed and its preview succeeds (SPEC 6.4, 6.6)`, + ); + const report = await runPreviewJson( + product, + workspace, + A4_RENAME_ARGV, + context, + ); + assertPreviewPlanContent(report, armAPlan(), context); + }, + ); + + // --- Arm (b): section move into an existing target file, then the real + // run pinning the import addition's offset --- + await withWorkspace( + SPECS_ONLY_CONFIG, + { + [B4_ORIGIN]: B4_ORIGIN_SOURCE, + [B4_TARGET]: B4_TARGET_SOURCE, + [B4_THIRD]: B4_THIRD_SOURCE, + }, + async (workspace) => { + const context = "T6.6-4(b) section-move preview (existing target)"; + await buildOk( + product, + workspace, + `${context}: staging premise \`build\` (SPEC 6.5, 6.6)`, + ); + const plan = armBPlan(); + // The preview inside a whole-root modifies-nothing compare: the + // real run below then executes on the byte-identical pre-operation + // state — TEST-SPEC's "running the operation on a copy" (H-4). + const additionOffset = await assertLeavesUnchanged( + workspace.root, + async () => { + const report = await runPreviewJson( + product, + workspace, + B4_MOVE_ARGV, + context, + ); + const captured = assertPreviewPlanContent(report, plan, context); + const offset = captured.get(B4_THIRD); + if (offset === undefined) { + throw new Error( + "T6.6-4(b): latitude capture must yield the Third.mdx " + + "import-addition offset", + ); + } + return offset; + }, + `${context}: the preview modifies nothing (SPEC 6.6) — pinning ` + + `the pre-operation state for the real run's byte assertion`, + ); + await assertRealRunInsertsImportAtPreviewedOffset( + product, + workspace, + { + operationArgv: [...B4_MOVE_ARGV], + file: B4_THIRD, + preSource: B4_THIRD_SOURCE, + referenceSpan: plan.thirdReferenceSpan, + rewrittenChainSuffix: ".tp.nw.kid", + importSpecifier: "./Target.xspec", + additionOffset, + }, + "T6.6-4(b) real move after the preview", + ); + // Composition soundness guard (the T6.5-7 precedent): everything + // resolves after the move — a defective expectation must fail loud + // rather than certify a broken rewrite. + await expectExit( + product, + workspace, + ["check"], + 0, + "T6.6-4(b) `check` after the real move — the rewritten workspace " + + "is valid and fresh (SPEC 6.5, 12.2)", + ); + }, + ); + + // --- Arm (c): file-form move preview --- + await withWorkspace( + SPECS_ONLY_CONFIG, + { + [C4_MV]: C4_MV_SOURCE, + [C4_PAL]: C4_PAL_SOURCE, + [C4_USER]: C4_USER_SOURCE, + }, + async (workspace) => { + const context = "T6.6-4(c) file-form move preview"; + await buildOk( + product, + workspace, + `${context}: staging premise \`build\` (SPEC 6.5, 6.6)`, + ); + const report = await runPreviewJson( + product, + workspace, + C4_MOVE_ARGV, + context, + ); + assertPreviewPlanContent(report, armCPlan(), context); + }, + ); + + // --- Arm (d): section-move preview whose target file does not exist --- + await withWorkspace( + SPECS_ONLY_CONFIG, + { [D4_SOLO]: D4_SOLO_SOURCE }, + async (workspace) => { + const context = "T6.6-4(d) created-target move preview"; + await buildOk( + product, + workspace, + `${context}: staging premise \`build\` (SPEC 6.5, 6.6)`, + ); + const report = await runPreviewJson( + product, + workspace, + D4_MOVE_ARGV, + context, + ); + assertPreviewPlanContent(report, armDPlan(), context); + }, + ); + + // --- Arm (e): the coincidence-capable tie-break staging --- + await withWorkspace( + SPECS_ONLY_CONFIG, + { + [E4_SRC]: E4_SRC_SOURCE, + [E4_DST]: E4_DST_SOURCE, + }, + async (workspace) => { + const context = "T6.6-4(e) top-level move preview (tie-break staging)"; + await buildOk( + product, + workspace, + `${context}: staging premise \`build\` (SPEC 6.5, 6.6)`, + ); + const report = await runPreviewJson( + product, + workspace, + E4_MOVE_ARGV, + context, + ); + assertPreviewPlanContent(report, armEPlan(), context); + }, + ); + }, +}); + +export const section66Tests: readonly ProductTestEntry[] = [ + T6_6_2, + T6_6_3, + T6_6_4, +]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 8626fdc..005d5e2 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -217,6 +217,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.5-7": ["6.5"], "T6.6-2": ["6.6"], "T6.6-3": ["6.6", "14"], + "T6.6-4": ["6.6"], "T6.7-1": ["6.7"], "T7-1": ["7", "14"], "T7-2": ["7", "14"], From b160ae1fcb635dbce25b3ea065249e7e61175e4b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 22:06:35 +0000 Subject: [PATCH 076/226] =?UTF-8?q?sdg(phase-9):=20implement=20T6.6-5=20?= =?UTF-8?q?=E2=80=94=20record-based=20derived-file=20delta=20(FP-031)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four arms on two workspaces: file-form move delta both directions (destination set generated, recorded origin set removed), rename [] both directions, the record-deleted move whose generated is the full post-move regeneration set with removed [] and nothing written (graph data still absent), and T6.6-4(d)'s created-target staging with the new file's derived paths under generated. Expected sets composed from the premise build's observed writes under the 13.1 name shape plus the 13.2/7.3 Markdown destination, with a diagnosed partition self-check; section-13.3's T13.3-2 graph-data helpers exported for the shared operational definition. Red-as-diagnosed at the first preview invocation; body proven green against a conforming shim and red against five deviations at exactly the diagnosed assertions; test:self unchanged (4 planned mid-loop reds). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 46 ++- test/suite/registry/section-13.3.ts | 16 +- test/suite/registry/section-6.6.ts | 453 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 4 files changed, 507 insertions(+), 9 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 56cf0a0..eaec82d 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1217,10 +1217,54 @@ A "new test T<x>" task always means, in one change: (certification-document ×3 → FP-091; S-1's 7 unmapped keys → stages E/G), S-5 and certification green.] -- [ ] FP-031 — Implement T6.6-5: derived-file delta both directions, +- [x] FP-031 — Implement T6.6-5: derived-file delta both directions, record-based. [R1 #10; TEST-SPEC §6.6] Not presence-based: with graph data deleted, `generated` approaches the full set and the preview still writes nothing. + [Done 2026-08-11: T6.6-5 registered in section-6.6.ts, four arms on two + workspaces. Expected delta sets are composed from the premise build's own + observed writes (H-4, module header): per source the added plain files + under the 13.1 name shape `DIR/NAME.xspec.<suffix>` (module asserted + present; suffix set observed — implementation latitude) plus the 13.2/7.3 + Markdown destination (`NAME.md` beside the source, outDir unset), a + partition self-check failing diagnosed on any unattributable write; a + not-yet-existing file's paths are the origin's suffix set transposed under + the destination name (13.1: per-source derived paths are defined by the + name shape alone). Workspace 1 (arm (c)'s Mv/Pal/User sources under the + Markdown-emitting config): (1) file-form move preview — `generated` + exactly the destination's module+companions+Markdown, `removed` exactly + the moved file's recorded set; (2) rename preview (`pal`→`pal2`, + cross-file content rewrites) — [] both directions; (3) record-deleted + (T13.3-2's operational definition shared from section-13.3.ts — + isGraphDataKey/assertGraphDataPresent/deleteGraphData now exported): the + same move preview's `generated` equals the FULL post-move regeneration + set (staying sources' on-disk paths included) and `removed` exactly [] + (origin's on-disk paths in neither direction), findings [] (a missing + record is nothing-recorded — never 14.23; the guard rejects the + unavailable encoding), inside a whole-root modifies-nothing compare plus + an explicit graph-data-still-absent sweep. Workspace 2: T6.6-4(d)'s + staging reused verbatim — the created target's transposed + module+companions under `generated` (no Markdown, emission disabled), + `removed` []. Delta decode/order enforcement rides the existing + S-5-guarded decodePreviewReport; no new adapter. Traceability "T6.6-5": + ["6.6"] (13.1–13.3/7.3/12.7 carriage context, home coverage at + T13.1-*/T13.3-*/T7-*/T12.7-*); CERTIFICATIONS.md keeps T6.6-5 in the + Exclusions. Verified: red-as-diagnosed at the first preview invocation + (exit 2 "unknown flag '--preview'"; premise build and staging observation + pass against the current product — probes show its writes match the + partition model exactly: module + impl.d.ts/impl.d.ts.map/impl.js + companions, .md beside source, .xspec/graph.json). Satisfiability and + teeth proven by running the registered body via a scratch suite binding + against a conforming shim (real-product build delegation + sidecar + record, canned 12.7 preview documents; green end-to-end through all four + arms) and five deviations: presence-based generated (red at the + record-deleted full-set equality), presence-based removed (red at removed + []), preview-refreshes-record (red at the modifies-nothing compare), + unavailable-on-missing (red at the unavailable guard), + misplaced-markdown (red at the record-present set equality). + Typecheck/format clean; `npm run test:self` unchanged 4 planned mid-loop + reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys → stages + E/G), S-5 and certification green.] - [ ] FP-032 — Implement T6.6-6: preview under a corrupt graph record. [R1 #11; TEST-SPEC §6.6, SPEC 14.23] diff --git a/test/suite/registry/section-13.3.ts b/test/suite/registry/section-13.3.ts index 083c93c..3fa5c93 100644 --- a/test/suite/registry/section-13.3.ts +++ b/test/suite/registry/section-13.3.ts @@ -142,9 +142,11 @@ async function withWorkspace<T>( /** * Whether a snapshot key (a `/`-separated workspace-relative path) is graph * data: under `.xspec/`, excluding the durable `.xspec/journal` and - * `.xspec/reviews/` (SPEC 13.3, 13.4; TEST-SPEC T13.3-2). + * `.xspec/reviews/` (SPEC 13.3, 13.4; TEST-SPEC T13.3-2). Exported: T6.6-5 + * (section-6.6.ts) shares this operational definition — TEST-SPEC's "graph + * data deleted (T13.3-2's operational definition)". */ -function isGraphDataKey(key: string): boolean { +export function isGraphDataKey(key: string): boolean { if (!key.startsWith(".xspec/")) return false; if (key === ".xspec/journal") return false; if (key === ".xspec/reviews" || key.startsWith(".xspec/reviews/")) { @@ -183,9 +185,10 @@ function asSnapshot( /** * Assert a snapshot holds at least one graph-data entry — after `build`, * graph data lives under `.xspec/` (SPEC 13.3), so an empty set means the - * product maintains it elsewhere or not at all. + * product maintains it elsewhere or not at all. Exported for T6.6-5's + * record-staging premise (section-6.6.ts). */ -function assertGraphDataPresent( +export function assertGraphDataPresent( snapshot: DirectorySnapshot, context: string, ): void { @@ -201,9 +204,10 @@ function assertGraphDataPresent( /** * Delete the graph data per the T13.3-2 operational definition: every path - * under `.xspec/` except `.xspec/journal` and `.xspec/reviews/`. + * under `.xspec/` except `.xspec/journal` and `.xspec/reviews/`. Exported + * for T6.6-5's record-deleted arm (section-6.6.ts). */ -async function deleteGraphData( +export async function deleteGraphData( workspace: TestWorkspace, context: string, ): Promise<void> { diff --git a/test/suite/registry/section-6.6.ts b/test/suite/registry/section-6.6.ts index 75fb8a7..49fc619 100644 --- a/test/suite/registry/section-6.6.ts +++ b/test/suite/registry/section-6.6.ts @@ -1,5 +1,6 @@ -// TEST-SPEC §6.6 (previews) — SUITE-24: T6.6-2, T6.6-3, T6.6-4. (T6.6-1 is -// retired; T6.6-5…T6.6-6 are staged by later plan tasks into this module.) +// TEST-SPEC §6.6 (previews) — SUITE-24: T6.6-2, T6.6-3, T6.6-4, T6.6-5. +// (T6.6-1 is retired; T6.6-6 is staged by a later plan task into this +// module.) // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -115,6 +116,37 @@ // `target-insertion` on coincidence; TEST-SPEC T6.6-4). Delta content is // T6.6-5's business — asserted here only as the decode's success // encoding (non-null beside `mapping` and `files`). +// - T6.6-5 asserts the delta's content record-based, its expected sets +// composed from the premise build's own observed writes (H-4): a source +// `DIR/NAME.mdx`'s module-and-companion paths are the plain files the +// build added under the 13.1 name shape `DIR/NAME.xspec.<suffix>` — the +// module `DIR/NAME.xspec.ts` asserted present; the companion suffix set +// is implementation latitude, so it is observed, never assumed — and its +// Markdown path is the 13.2/7.3 destination, `DIR/NAME.md` next to the +// source with `outDir` unset. Derived paths of a file not existing before +// the operation (the moved-to file, the created target) are the origin's +// observed suffix set transposed under the destination name (SPEC 13.1: +// per-source derived paths are defined by the `NAME.mdx` name shape +// alone). A partition self-check makes every premise-build write +// attributable — graph data (T13.3-2's key rule, shared from +// section-13.3.ts) or exactly one staged source's +// module/companion/Markdown — failing diagnosed otherwise (SPEC 13.1–13.3 +// enumerate what `build` writes). The record itself is opaque (H-4), so +// "recorded" is pinned through 13.3's contract — the record holds the +// paths of the derived files most recently generated, exactly the premise +// build's observed writes — and the delta assertions discriminate a +// product recording anything else. The record-deleted arm (T13.3-2's +// operational definition) asserts the record-based rule from both +// directions: `generated` equal to the FULL post-move regeneration set — +// the staying sources' paths listed although their files sit on disk, the +// origin's still-on-disk paths in neither direction — and `removed` +// exactly [] (nothing recorded), so a presence-based product fails both +// set equalities. An absent record is nothing-recorded, the empty-record +// SUCCESS path (SPEC 6.6: findings [], delta a plain value) — never the +// 14.23 unavailability of T6.6-6, which covers recorded state that exists +// but cannot be read — and the preview never refreshes it (whole-root +// compare around the invocation; graph data asserted still absent +// afterward). import { Buffer } from "node:buffer"; import { defineProductTest } from "../../helpers/registry.js"; @@ -139,9 +171,11 @@ import { parseJsonStdout, } from "../../helpers/assertions.js"; import { assertRunTwiceDeterministic } from "../../helpers/determinism.js"; +import type { DirectorySnapshot } from "../../helpers/snapshot.js"; import { assertLeavesUnchanged, assertSnapshotsEqual, + displaySnapshotPath, snapshotDirectory, } from "../../helpers/snapshot.js"; import type { @@ -189,6 +223,11 @@ import { MOVE_WRONG_KIND_CASES, stageMoveRefusalOccupants, } from "./section-6.5.js"; +import { + assertGraphDataPresent, + deleteGraphData, + isGraphDataKey, +} from "./section-13.3.js"; import { CORE_DECL, awaitHoldFile, @@ -2372,8 +2411,418 @@ const T6_6_4 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T6.6-5 — delta: the derived-file delta, both directions, record-based +// --------------------------------------------------------------------------- + +// The file-form arm reuses arm (c)'s sources — origin `specs/Mv.mdx` with an +// imported neighbor and an importer — under the Markdown-emitting +// configuration, so the delta's universe spans every derived-file kind the +// record covers (SPEC 13.3: modules, companions, emitted Markdown). The +// rename arm renames `pal` in place: its cross-file `PAL.pal` references are +// content rewrites, changing no derived path. +const F5_DEST = C4_MOVE_ARGV[2]; +const F5_RENAME_ARGV = ["rename", C4_PAL, "pal", "pal2"] as const; + +/** The plain files a premise `build` added: file entries of `after` whose + * key `before` lacks (snapshot keys are workspace-relative paths). */ +function addedFiles( + before: DirectorySnapshot, + after: DirectorySnapshot, +): readonly string[] { + const added: string[] = []; + for (const [key, entry] of after.entries) { + if (entry.kind === "file" && !before.entries.has(key)) added.push(key); + } + return added; +} + +/** `DIR/NAME.mdx` → `DIR/NAME` (staging arithmetic; misuse throws). */ +function sourceStem(sourcePath: string): string { + if (!sourcePath.endsWith(".mdx")) { + throw new Error( + `T6.6-5 staging: ${sourcePath} is not a NAME.mdx spec source`, + ); + } + return sourcePath.slice(0, -".mdx".length); +} + +/** The 13.1 module-and-companion name-shape prefix: `DIR/NAME.xspec.`. */ +function moduleCompanionPrefix(sourcePath: string): string { + return `${sourceStem(sourcePath)}.xspec.`; +} + +/** The 13.2/7.3 Markdown emit destination with `emit: true` and `outDir` + * unset: `DIR/NAME.md` next to the source. */ +function markdownDestination(sourcePath: string): string { + return `${sourceStem(sourcePath)}.md`; +} + +/** Paths in byte order (SPEC 12.7: delta directions list paths in byte + * order, so composed expected lists must meet the decode-enforced order). */ +function byteSortedPaths(paths: readonly string[]): readonly string[] { + return [...paths].sort((a, b) => + Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")), + ); +} + +/** One staged source's observed derived files (module header, H-4). */ +interface ObservedDerived { + /** Observed `DIR/NAME.xspec.<suffix>` paths, byte-sorted. */ + readonly moduleAndCompanions: readonly string[]; + /** The Markdown destination; `null` while emission is disabled. */ + readonly markdown: string | null; +} + +/** Every derived path of one source — module, companions, Markdown. */ +function derivedPathsOf(observed: ObservedDerived): readonly string[] { + return [ + ...observed.moduleAndCompanions, + ...(observed.markdown === null ? [] : [observed.markdown]), + ]; +} + +/** + * Partition the premise build's written files into graph data and each + * staged source's derived files (module header, H-4): per source, the + * observed `DIR/NAME.xspec.<suffix>` plain files — the module + * `DIR/NAME.xspec.ts` asserted present (SPEC 13.1), a suffix containing a + * path separator rejected (every companion is a plain file beside the + * module) — plus, with emission enabled, the 13.2/7.3 Markdown destination + * asserted written. A write that is neither graph data nor attributable to + * a staged source fails diagnosed: SPEC 13.1–13.3 enumerate what `build` + * writes. + */ +function observeDerivedWrites( + written: readonly string[], + sources: readonly string[], + emission: boolean, + context: string, +): ReadonlyMap<string, ObservedDerived> { + const unattributed = new Set(written.filter((path) => !isGraphDataKey(path))); + const observed = new Map<string, ObservedDerived>(); + for (const source of sources) { + const prefix = moduleCompanionPrefix(source); + const moduleAndCompanions = byteSortedPaths( + [...unattributed].filter((path) => path.startsWith(prefix)), + ); + for (const path of moduleAndCompanions) { + if (path.slice(prefix.length).includes("/")) { + fail( + `${context}: the premise build wrote ${path} — every companion ` + + `file is named \`NAME.xspec.\` plus a suffix, a plain file ` + + `beside the module (SPEC 13.1), never a deeper path`, + ); + } + unattributed.delete(path); + } + const modulePath = `${prefix}ts`; + if (!moduleAndCompanions.includes(modulePath)) { + fail( + `${context}: the premise build must generate ${source}'s module ` + + `${modulePath} (SPEC 13.1); under the name shape it wrote only ` + + `[${moduleAndCompanions.join(", ")}]`, + ); + } + let markdown: string | null = null; + if (emission) { + markdown = markdownDestination(source); + if (!unattributed.has(markdown)) { + fail( + `${context}: with emission enabled, ${source} emits ${markdown} — ` + + `\`NAME.md\` next to the source, \`outDir\` unset (SPEC 13.2, ` + + `7.3); the premise build did not write it`, + ); + } + unattributed.delete(markdown); + } + observed.set(source, { moduleAndCompanions, markdown }); + } + if (unattributed.size > 0) { + fail( + `${context}: every file the premise build writes is a source's ` + + `module or companion (SPEC 13.1), its emitted Markdown (13.2), or ` + + `graph data under .xspec/ (13.3); it also wrote ` + + `[${[...unattributed].join(", ")}]`, + ); + } + return observed; +} + +/** + * The origin's observed module-and-companion paths transposed under another + * source name — SPEC 13.1: per-source derived paths are defined by the + * `NAME.mdx` name shape alone, so a not-yet-existing file's set is the + * observed suffix set under its own `DIR/NAME.xspec.` prefix. + */ +function transposeModuleCompanions( + observed: ObservedDerived, + fromSource: string, + toSource: string, +): readonly string[] { + const fromPrefix = moduleCompanionPrefix(fromSource); + const toPrefix = moduleCompanionPrefix(toSource); + return observed.moduleAndCompanions.map((path) => { + if (!path.startsWith(fromPrefix)) { + throw new Error( + `T6.6-5 staging self-check: ${path} must lie under ${fromPrefix}`, + ); + } + return `${toPrefix}${path.slice(fromPrefix.length)}`; + }); +} + +/** + * Assert a successful preview's delta content exactly (T6.6-5): findings + * `[]`, the success plan encoding, `delta` a plain two-direction value — an + * absent record is nothing-recorded, the empty-record success path (SPEC + * 6.6), never the 14.23 unavailability of T6.6-6 — and each direction equal + * to the expected path set in byte order. + */ +function assertDeltaContent( + report: PreviewReport, + expected: { + readonly generated: readonly string[]; + readonly removed: readonly string[]; + }, + context: string, +): void { + assertSameJson( + report.findings, + [], + `${context}: a preview whose real operation would proceed reports ` + + `findings [] (SPEC 6.6, 12.7) — a missing record is no finding: ` + + `condition 23 covers recorded state that exists but cannot be read ` + + `(SPEC 14.23, T6.6-6)`, + ); + if ( + report.mapping === null || + report.files === null || + report.delta === null + ) { + fail( + `${context}: a successful preview reports its plan — \`mapping\`, ` + + `\`files\`, and \`delta\` are null exactly on refusal (SPEC 6.6, ` + + `12.7); got mapping ${report.mapping === null ? "null" : "present"}, ` + + `files ${report.files === null ? "null" : "present"}, delta ` + + `${report.delta === null ? "null" : "present"}`, + ); + } + const delta = report.delta; + if ("unavailable" in delta) { + fail( + `${context}: the delta is explicitly unavailable only where recorded ` + + `state exists but cannot be read as a record (SPEC 14.23; T6.6-6's ` + + `staging) — an absent or empty record is the nothing-recorded ` + + `success path, reported as a plain two-direction value (SPEC 6.6)`, + ); + } + assertSameJson( + delta.generated, + expected.generated, + `${context}: \`generated\` — exactly the derived paths the operation ` + + `would newly generate, the paths where nothing is currently recorded ` + + `as generated, in byte order (SPEC 6.6, 12.7)`, + ); + assertSameJson( + delta.removed, + expected.removed, + `${context}: \`removed\` — exactly the recorded derived paths the ` + + `operation would leave no longer generated, in byte order (SPEC 6.6, ` + + `12.7)`, + ); +} + +const T6_6_5 = defineProductTest({ + id: "T6.6-5", + title: + "delta: after a build, a file-form move preview reports the derived-file delta both directions — under `generated` the destination's module, companion, and (emission enabled) Markdown paths, nothing being recorded there, and under `removed` the recorded pre-move module, companions, and Markdown the operation would leave no longer generated; a rename preview on the same workspace reports [] in both directions (regeneration rewrites recorded paths in place); the created-target move of T6.6-4(d) reports the new file's derived paths under `generated`; record-based, not presence-based: with graph data deleted (T13.3-2's operational definition) the same move preview's `generated` is the full post-move regeneration set and its `removed` [] — nothing being recorded, presence on disk deciding neither direction — and the preview still writes nothing: no refresh, graph data still absent afterward (SPEC 6.6, 12.7, 13.1, 13.2, 13.3, 7.3, 12.1; H-3, H-4)", + run: async (product) => { + // --- File-form move, rename, and the record-deleted arm: one + // Markdown-emitting workspace (arm (c)'s sources) --- + await withWorkspace( + SPECS_MD_CONFIG, + { + [C4_MV]: C4_MV_SOURCE, + [C4_PAL]: C4_PAL_SOURCE, + [C4_USER]: C4_USER_SOURCE, + }, + async (workspace) => { + const context = "T6.6-5 file-form move"; + const before = await snapshotDirectory(workspace.root); + await buildOk( + product, + workspace, + `${context}: staging premise \`build\` — it generates every ` + + `derived-file kind and records their paths (SPEC 12.1, 13.3)`, + ); + const after = await snapshotDirectory(workspace.root); + assertGraphDataPresent( + after, + `${context}: staging premise — the record the delta consults`, + ); + const observed = observeDerivedWrites( + addedFiles(before, after), + [C4_MV, C4_PAL, C4_USER], + true, + `${context} staging observation`, + ); + // `get` cannot miss: observeDerivedWrites maps exactly the sources. + const mv = observed.get(C4_MV)!; + const pal = observed.get(C4_PAL)!; + const user = observed.get(C4_USER)!; + + // The destination's derived paths — nothing recorded there — and + // the moved file's recorded paths, left no longer generated. + const destinationDerived = byteSortedPaths([ + ...transposeModuleCompanions(mv, C4_MV, F5_DEST), + markdownDestination(F5_DEST), + ]); + const originDerived = byteSortedPaths(derivedPathsOf(mv)); + + await assertLeavesUnchanged( + workspace.root, + async () => { + const armContext = `${context} (record present)`; + const report = await runPreviewJson( + product, + workspace, + C4_MOVE_ARGV, + armContext, + ); + assertDeltaContent( + report, + { generated: destinationDerived, removed: originDerived }, + armContext, + ); + }, + `${context} (record present): the preview modifies nothing ` + + `(SPEC 6.6)`, + ); + + // A rename preview on the same workspace: regeneration rewrites + // recorded paths in place, so both directions are [] (SPEC 6.6) — + // the cross-file `PAL.pal` rewrites change file contents, never a + // derived path. + await assertLeavesUnchanged( + workspace.root, + async () => { + const renameContext = `${context}, rename \`${F5_RENAME_ARGV.join(" ")}\``; + const report = await runPreviewJson( + product, + workspace, + F5_RENAME_ARGV, + renameContext, + ); + assertDeltaContent( + report, + { generated: [], removed: [] }, + renameContext, + ); + }, + `${context}, rename arm: the preview modifies nothing (SPEC 6.6)`, + ); + + // --- Record-based, not presence-based: graph data deleted --- + const deletedContext = `${context} (record deleted)`; + await deleteGraphData(workspace, deletedContext); + // With nothing recorded, every path the operation would generate is + // a path "where nothing is currently recorded as generated": the + // full post-move regeneration set — every post-move source's + // module, companions, and Markdown, the staying sources' present- + // on-disk files included (presence cannot tell a generated occupant + // from a foreign one, SPEC 6.6) — while `removed` is exactly []: + // the origin's still-on-disk files are recorded nowhere. + const fullRegenerationSet = byteSortedPaths([ + ...destinationDerived, + ...derivedPathsOf(pal), + ...derivedPathsOf(user), + ]); + await assertLeavesUnchanged( + workspace.root, + async () => { + const report = await runPreviewJson( + product, + workspace, + C4_MOVE_ARGV, + deletedContext, + ); + assertDeltaContent( + report, + { generated: fullRegenerationSet, removed: [] }, + deletedContext, + ); + }, + `${deletedContext}: the preview still writes nothing — no ` + + `refresh, no record rebuild (SPEC 6.6, 13.3)`, + ); + const postPreview = await snapshotDirectory(workspace.root); + for (const key of postPreview.entries.keys()) { + if (isGraphDataKey(key)) { + fail( + `${deletedContext}: graph data must still be absent after ` + + `the preview — a preview writes nothing and never ` + + `refreshes the record (SPEC 6.6, 13.3); found ` + + `${displaySnapshotPath(key)}`, + ); + } + } + }, + ); + + // --- The created-target move of T6.6-4(d), staged identically: the + // new file's derived paths under `generated` --- + await withWorkspace( + SPECS_ONLY_CONFIG, + { [D4_SOLO]: D4_SOLO_SOURCE }, + async (workspace) => { + const context = "T6.6-5 created-target move (T6.6-4(d)'s staging)"; + const before = await snapshotDirectory(workspace.root); + await buildOk( + product, + workspace, + `${context}: staging premise \`build\` (SPEC 6.5, 6.6)`, + ); + const after = await snapshotDirectory(workspace.root); + const observed = observeDerivedWrites( + addedFiles(before, after), + [D4_SOLO], + false, + `${context} staging observation`, + ); + const solo = observed.get(D4_SOLO)!; + // The created file's derived paths: the destination's module and + // companions — no Markdown component, emission being disabled + // (SPEC 7.3, 13.1). The origin file stays, its recorded paths + // regenerated in place, so `removed` is exactly []. + const madeDerived = byteSortedPaths( + transposeModuleCompanions(solo, D4_SOLO, D4_MADE), + ); + await assertLeavesUnchanged( + workspace.root, + async () => { + const report = await runPreviewJson( + product, + workspace, + D4_MOVE_ARGV, + context, + ); + assertDeltaContent( + report, + { generated: madeDerived, removed: [] }, + context, + ); + }, + `${context}: the preview modifies nothing (SPEC 6.6)`, + ); + }, + ); + }, +}); + export const section66Tests: readonly ProductTestEntry[] = [ T6_6_2, T6_6_3, T6_6_4, + T6_6_5, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 005d5e2..e57f357 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -218,6 +218,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.6-2": ["6.6"], "T6.6-3": ["6.6", "14"], "T6.6-4": ["6.6"], + "T6.6-5": ["6.6"], "T6.7-1": ["6.7"], "T7-1": ["7", "14"], "T7-2": ["7", "14"], From a524885c2c71d04d79f6931d111a0df5f0f48d36 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 22:36:27 +0000 Subject: [PATCH 077/226] =?UTF-8?q?sdg(phase-9):=20implement=20T6.6-6=20?= =?UTF-8?q?=E2=80=94=20preview=20under=20a=20corrupt=20graph=20record=20(F?= =?UTF-8?q?P-032)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New H-3 record-staging adapter (shape-blind garbage over T13.3-2's operational path set, product-written files only, loud otherwise; the isGraphDataKey predicate moves there, section-13.3 re-exports). T6.6-6: intact-record reference preview, then both corrupt-state previews in one whole-root compare — full plan with delta explicitly unavailable and the one located-nowhere unreadable-record finding (path .xspec), the refused identity-unchanged rename reporting its refusal alone — then the real move proceeding on the same state, applied mapping == previewed mapping, check clean after. S-5 gains the staging guards; traceability maps T6.6-6 to 6.6 + 14. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 65 ++++- test/helpers/adapters/index.ts | 4 + test/helpers/adapters/record-staging.ts | 171 +++++++++++ test/self/s5-output-adapters.test.ts | 145 ++++++++++ test/suite/registry/section-13.3.ts | 24 +- test/suite/registry/section-6.6.ts | 368 +++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 7 files changed, 759 insertions(+), 19 deletions(-) create mode 100644 test/helpers/adapters/record-staging.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index eaec82d..23a162f 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1266,13 +1266,76 @@ A "new test T<x>" task always means, in one change: reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys → stages E/G), S-5 and certification green.] -- [ ] FP-032 — Implement T6.6-6: preview under a corrupt graph record. +- [x] FP-032 — Implement T6.6-6: preview under a corrupt graph record. [R1 #11; TEST-SPEC §6.6, SPEC 14.23] Full preview with `delta` explicitly unavailable, a condition-23 finding (`unreadable-record`, concerned path the graph-data area), exit 1; the real operation proceeds; a refused preview reports refusal findings alone. Share the corrupt-record staging with FP-041 (T12.2-2's unreadable-record arm reuses it). + [Done 2026-08-11: T6.6-6 registered in section-6.6.ts. The corrupt-record + staging lives in the H-3 adapter layer per amended H-3's naming of + T6.6-6 — NEW test/helpers/adapters/record-staging.ts: + `corruptGraphDataShapeBlind` overwrites every product-written plain file + of T13.3-2's operational path set with fixed garbage (files stay present + but readable as no record — not even valid UTF-8), never creates a path, + and fails loudly with nothing modified on a missing area, an empty set, + or a non-plain-file occupant (SPEC 13.4); the operational predicate + isGraphDataKey moved there (one home), section-13.3.ts re-exporting it + for its existing importers (behavior-preserving: section-13.3 unchanged + 2 failed / 2 passed). FP-041/FP-044/FP-072 ("T6.6-6's staging") import + `corruptGraphDataShapeBlind` from the adapters index. One workspace + under the Markdown-emitting config, latitude-free section move + (self-contained subtree, no outside reference to a moved node → no + import edits, SPEC 6.5's one preview latitude): after the premise build + (record presence asserted), the intact-record reference preview runs in + its own modifies-nothing compare — exit 0, findings [], delta a plain + value, mapping pinned to the exact two-pair full-1.5-identity + expectation, files paths pinned to [Origin, Target] — then the record is + garbled and BOTH corrupt-state previews run inside ONE whole-root + compare (the corrupt state persists byte for byte): the move preview + exits 1 with exactly one 14.23 finding (stable code unreadable-record; + concerned path ".xspec" per 11.6; locations exactly [] — a + path-concerned condition is unlocated, T12.7-1's reading, module header) + and the full plan — mapping the exact expectation, files deep-equal to + the intact run (the "emitted in full" operationalization, sound because + the plan is latitude-free), delta the unavailability marker, never an + empty-record read; the refused preview (identity-unchanged rename) + reports exactly one refused-identity-unchanged finding — never a 14.23 + beside — with mapping/files/delta all null. Then the real move on the + same state: exit 0, applied mapping (T6.4-1's adapter) == the previewed + mapping, `check` exit 0 (the finishing regeneration replaced the corrupt + record, T12.2-2's protocol). S-5 gains three record-staging guards + (garbles-every-file positive control with durables/structure untouched + and the not-UTF-8 premise; nothing-to-corrupt rejections; non-plain-file + rejections, files untouched). Traceability "T6.6-6": ["6.6", "14"] + (TEST-SPEC 14 names T6.6-6 in 14.23's primary record); CERTIFICATIONS.md + keeps T6.6-6 in the Exclusions (shape-blind 14.23 stagings are + self-controlled — the condition-23 finding is the in-test reachability + control), no fixture change. Verified: red-as-diagnosed at the + intact-record reference preview (exit 2 "unknown flag '--preview'", the + whole 6.6 surface patch-new) with build and record-presence premises + green; staging soundness probed against the built product — build exit 0 + (record: .xspec/graph.json), check clean, and on the garbled record the + REAL move exits 0 with `check` clean afterward (the pre-patch machinery + already replaces the record) while the identity-unchanged rename refuses + exit 1 (old-shape document) leaving the garbage untouched; + satisfiability and teeth proven by running the registered body via a + scratch binding against a conforming shim (real-product delegation for + build/check/real move + synthetic 12.7 preview/applied-mapping answers + computed from the staged bytes, corruption detected shim-side) — green + end-to-end, the real product performing the actual move on the corrupt + record — and against six deviations: empty-record-read (red at the + exit-1 assertion), refused-consults-record (red at the + refusal-findings-alone count), preview-repairs (red at the + modifies-nothing compare), located-finding (red at the + no-path-inside-the-area locations assertion), incomplete-files (red at + the `files` complete equality), real-refuses (red at the + real-operation-proceeds exit). Typecheck/format clean; section-6.6 suite + 5 failed as diagnosed (T6.6-2..-5 keep their pre-existing reds); `npm + run test:self` unchanged 4 planned mid-loop reds (certification-document + ×3 → FP-091; S-1's 7 unmapped keys → stages G), S-5 (73 tests) and + certification green.] ## Stage F — §§10–14 missing arms diff --git a/test/helpers/adapters/index.ts b/test/helpers/adapters/index.ts index fc28509..398e180 100644 --- a/test/helpers/adapters/index.ts +++ b/test/helpers/adapters/index.ts @@ -17,6 +17,9 @@ // human.ts robust required-information matching on human reports // session-staging.ts T10.1-4 corruption transformations (shape-aware, // value-blind, over product-written session files) +// record-staging.ts T6.6-6's shape-blind corrupt-record staging (garbage +// over T13.3-2's operational path set, product-written +// files only), shared by the other 14.23 stagings // sorted-keys.ts T13.4-1 byte-sorted-keys assertion (shape/value-blind) // // The adapter modules are the only place aware of concrete output shape; they @@ -35,4 +38,5 @@ export * from "./operations.js"; export * from "./review.js"; export * from "./human.js"; export * from "./session-staging.js"; +export * from "./record-staging.js"; export * from "./sorted-keys.js"; diff --git a/test/helpers/adapters/record-staging.ts b/test/helpers/adapters/record-staging.ts new file mode 100644 index 0000000..57c90b6 --- /dev/null +++ b/test/helpers/adapters/record-staging.ts @@ -0,0 +1,171 @@ +// H-3 adapter layer — corrupt-record staging for T6.6-6 (TEST-SPEC §0 H-3, +// §6.6), shared by the other 14.23 stagings that reuse "T6.6-6's staging" +// (T12.2-2's unreadable-record arm, T13.3-2's record discipline, T11.6-4). +// +// Graph-data content is opaque (H-4) and its layout deliberately unenumerated +// (SPEC 13.3, 11.6), so the only shape knowledge that exists for the record +// is T13.3-2's operational path set: every path under `.xspec/` except the +// durable `.xspec/journal` and `.xspec/reviews/`. That predicate lives here +// (`isGraphDataKey`; the T13.3-2 machinery in +// test/suite/registry/section-13.3.ts re-exports it), and the corruption is +// shape-blind — TEST-SPEC T6.6-6: "truncation or garbage over T13.3-2's +// operational path set" — realized as a garbage overwrite of every +// product-written plain file in the set, staging "recorded state that exists +// but cannot be read as a record" (SPEC 14.23): the files stay present (an +// absent record is the different, nothing-recorded success path, T6.6-5) +// while their bytes can be read as no structured record at all (not even +// valid UTF-8). +// +// H-3 staging discipline (as T10.1-4's session-staging.ts): the +// transformation applies only to files the product itself wrote — it never +// creates a path, so the harness never fabricates a record file from an +// assumed layout — and fails loudly (diagnosed test error, nothing modified) +// when the workspace holds nothing to corrupt: no graph-data area, no +// graph-data file in it (the caller must run a successful `build` first), or +// a non-plain-file entry in the set (every file xspec writes is a plain file +// and its writes never traverse a symbolic link, SPEC 13.4 — such an +// occupant is not a product-written record file, and writing through it +// could escape the workspace). + +import { Buffer } from "node:buffer"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import { fail } from "../assertions.js"; + +/** + * The graph-data area: the location under which graph data is kept, spelled + * as its workspace-relative path with no trailing separator (SPEC 11.6) — + * the concerned path of every condition-23 finding and of the 14.10 unit + * form ("no path inside the area is named"). + */ +export const GRAPH_DATA_AREA_PATH = ".xspec"; + +/** + * Whether a workspace-relative, `/`-separated path is graph data: under + * `.xspec/`, excluding the durable `.xspec/journal` and `.xspec/reviews/` + * (SPEC 13.3, 13.4; TEST-SPEC T13.3-2's operational definition — the whole + * shape SPEC.md gives the record). One home for the predicate: the suite's + * graph-data machinery (section-13.3.ts) re-exports it. + */ +export function isGraphDataKey(key: string): boolean { + if (!key.startsWith(`${GRAPH_DATA_AREA_PATH}/`)) return false; + if (key === `${GRAPH_DATA_AREA_PATH}/journal`) return false; + if ( + key === `${GRAPH_DATA_AREA_PATH}/reviews` || + key.startsWith(`${GRAPH_DATA_AREA_PATH}/reviews/`) + ) { + return false; + } + return true; +} + +/** + * The deterministic garbage a corrupted record file holds: readable as no + * record — not one JSON document, not even valid UTF-8 (0xFF and 0xFE occur + * in no UTF-8 sequence; 0xC3 0x28 is a truncated one) — while the file stays + * present, so the staged state is "exists but cannot be read as a record" + * (SPEC 14.23), never the absent-record success path. + */ +export const RECORD_GARBAGE_BYTES: Uint8Array = Uint8Array.from([ + ...Buffer.from("xspec-harness: not a record ", "utf8"), + 0x00, + 0xff, + 0xfe, + 0xc3, + 0x28, +]); + +function stagingFail(context: string, problem: string): never { + fail( + `${context}: corrupt-record staging: ${problem}. H-3: the shape-blind ` + + `corruption applies only to record files the product itself wrote ` + + `(truncation or garbage over T13.3-2's operational path set) and ` + + `fails loudly otherwise — the harness never fabricates a record file ` + + `from an assumed layout. Nothing was modified.`, + ); +} + +/** Recursively collect the graph-data plain files under `rel` (see above). */ +async function collectGraphDataFiles( + rootAbs: string, + rel: string, + context: string, +): Promise<string[]> { + const collected: string[] = []; + const entries = await fsp.readdir(path.join(rootAbs, rel), { + withFileTypes: true, + }); + for (const entry of entries) { + const key = `${rel}/${entry.name}`; + // The durable journal and reviews paths are no part of the record + // (T13.3-2): skipped entirely, whatever occupies them. + if (!isGraphDataKey(key)) continue; + if (entry.isDirectory()) { + collected.push(...(await collectGraphDataFiles(rootAbs, key, context))); + } else if (entry.isFile()) { + collected.push(key); + } else { + stagingFail( + context, + `${key} is not a plain file or directory — every file xspec writes ` + + `is a plain file and its writes never traverse a symbolic link ` + + `(SPEC 13.4), so this occupant is not a product-written record ` + + `file and the harness will not write through it`, + ); + } + } + return collected; +} + +/** + * Corrupt the product-written graph data shape-blind (TEST-SPEC T6.6-6): + * overwrite every plain file of T13.3-2's operational path set — every path + * under `.xspec/` except the durable journal and reviews paths — with + * {@link RECORD_GARBAGE_BYTES}, leaving every path present (no path is + * created or removed; directories keep their structure). Fails loudly, with + * nothing modified, when the graph-data area is missing or not a real + * directory, when the set holds no plain file (nothing product-written to + * corrupt — run a successful `build` first), or when it holds a + * non-plain-file entry (SPEC 13.4). Returns the corrupted files' + * workspace-relative paths in byte order. + */ +export async function corruptGraphDataShapeBlind( + rootAbs: string, + context: string, +): Promise<readonly string[]> { + const areaAbs = path.join(rootAbs, GRAPH_DATA_AREA_PATH); + let areaStats; + try { + areaStats = await fsp.lstat(areaAbs); + } catch { + stagingFail( + context, + `no ${GRAPH_DATA_AREA_PATH} directory exists — the product has ` + + `written no graph data here (SPEC 13.3: xspec maintains graph data ` + + `under .xspec/)`, + ); + } + if (!areaStats.isDirectory()) { + stagingFail( + context, + `${GRAPH_DATA_AREA_PATH} is not a real directory — the graph-data ` + + `area the product writes is one (SPEC 13.3, 13.4)`, + ); + } + const files = ( + await collectGraphDataFiles(rootAbs, GRAPH_DATA_AREA_PATH, context) + ).sort(); + if (files.length === 0) { + stagingFail( + context, + `found no graph-data file to corrupt under ${GRAPH_DATA_AREA_PATH}/ ` + + `(outside the durable journal and reviews paths) — the corruption ` + + `applies to record files the product itself wrote, so run a ` + + `successful \`build\` first (SPEC 12.1, 13.3)`, + ); + } + for (const key of files) { + await fsp.writeFile(path.join(rootAbs, key), RECORD_GARBAGE_BYTES); + } + return files; +} diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index 9d94986..c249650 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -22,7 +22,9 @@ import { HarnessAssertionError } from "../helpers/assertions.js"; import type { RunResult } from "../helpers/subprocess.js"; import type { Finding } from "../helpers/adapters/index.js"; import { + GRAPH_DATA_AREA_PATH, ITEM_STATUSES, + RECORD_GARBAGE_BYTES, assertBareEdgeEndpoints, assertJsonKeysByteSorted, assertNodeEdgeListsBare, @@ -31,6 +33,7 @@ import { classifyIgnoredReasons, compareFindings, conditionMention, + corruptGraphDataShapeBlind, decodeAppliedMappingReport, decodeCoverageReport, decodeDatum, @@ -56,6 +59,7 @@ import { decodeSessionListReport, decodeSessionStatusReport, expectNonNegativeInteger, + isGraphDataKey, rootSite, stageBlockedByAbsentItem, stageBlockedByCycle, @@ -2911,6 +2915,147 @@ test("S-5: staging fails loudly on shape mismatch and leaves the file untouched" } }); +// --- T6.6-6 corrupt-record staging (record-staging.ts) ------------------------ +// Shape-blind by design (graph-data content is opaque, H-4): the staging's +// only shape knowledge is T13.3-2's operational path set, so the guards +// cover the H-3 discipline — product-written files only, never fabricated, +// loud with nothing modified when there is nothing to corrupt. + +test("S-5: corrupt-record staging garbles every graph-data file shape-blind, durables and structure untouched", async () => { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": "// outside the area — untouched", + ".xspec/journal": '{"op": 1}\n', + ".xspec/reviews/s1.json": '{"items": []}\n', + ".xspec/graph.json": '{"nodes": []}\n', + ".xspec/cache/part-b.bin": "bb", + ".xspec/cache/part-a.bin": "aa", + }, + }); + onTestFinished(() => workspace.dispose()); + const corrupted = await corruptGraphDataShapeBlind( + workspace.root, + "S-5 record staging", + ); + // Exactly the operational path set's plain files, byte-ordered — the + // durable journal and reviews paths are no part of the record (T13.3-2). + expect(corrupted).toEqual([ + ".xspec/cache/part-a.bin", + ".xspec/cache/part-b.bin", + ".xspec/graph.json", + ]); + for (const key of corrupted) { + const bytes = await workspace.readBytes(key); + expect( + Buffer.compare(Buffer.from(bytes), Buffer.from(RECORD_GARBAGE_BYTES)), + ).toBe(0); + expect(isGraphDataKey(key)).toBe(true); + } + // The staged state is "exists but cannot be read as a record" (SPEC + // 14.23): the files stay present while the garbage decodes as no UTF-8 + // text at all — so no structured read of any kind can succeed. + expect(() => + new TextDecoder("utf-8", { fatal: true }).decode(RECORD_GARBAGE_BYTES), + ).toThrow(); + // Durables and out-of-area files byte-untouched; directory structure + // kept; no path created or removed. + const utf8 = async (rel: string): Promise<string> => + Buffer.from(await workspace.readBytes(rel)).toString("utf8"); + expect(await utf8(".xspec/journal")).toBe('{"op": 1}\n'); + expect(await utf8(".xspec/reviews/s1.json")).toBe('{"items": []}\n'); + expect(await utf8("xspec.config.ts")).toBe("// outside the area — untouched"); + expect((await workspace.readdirNames(GRAPH_DATA_AREA_PATH)).sort()).toEqual([ + "cache", + "graph.json", + "journal", + "reviews", + ]); + expect((await workspace.readdirNames(".xspec/cache")).sort()).toEqual([ + "part-a.bin", + "part-b.bin", + ]); +}); + +test("S-5: corrupt-record staging fails loudly with nothing product-written to corrupt", async () => { + // No graph-data area at all: the product never wrote graph data here. + const bare = await TestWorkspace.create({ + files: { "xspec.config.ts": "// no build ran" }, + }); + onTestFinished(() => bare.dispose()); + const missing = await expectDiagnosedAsync("no .xspec directory", () => + corruptGraphDataShapeBlind(bare.root, "no .xspec directory"), + ); + expect(missing.message).toContain("corrupt-record staging"); + + // The area holds only the durable paths: nothing in the operational set. + const durablesOnly = await TestWorkspace.create({ + files: { + ".xspec/journal": "j\n", + ".xspec/reviews/s1.json": "{}", + }, + }); + onTestFinished(() => durablesOnly.dispose()); + const durablesFailure = await expectDiagnosedAsync("durables only", () => + corruptGraphDataShapeBlind(durablesOnly.root, "durables only"), + ); + expect(durablesFailure.message).toContain("no graph-data file"); + // Nothing modified: the durables keep their bytes. + expect( + Buffer.from(await durablesOnly.readBytes(".xspec/journal")).toString( + "utf8", + ), + ).toBe("j\n"); + expect( + Buffer.from( + await durablesOnly.readBytes(".xspec/reviews/s1.json"), + ).toString("utf8"), + ).toBe("{}"); + + // A directory alone is no record file either. + const dirOnly = await TestWorkspace.create({ dirs: [".xspec/cache"] }); + onTestFinished(() => dirOnly.dispose()); + const dirFailure = await expectDiagnosedAsync("empty directory only", () => + corruptGraphDataShapeBlind(dirOnly.root, "empty directory only"), + ); + expect(dirFailure.message).toContain("no graph-data file"); +}); + +test("S-5: corrupt-record staging fails loudly on non-plain-file occupants, files untouched", async () => { + // A symbolic link inside the operational set: not a product-written + // record file (SPEC 13.4) — refuse, and touch nothing, the plain file + // beside it included. + const linked = await TestWorkspace.create({ + files: { ".xspec/graph.json": '{"nodes": []}' }, + symlinks: { ".xspec/link.json": "graph.json" }, + }); + onTestFinished(() => linked.dispose()); + const linkFailure = await expectDiagnosedAsync("symlink in the set", () => + corruptGraphDataShapeBlind(linked.root, "symlink in the set"), + ); + expect(linkFailure.message).toContain("corrupt-record staging"); + expect(linkFailure.message).toContain(".xspec/link.json"); + expect( + Buffer.from(await linked.readBytes(".xspec/graph.json")).toString("utf8"), + ).toBe('{"nodes": []}'); + + // The area itself occupied by a symlink: not the directory the product + // writes — refuse, and write nothing through it. + const areaLink = await TestWorkspace.create({ + files: { "real-area/graph.json": '{"nodes": []}' }, + symlinks: { ".xspec": "real-area" }, + }); + onTestFinished(() => areaLink.dispose()); + const areaFailure = await expectDiagnosedAsync(".xspec is a symlink", () => + corruptGraphDataShapeBlind(areaLink.root, ".xspec is a symlink"), + ); + expect(areaFailure.message).toContain("not a real directory"); + expect( + Buffer.from(await areaLink.readBytes("real-area/graph.json")).toString( + "utf8", + ), + ).toBe('{"nodes": []}'); +}); + // --- T13.4-1 sorted-keys assertion -------------------------------------------- test("S-5: sorted-keys assertion accepts byte-sorted documents of any shape", () => { diff --git a/test/suite/registry/section-13.3.ts b/test/suite/registry/section-13.3.ts index 3fa5c93..5da715a 100644 --- a/test/suite/registry/section-13.3.ts +++ b/test/suite/registry/section-13.3.ts @@ -67,6 +67,7 @@ import { decodeNodeRowsReport, decodeSessionListReport, decodeSessionStatusReport, + isGraphDataKey, } from "../../helpers/adapters/index.js"; import { assertBytesEqual, @@ -139,21 +140,14 @@ async function withWorkspace<T>( // Graph-data machinery (the T13.3-2 operational definition) // --------------------------------------------------------------------------- -/** - * Whether a snapshot key (a `/`-separated workspace-relative path) is graph - * data: under `.xspec/`, excluding the durable `.xspec/journal` and - * `.xspec/reviews/` (SPEC 13.3, 13.4; TEST-SPEC T13.3-2). Exported: T6.6-5 - * (section-6.6.ts) shares this operational definition — TEST-SPEC's "graph - * data deleted (T13.3-2's operational definition)". - */ -export function isGraphDataKey(key: string): boolean { - if (!key.startsWith(".xspec/")) return false; - if (key === ".xspec/journal") return false; - if (key === ".xspec/reviews" || key.startsWith(".xspec/reviews/")) { - return false; - } - return true; -} +// Whether a snapshot key (a `/`-separated workspace-relative path) is graph +// data: under `.xspec/`, excluding the durable `.xspec/journal` and +// `.xspec/reviews/` (SPEC 13.3, 13.4; TEST-SPEC T13.3-2). The predicate's +// home is the H-3 adapter layer (record-staging.ts, whose shape-blind +// corruption shares the operational path set); re-exported here for the +// suite modules sharing T13.3-2's operational definition — T6.6-5's +// record-deleted arm and T6.6-6's corrupt-record staging (section-6.6.ts). +export { isGraphDataKey }; /** The entries of a snapshot whose keys satisfy `keep`. */ function filteredEntries( diff --git a/test/suite/registry/section-6.6.ts b/test/suite/registry/section-6.6.ts index 49fc619..40b5f39 100644 --- a/test/suite/registry/section-6.6.ts +++ b/test/suite/registry/section-6.6.ts @@ -1,6 +1,5 @@ -// TEST-SPEC §6.6 (previews) — SUITE-24: T6.6-2, T6.6-3, T6.6-4, T6.6-5. -// (T6.6-1 is retired; T6.6-6 is staged by a later plan task into this -// module.) +// TEST-SPEC §6.6 (previews) — SUITE-24: T6.6-2, T6.6-3, T6.6-4, T6.6-5, +// T6.6-6. (T6.6-1 is retired.) // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -147,6 +146,38 @@ // but cannot be read — and the preview never refreshes it (whole-root // compare around the invocation; graph data asserted still absent // afterward). +// - T6.6-6 stages the unreadable record through the H-3 corrupt-record +// adapter (record-staging.ts): shape-blind garbage — files present, their +// bytes readable as no record, not even valid UTF-8 — over every +// product-written plain file of T13.3-2's operational path set, applied +// only after the premise `build` wrote them (never fabricated, H-3); the +// staging's reachability is positively controlled by the condition-23 +// finding the arm itself asserts (CERTIFICATIONS.md's Exclusions note on +// the shape-blind 14.23 stagings). "The full preview — `mapping` and +// `files` complete … every other part of the preview report emitted in +// full" (SPEC 14.23, TEST-SPEC T6.6-6) is operationalized as deep +// equality against the intact-record run of the same preview first, on +// the byte-identical sources: the staging is latitude-free — the moved +// subtree is self-contained and nothing outside it references a moved +// node, so the plan holds no import addition (SPEC 6.5's one preview +// latitude) and is fully determined by sources + operation — with the +// exact expected mapping pinned on both runs and re-pinned as the real +// run's applied mapping (the preview's `mapping` IS the complete identity +// mapping the operation then journals, SPEC 6.6). The condition-23 +// finding's `locations` are asserted exactly []: 14.23's concern is the +// concerned-path member — the graph-data area, `.xspec` spelled +// workspace-relative with no trailing separator (SPEC 11.6), no path +// inside it named (the record's layout is deliberately unenumerated, +// 13.3) — and a path-concerned condition is an unlocated one (T12.7-1: +// `locations` [] for unlocated conditions, `path` the concerned path). +// Both corrupt-state previews — the full move preview and the refused +// identity-unchanged rename preview — run inside ONE whole-root +// modifies-nothing compare (a preview writes nothing and never refreshes +// the record, SPEC 6.6/13.3, so the corrupt state persists byte for +// byte), which makes the subsequent real move run on literally "the same +// state"; its exit-0 success, applied mapping, and `check` exit 0 realize +// "not refused — it proceeds, its finishing regeneration replacing the +// corrupt record" (SPEC 6.6, 6.4, 14.10; T12.2-2's protocol). import { Buffer } from "node:buffer"; import { defineProductTest } from "../../helpers/registry.js"; @@ -154,12 +185,16 @@ import type { ProductTestEntry } from "../../helpers/registry.js"; import type { AppliedMappingPair, Finding, + PreviewDeltaDatum, PreviewEdit, PreviewEditClass, + PreviewFileEntry, PreviewReport, SourceRange, } from "../../helpers/adapters/index.js"; import { + GRAPH_DATA_AREA_PATH, + corruptGraphDataShapeBlind, decodeAppliedMappingReport, decodeFindingsReport, decodePreviewReport, @@ -238,6 +273,7 @@ import { import { assertAppliedMapping, assertConditionCounts, + assertFindingConcernsPath, assertSameJson, buildFindings, buildOk, @@ -2820,9 +2856,335 @@ const T6_6_5 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T6.6-6 — unreadable record +// --------------------------------------------------------------------------- + +// Staging (module header, H-4): a section-form move whose plan is +// latitude-free — the moved subtree `org.mv` is self-contained (its one +// internal `d` reference points down at its own child and moves with it, +// SPEC 5.3-acyclic) and nothing outside the subtree references a moved node, +// so the move adds and removes no import anywhere (SPEC 6.5) and the +// previewed plan is fully determined by sources + operation: origin deletion +// with the re-identification and reference rewrites nested inside it, and +// the target insertion. `tm` is top-level (no target parent needed) and +// collides with nothing in Target.mdx; the whole move is unambiguously +// valid, so the preview's only imperfection under the corrupt record is the +// record itself (SPEC 14.23). +const R6_ORIGIN = "specs/Origin.mdx"; +const R6_TARGET = "specs/Target.mdx"; +const R6_ORIGIN_SOURCE = [ + '<S id="org">', + "Origin holder text.", + "", + '<S id="org.mv" d={"org.mv.k1"}>', + "Moved root text.", + "", + '<S id="org.mv.k1">', + "Moved kid.", + "</S>", + "</S>", + "</S>", + "", +].join("\n"); +const R6_TARGET_SOURCE = ['<S id="tgt">', "Target text.", "</S>", ""].join( + "\n", +); +const R6_MOVE_ARGV = [ + "move", + `${R6_ORIGIN}#org.mv`, + `${R6_TARGET}#tm`, +] as const; +// The refused preview staged on the same corrupt-record state: an +// identity-unchanged rename collides with nothing and reports +// `refused-identity-unchanged` alone (SPEC 6.4). +const R6_RENAME_SAME_ARGV = ["rename", R6_ORIGIN, "org", "org"] as const; +// The complete identity mapping the move journals — the moved ID and its +// descendant, prefix-replaced, in full 1.5 identity form, `from`-byte +// ordered (SPEC 6.4, 6.5, 12.7). +const R6_EXPECTED_MAPPING: readonly AppliedMappingPair[] = [ + { from: `${R6_ORIGIN}#org.mv`, to: `${R6_TARGET}#tm` }, + { from: `${R6_ORIGIN}#org.mv.k1`, to: `${R6_TARGET}#tm.k1` }, +]; + +/** + * A successful preview's plan members, non-null — `mapping`, `files`, and + * `delta` are `null` exactly on refusal, all together (SPEC 6.6, 12.7; the + * decode already rejects mixed nullity). + */ +function requirePreviewPlan( + report: PreviewReport, + context: string, +): { + readonly mapping: readonly AppliedMappingPair[]; + readonly files: readonly PreviewFileEntry[]; + readonly delta: PreviewDeltaDatum; +} { + const { mapping, files, delta } = report; + if (mapping === null || files === null || delta === null) { + fail( + `${context}: the preview emits its full plan — \`mapping\`, ` + + `\`files\`, and \`delta\` are null exactly on refusal (SPEC 6.6, ` + + `12.7); got mapping ${mapping === null ? "null" : "present"}, ` + + `files ${files === null ? "null" : "present"}, delta ` + + `${delta === null ? "null" : "present"}`, + ); + } + return { mapping, files, delta }; +} + +const T6_6_6 = defineProductTest({ + id: "T6.6-6", + title: + "unreadable record: with the product-written graph data corrupted shape-blind (garbage over T13.3-2's operational path set; H-3 record-staging adapter), a move `--preview` whose plan is otherwise valid exits 1 emitting the full preview — `mapping` and `files` complete: the exact journaled mapping, the files deep-equal to the intact-record run on the identical sources — with `delta` explicitly unavailable as one datum, never read as an empty record, and the condition-23 finding (`unreadable-record`, concerned path the graph-data area `.xspec`, no path inside it named: locations []) in `findings`; the real operation on the same state is not refused — it proceeds, its applied mapping the previewed mapping, its finishing regeneration replacing the corrupt record (`check` clean afterward, T12.2-2) — and a refused preview staged on the same corrupt-record state (an identity-unchanged rename) reports the refusal finding alone with `mapping`/`files`/`delta` null, never a condition-23 finding (SPEC 6.6, 6.4, 6.5, 14.23, 14.10, 11.6, 12.0, 12.7, 13.3; H-3, H-4)", + run: async (product) => { + await withWorkspace( + SPECS_MD_CONFIG, + { [R6_ORIGIN]: R6_ORIGIN_SOURCE, [R6_TARGET]: R6_TARGET_SOURCE }, + async (workspace) => { + const context = "T6.6-6"; + // Premise: the record under corruption is one the product itself + // wrote (H-3) — the staged workspace builds and graph data exists. + await buildOk( + product, + workspace, + `${context}: staging premise \`build\` — it writes the record the ` + + `corruption then applies to (SPEC 12.1, 13.3; H-3)`, + ); + assertGraphDataPresent( + await snapshotDirectory(workspace.root), + `${context}: staging premise — the product-written record exists`, + ); + + // Intact-record reference run of the same preview: exit 0, findings + // [], delta a plain value — pinning the plan the corrupt-state run + // must still emit in full. Wrapped in its own modifies-nothing + // compare, so the two runs' inputs differ in the record bytes alone. + const intactContext = `${context} (record intact)`; + const intactReport = await assertLeavesUnchanged( + workspace.root, + async () => + await runPreviewJson( + product, + workspace, + R6_MOVE_ARGV, + intactContext, + ), + `${intactContext}: the preview modifies nothing (SPEC 6.6)`, + ); + assertSameJson( + intactReport.findings, + [], + `${intactContext}: on a readable record, this valid move's ` + + `preview reports findings [] (SPEC 6.6, 12.7)`, + ); + const intact = requirePreviewPlan(intactReport, intactContext); + if ("unavailable" in intact.delta) { + fail( + `${intactContext}: with the record readable, the delta is the ` + + `plain two-direction value — unavailability covers recorded ` + + `state that exists but cannot be read (SPEC 6.6, 14.23)`, + ); + } + assertSameJson( + intact.mapping, + R6_EXPECTED_MAPPING, + `${intactContext}: staging premise — the previewed plan maps ` + + `exactly the moved subtree, prefix-replaced, in full 1.5 ` + + `identity form (SPEC 6.4, 6.5, 6.6, 12.7)`, + ); + assertSameJson( + intact.files.map((entry) => entry.file), + [R6_ORIGIN, R6_TARGET], + `${intactContext}: staging premise — the plan rewrites the origin ` + + `and the target file (SPEC 6.5, 6.6, 12.7), so the ` + + `completeness equality on the corrupt-record run has content`, + ); + + // Corrupt the product-written record shape-blind (TEST-SPEC + // T6.6-6; H-3 adapter — garbage over T13.3-2's operational path + // set, files present but readable as no record). + await corruptGraphDataShapeBlind( + workspace.root, + `${context}: corrupt-record staging`, + ); + + // Both corrupt-state previews inside ONE whole-root compare: a + // preview writes nothing and never refreshes the record (SPEC 6.6, + // 13.3), so the corrupt state persists byte for byte and the real + // operation below runs on the same state. + await assertLeavesUnchanged( + workspace.root, + async () => { + // (1) The move preview: full plan, delta explicitly + // unavailable, the condition-23 finding, exit 1 (SPEC 14.23). + const corruptContext = `${context} (record corrupt), move preview`; + const argv = [...R6_MOVE_ARGV, "--preview", "--json"]; + const result = await expectExit( + product, + workspace, + argv, + 1, + `${corruptContext}: \`${argv.join(" ")}\` — an answer ` + + `carrying a finding and explicitly-unavailable data exits ` + + `1, the full answer still emitted (SPEC 14.23, 12.0)`, + ); + const report = decodePreviewReport( + parseJsonStdout( + result, + `${corruptContext}: a single JSON document as the entire ` + + `stdout (SPEC 12.0)`, + ), + corruptContext, + ); + assertConditionCounts( + report.findings, + { "14.23": 1 }, + `${corruptContext}: exactly the one condition-23 finding ` + + `(stable code unreadable-record) accompanies the answer — ` + + `the workspace is otherwise clean (SPEC 14.23, 14)`, + ); + const finding = report.findings[0]!; + assertFindingConcernsPath( + finding, + GRAPH_DATA_AREA_PATH, + `${corruptContext}: the concerned path is the graph-data ` + + `area — the .xspec directory spelled as its ` + + `workspace-relative path, no trailing separator (SPEC ` + + `14.23, 11.6)`, + ); + assertSameJson( + finding.locations, + [], + `${corruptContext}: no path inside the area is named — the ` + + `record's layout is deliberately unenumerated (SPEC 14.23, ` + + `13.3), and a path-concerned condition is unlocated: ` + + `locations [] (SPEC 12.7; T12.7-1)`, + ); + const plan = requirePreviewPlan(report, corruptContext); + assertSameJson( + plan.mapping, + R6_EXPECTED_MAPPING, + `${corruptContext}: \`mapping\` complete — the complete ` + + `identity mapping the operation would journal, exactly as ` + + `on the readable record (SPEC 6.6, 14.23)`, + ); + assertSameJson( + plan.files, + intact.files, + `${corruptContext}: \`files\` complete — every other part of ` + + `the preview report is emitted in full, equal to the ` + + `intact-record run on these byte-identical sources (the ` + + `plan holds no import addition, so no 6.5 latitude can ` + + `differ between the runs) (SPEC 14.23, 6.6)`, + ); + if (!("unavailable" in plan.delta)) { + fail( + `${corruptContext}: the record-supplied datum — the delta ` + + `— is reported explicitly unavailable as one datum, ` + + `never fabricated and never read as an empty record ` + + `(SPEC 14.23, 6.6, 12.7); got ` + + `${JSON.stringify(plan.delta)}`, + ); + } + + // (2) A refused preview staged on the same corrupt-record + // state: the identity-unchanged rename reports its refusal + // finding alone — a refused preview consults no record, so no + // condition-23 finding ever accompanies it (SPEC 6.6, 6.4). + const refusedContext = `${context} (record corrupt), refused rename preview`; + const refusedArgv = [...R6_RENAME_SAME_ARGV, "--preview", "--json"]; + const refused = await expectExit( + product, + workspace, + refusedArgv, + 1, + `${refusedContext}: \`${refusedArgv.join(" ")}\` — an ` + + `identity-unchanged rename is refused, previewed exactly ` + + `as real (SPEC 6.4, 6.6, 12.0)`, + ); + const refusedReport = decodePreviewReport( + parseJsonStdout( + refused, + `${refusedContext}: a single JSON document as the entire ` + + `stdout (SPEC 12.0)`, + ), + refusedContext, + ); + assertConditionCounts( + refusedReport.findings, + { "refused-identity-unchanged": 1 }, + `${refusedContext}: the refusal findings alone — ` + + `refused-identity-unchanged and nothing beside it, never a ` + + `condition-23 finding: a refused preview consults no ` + + `record (SPEC 6.4 "reports refused-identity-unchanged ` + + `alone", 6.6, 14)`, + ); + if ( + refusedReport.mapping !== null || + refusedReport.files !== null || + refusedReport.delta !== null + ) { + fail( + `${refusedContext}: a refused preview's \`mapping\`, ` + + `\`files\`, and \`delta\` are null (SPEC 6.6, 12.7); got ` + + `mapping ` + + `${refusedReport.mapping === null ? "null" : "present"}, ` + + `files ` + + `${refusedReport.files === null ? "null" : "present"}, ` + + `delta ` + + `${refusedReport.delta === null ? "null" : "present"}`, + ); + } + }, + `${context} (record corrupt): the previews modify nothing — no ` + + `sources, no journal, no derived files, no graph data: the ` + + `corrupt record is not repaired, replaced, or removed, so the ` + + `real operation below runs on the same state (SPEC 6.6, 13.3)`, + ); + + // The real operation on the same corrupt-record state is not + // refused — a corrupt record fails no build validation, so the + // unreadable record lies on the success side of the refusal + // equivalence (SPEC 6.6) — and its finishing regeneration replaces + // the corrupt record (SPEC 6.4, 6.5). + const realContext = `${context} (record corrupt), real move`; + const realArgv = [...R6_MOVE_ARGV, "--json"]; + const applied = decodeAppliedMappingReport( + await runJson( + product, + workspace, + realArgv, + `${realContext}: \`${realArgv.join(" ")}\` — the real ` + + `operation proceeds, exit 0 (SPEC 6.6, 6.5, 12.0)`, + ), + realContext, + ); + assertAppliedMapping( + applied, + R6_EXPECTED_MAPPING, + `${realContext}: the applied mapping is the previewed mapping — ` + + `the corrupt-state preview reported the complete identity ` + + `mapping the operation has now journaled (SPEC 6.4, 6.5, 6.6)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context}: after the real move, \`check\` is clean — the ` + + `finishing regeneration replaced the corrupt record and left ` + + `no stale output (SPEC 6.4, 12.1, 14.10, 14.23; T12.2-2)`, + ); + }, + ); + }, +}); + export const section66Tests: readonly ProductTestEntry[] = [ T6_6_2, T6_6_3, T6_6_4, T6_6_5, + T6_6_6, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index e57f357..445d456 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -219,6 +219,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.6-3": ["6.6", "14"], "T6.6-4": ["6.6"], "T6.6-5": ["6.6"], + "T6.6-6": ["6.6", "14"], "T6.7-1": ["6.7"], "T7-1": ["7", "14"], "T7-2": ["7", "14"], From 7155de4f2e35bedb1115da04391d64094e3842f9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 22:54:06 +0000 Subject: [PATCH 078/226] sdg(phase-9): implement T10.1-4 malformed-recorded-decompositions arm (FP-033) New corrupt-state arm: the product performs a review split (its decomposition recorded durably, SPEC 10.7; original id pinned gone), then the recorded decompositions member is garbled by the new shape-aware, value-blind stageGarbleDecompositions transformation (session-staging.ts; creation-parameters garble refactored into a shared structural-type-flip) and the full corrupt-session contract is asserted. S-5 gains the decompositions staging guards (74 tests, green). Suite section-10.1 unchanged 3 failed / 1 passed (pre-existing FP-001/FP-002-class product gaps); test:self unchanged 4 planned mid-loop reds; arm soundness proven by direct probes against the built product. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 34 +++++- test/helpers/adapters/session-staging.ts | 65 ++++++++--- test/self/s5-output-adapters.test.ts | 30 +++++ test/suite/registry/section-10.1.ts | 133 +++++++++++++++++++---- 4 files changed, 224 insertions(+), 38 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 23a162f..ca9a0d6 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1339,9 +1339,41 @@ A "new test T<x>" task always means, in one change: ## Stage F — §§10–14 missing arms -- [ ] FP-033 — T10.1-4: stage the "malformed recorded decompositions" +- [x] FP-033 — T10.1-4: stage the "malformed recorded decompositions" corrupt-state arm. [R2 #20; TEST-SPEC §10.1] `test/suite/registry/section-10.1.ts`. + [Done 2026-08-11: new arm between the creation-parameters and + garbage-bytes states — build, create an audit session, select the + subtree-coherence item scoped at `specs/A.mdx#a` (the one section with a + child) from the decoded status report, have the PRODUCT perform `review + split` on it (exit 0; post-split premise pins the original id gone — + removed and never reused, SPEC 10.7 — so a decomposition is genuinely + recorded durably), capture a current item id, then garble the recorded + decompositions via the new H-3 transformation + `stageGarbleDecompositions` (session-staging.ts: SESSION_SHAPE gains + `decompositionsKey`; the creation-parameters garble refactored into a + shared `garbleRecordedMember` structural-type-flip — shape-aware, + value-blind, fail-loud, staged file stays one JSON document) and run the + full `assertCorruptSessionContract` (six naming subcommands exit 1 + + /corrupt/i + modifies-nothing; `list` corrupt-in-place exit 1; `check` + 14.21). S-5 gains the decompositions guards (type-flip positive control + both directions with the rest of the session untouched; stays-parseable + loop entry; no-member rejection): 73 → 74 tests, green. Soundness proven + by direct probes against the built product (the suite arm is unreached: + T10.1-4 stays red-as-diagnosed at the FIRST adapter state's `check + --json` form-exact findings decode, the pre-existing FP-001-class + product gap — "expected no member \"condition\""): split records + `{"kind","scope"}` under `decompositions`; on the garbled state all six + naming subcommands exit 1 mentioning corrupt, `list --json` exit 1 with + `[{cor, corrupt: true}]`, `check --json` exit 1 with a 14.21 finding + (old shape), and the whole tree is byte-unchanged across every contract + command. Section-10.1 unchanged 3 failed / 1 passed (T10.1-2/-3 keep + their pre-existing FP-002-class exit-2 error-document reds; T10.1-1 + green). Typecheck/format clean; `npm run test:self` unchanged 4 planned + mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped + keys → stage G), S-5 and certification green. Traceability unchanged + (["10.1", "14"] — 14.21's primary record already rides "14"); T10.1-4 + in no certification scope.] - [ ] FP-034 — T10.4-2: add the non-scope presence recordings. [R2 #21; TEST-SPEC §10.4] diff --git a/test/helpers/adapters/session-staging.ts b/test/helpers/adapters/session-staging.ts index c53a39a..88c3c83 100644 --- a/test/helpers/adapters/session-staging.ts +++ b/test/helpers/adapters/session-staging.ts @@ -4,15 +4,16 @@ // the product itself wrote and is corrupted here — the one place aware of the // stored session's concrete shape. The transformations are shape-aware and // value-blind: they locate structure (the item list, an item's id, status, -// blockedBy, the recorded creation parameters), never inspect what the values -// are, and fail loudly (diagnosed test error, file untouched) when the shape -// does not match. The harness never writes a session file from an assumed +// blockedBy, the recorded creation parameters and decompositions), never +// inspect what the values are, and fail loudly (diagnosed test error, file +// untouched) when the shape does not match. The harness never writes a session file from an assumed // layout — shape-independent corrupt states (unparseable bytes, truncation, a // directory or symlink at the path) are staged directly by the tests, not // here. // // ASSUMED STORED-SESSION SHAPE (adjustable per H-3, values never): -// { ..., "creationParameters": <recorded>, ..., +// { ..., "creationParameters": <recorded>, "decompositions": <recorded>, +// ..., // "items": [ { "id": string, "status": string, "blockedBy": [id...], // ...per-item fields... }, ... ], ... } // @@ -33,6 +34,7 @@ const SESSION_SHAPE = { statusKey: "status", blockedByKey: "blockedBy", creationParametersKey: "creationParameters", + decompositionsKey: "decompositions", } as const; interface LoadedSession { @@ -296,23 +298,20 @@ export async function stageDeleteItemField( } /** - * T10.1-4 "malformed recorded creation parameters": garble the recorded - * creation parameters by replacing them with a value of a different JSON - * structural type (value-blind: only the stored value's type is examined, so - * the replacement is malformed whatever the recorded content was — a garbage - * *string* where a string is stored could still parse as merely unresolvable, - * which is a different, exit-2 state, T10.7-3). + * Garble a recorded top-level session member by replacing it with a value of + * a different JSON structural type (value-blind: only the stored value's type + * is examined, so the replacement is malformed whatever the recorded content + * was — a garbage *string* where a string is stored could still parse as + * merely unresolvable, which is a different, exit-2 state, T10.7-3). */ -export async function stageGarbleCreationParameters( +async function garbleRecordedMember( absPath: string, + key: string, + what: string, ): Promise<void> { const loaded = await loadSession(absPath); - const key = SESSION_SHAPE.creationParametersKey; if (!Object.hasOwn(loaded.doc, key)) { - shapeFail( - absPath, - `expected a "${key}" member holding the recorded creation parameters`, - ); + shapeFail(absPath, `expected a "${key}" member holding the ${what}`); } const stored = loaded.doc[key]; loaded.doc[key] = @@ -321,3 +320,37 @@ export async function stageGarbleCreationParameters( : { "xspec-harness-garbled": true }; await writeSession(absPath, loaded.doc); } + +/** + * T10.1-4 "malformed recorded creation parameters": garble the recorded + * creation parameters by structural type flip (see + * {@link garbleRecordedMember}). + */ +export async function stageGarbleCreationParameters( + absPath: string, +): Promise<void> { + await garbleRecordedMember( + absPath, + SESSION_SHAPE.creationParametersKey, + "recorded creation parameters", + ); +} + +/** + * T10.1-4 "malformed recorded decompositions": garble the recorded + * decompositions (SPEC 10.7: a `split`'s decomposition — the original's kind + * and scope node — is recorded durably in the session and governs + * re-derivation) by structural type flip (see {@link garbleRecordedMember}). + * Staged over a session in which the product itself performed a `split`, so + * a decomposition is genuinely recorded (T10.1-4's staging discipline: the + * corrupted file starts as one the product wrote). + */ +export async function stageGarbleDecompositions( + absPath: string, +): Promise<void> { + await garbleRecordedMember( + absPath, + SESSION_SHAPE.decompositionsKey, + "recorded decompositions", + ); +} diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index c249650..41276b3 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -66,6 +66,7 @@ import { stageDeleteItemField, stageDuplicateItemEntry, stageGarbleCreationParameters, + stageGarbleDecompositions, stageUnknownItemStatus, } from "../helpers/adapters/index.js"; import { TestWorkspace } from "../helpers/workspace.js"; @@ -2664,6 +2665,7 @@ const SESSION_REL = ".xspec/reviews/s.json"; /** A synthetic well-shaped stored session (per the layer's assumed shape). */ const WELL_SHAPED_SESSION = { creationParameters: { strategy: "audit" }, + decompositions: [{ kind: "subtree-coherence", scope: "specs/A.mdx#a" }], items: [ { blockedBy: [], @@ -2791,6 +2793,28 @@ test("S-5: staging garbles recorded creation parameters by structural type flip" expect(flippedToObject["creationParameters"]).not.toBeNull(); }); +test("S-5: staging garbles recorded decompositions by structural type flip", async () => { + // The natural recorded form is an array (`typeof [] === "object"`), so the + // flip lands on a scalar; the rest of the session is untouched. + const arrayRecorded = await sessionWorkspace(WELL_SHAPED_SESSION); + await stageGarbleDecompositions(arrayRecorded.file); + const flippedToScalar = await arrayRecorded.read(); + expect(typeof flippedToScalar["decompositions"]).toBe("string"); + expect(flippedToScalar["creationParameters"]).toEqual( + WELL_SHAPED_SESSION.creationParameters, + ); + expect(itemsOf(flippedToScalar)).toEqual(WELL_SHAPED_SESSION.items); + + const scalarRecorded = await sessionWorkspace({ + ...WELL_SHAPED_SESSION, + decompositions: "abc123", + }); + await stageGarbleDecompositions(scalarRecorded.file); + const flippedToObject = await scalarRecorded.read(); + expect(typeof flippedToObject["decompositions"]).toBe("object"); + expect(flippedToObject["decompositions"]).not.toBeNull(); +}); + test("S-5: every staged corruption leaves the file one well-formed JSON document", async () => { // Unparseable bytes are a separate, shape-independent corrupt state staged // directly by tests — these transformations must each inject exactly their @@ -2802,6 +2826,7 @@ test("S-5: every staged corruption leaves the file one well-formed JSON document stageBlockedByAbsentItem, (file: string) => stageDeleteItemField(file, "kind"), stageGarbleCreationParameters, + stageGarbleDecompositions, ]) { const { file, read } = await sessionWorkspace(WELL_SHAPED_SESSION); await stage(file); @@ -2889,6 +2914,11 @@ const STAGING_REJECTIONS: readonly StagingRejection[] = [ contents: '{"items": []}', stage: stageGarbleCreationParameters, }, + { + label: "no decompositions member to garble", + contents: '{"items": []}', + stage: stageGarbleDecompositions, + }, ]; test("S-5: staging fails loudly on shape mismatch and leaves the file untouched", async () => { diff --git a/test/suite/registry/section-10.1.ts b/test/suite/registry/section-10.1.ts index fd3f8b2..1dd0d46 100644 --- a/test/suite/registry/section-10.1.ts +++ b/test/suite/registry/section-10.1.ts @@ -47,8 +47,12 @@ // staged directly. The harness never writes a session file from an assumed // layout. The malformed-creation-parameters state uses a `coverage` session: // it records the profile's resolved definition, where an `audit` session -// records none (SPEC 10.7), so there is a recorded value to garble. +// records none (SPEC 10.7), so there is a recorded value to garble. The +// malformed-recorded-decompositions state first has the product perform a +// `split` — the decomposition is recorded durably in the session (SPEC 10.7) +// — so the garbled member holds a genuine product-recorded decomposition. +import type { SessionStatusRow } from "../../helpers/adapters/index.js"; import { assertReportMentions, decodeFindingsReport, @@ -59,6 +63,7 @@ import { stageDeleteItemField, stageDuplicateItemEntry, stageGarbleCreationParameters, + stageGarbleDecompositions, stageUnknownItemStatus, } from "../../helpers/adapters/index.js"; import { @@ -759,10 +764,37 @@ const PLACEHOLDER_ITEM_ID = "item-1"; /** The corrupt session's name in every T10.1-4 staging. */ const CORRUPT_NAME = "cor"; +/** + * Decode `review status <cor> --json` and require at least one item — the + * pre-corruption read of a product-written session. + */ +async function readSessionItems( + product: ProductBinding, + workspace: TestWorkspace, + label: string, +): Promise<readonly SessionStatusRow[]> { + const status = decodeSessionStatusReport( + await runJson( + product, + workspace, + ["review", "status", CORRUPT_NAME, "--json"], + label, + ), + label, + ); + if (status.items.length === 0) { + fail( + `${label}: staging premise — the session must hold at least one item ` + + `for the corruption transformations and the item-naming subcommands ` + + `(SPEC 10.5–10.7); got none`, + ); + } + return status.items; +} + /** * Build, create the session via the given argv, and capture one item id from - * `status --json` before the file is corrupted (the pre-corruption read of a - * product-written session). + * `status --json` before the file is corrupted. */ async function stageProductSession( product: ProductBinding, @@ -778,24 +810,12 @@ async function stageProductSession( 0, `${context} \`${createArgv.join(" ")}\``, ); - const label = `${context} \`review status ${CORRUPT_NAME} --json\` (pre-corruption item-id capture)`; - const status = decodeSessionStatusReport( - await runJson( - product, - workspace, - ["review", "status", CORRUPT_NAME, "--json"], - label, - ), - label, + const items = await readSessionItems( + product, + workspace, + `${context} \`review status ${CORRUPT_NAME} --json\` (pre-corruption item-id capture)`, ); - if (status.items.length === 0) { - fail( - `${label}: staging premise — the created session must hold at least ` + - `one item for the corruption transformations and the item-naming ` + - `subcommands (SPEC 10.5–10.7); got none`, - ); - } - return status.items[0].id; + return items[0].id; } /** What `review list` must report for a staged corrupt state. */ @@ -932,7 +952,7 @@ const ADAPTER_STATES: readonly (readonly [ const T10_1_4 = defineProductTest({ id: "T10.1-4", title: - "each corrupt session state — unparseable bytes (garbage and truncation), missing 10.2 field, unknown status, duplicate item ids, blockedBy at an absent item, a blockedBy cycle, duplicate kind+scope, malformed recorded creation parameters, and a directory or symlink at the session path — makes every review subcommand naming the session report corruption, exit 1, and modify nothing; `list` reports it corrupt in place of its fields (exit 1); `check` reports 14.21; shape-dependent states are staged via the H-3 adapter over product-written files (SPEC 10.1, 10.7, 13.4, 14.21)", + "each corrupt session state — unparseable bytes (garbage and truncation), missing 10.2 field, unknown status, duplicate item ids, blockedBy at an absent item, a blockedBy cycle, duplicate kind+scope, malformed recorded creation parameters, malformed recorded decompositions (garbled over a product-performed `split`'s durable record), and a directory or symlink at the session path — makes every review subcommand naming the session report corruption, exit 1, and modify nothing; `list` reports it corrupt in place of its fields (exit 1); `check` reports 14.21; shape-dependent states are staged via the H-3 adapter over product-written files (SPEC 10.1, 10.7, 13.4, 14.21)", timeoutMs: 360_000, run: async (product) => { // --- Shape-dependent states via the adapter, over an audit session --- @@ -971,6 +991,77 @@ const T10_1_4 = defineProductTest({ ]); }); + // --- Malformed recorded decompositions --- + // A `split` records its decomposition — the original's kind and scope + // node — durably in the session (SPEC 10.7), so the product itself is + // made to perform one before the recorded value is garbled: the + // corrupted file starts as one the product wrote holding a genuine + // recorded decomposition (an unsplit session may record none). + await withWorkspace(CORE_FILES, async (workspace) => { + const state = "malformed recorded decompositions"; + const context = `T10.1-4 [${state}]`; + await buildOk(product, workspace, `${context} \`build\``); + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", CORRUPT_NAME], + 0, + `${context} \`review create --strategy audit --name ${CORRUPT_NAME}\``, + ); + const items = await readSessionItems( + product, + workspace, + `${context} \`review status ${CORRUPT_NAME} --json\` (split-target selection)`, + ); + // The split target: the subtree-coherence item scoped at the one + // section with a child (`a` contains `a.k`), so the split is not + // refused (SPEC 10.7: a childless scope root refuses). + const splitScope = "specs/A.mdx#a"; + const splitTarget = items.find( + (item) => + item.kind === "subtree-coherence" && item.scope === splitScope, + ); + if (splitTarget === undefined) { + fail( + `${context}: staging premise — the audit session holds one ` + + `subtree-coherence item per requirement node (SPEC 10.6), so an ` + + `item scoped at ${splitScope} must exist for \`split\` to ` + + `decompose; item scopes: ` + + JSON.stringify(items.map((item) => item.scope)), + ); + } + await expectExit( + product, + workspace, + ["review", "split", CORRUPT_NAME, splitTarget.id], + 0, + `${context} \`review split ${CORRUPT_NAME} ${splitTarget.id}\` — ` + + `the product-performed split records the decomposition durably ` + + `(SPEC 10.7)`, + ); + const postSplit = await readSessionItems( + product, + workspace, + `${context} \`review status ${CORRUPT_NAME} --json\` (post-split item-id capture)`, + ); + if (postSplit.some((item) => item.id === splitTarget.id)) { + fail( + `${context}: staging premise — after \`split\`, the original item ` + + `is removed from the session and its id (${splitTarget.id}) ` + + `never reused (SPEC 10.7), so its decomposition is genuinely ` + + `recorded; the id is still present`, + ); + } + await stageGarbleDecompositions(workspace.path(sessionRel(CORRUPT_NAME))); + await assertCorruptSessionContract( + product, + workspace, + state, + postSplit[0].id, + [{ name: CORRUPT_NAME, corrupt: true }], + ); + }); + // --- Unparseable JSON: garbage bytes (shape-independent, staged // directly — no assumed session layout is involved) --- await withWorkspace(CORE_FILES, async (workspace) => { From 4ac3afa914694cbf53ba8d28920f54355f0d9693 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 11 Aug 2026 23:19:44 +0000 Subject: [PATCH 079/226] sdg(phase-9): implement T10.4-2 non-scope presence-recording arms (FP-034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context arm (metadata-consistency): one edit removes D's d reference and deletes sibling T's section; after resolve, re-authoring T invalidates through the context node's absent-to-present flip alone. Origin arm (dependency-consistency): X->T->D; a d-list edit on D derives X's item (scope X, context {T}, origin {D}); after resolve, one edit removes T's reference and deletes D's section — invalidation through the origin node's present-to-absent flip alone. Purity asserted in-test via assertHashPremises (extracted from runSensitivityArms, fail-loud on missing captures); item composition via assertSoleContext/Origin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 39 +- test/suite/registry/section-10.4.ts | 569 ++++++++++++++++++++++++++-- 2 files changed, 582 insertions(+), 26 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index ca9a0d6..7f2acd1 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1375,12 +1375,49 @@ A "new test T<x>" task always means, in one change: (["10.1", "14"] — 14.21's primary record already rides "14"); T10.1-4 in no certification scope.] -- [ ] FP-034 — T10.4-2: add the non-scope presence recordings. [R2 #21; +- [x] FP-034 — T10.4-2: add the non-scope presence recordings. [R2 #21; TEST-SPEC §10.4] `test/suite/registry/section-10.4.ts`: context arm (`metadata-consistency`, removed target `T` re-authored) and origin arm (`dependency-consistency`, origin `D` deleted with hashes/context set unchanged). + [Done 2026-08-11: both arms appended to T10.4-2 (title extended, timeout + 240s → 360s), each its own git-baselined workspace. Context arm: baseline + `D` bears `d={"tt"}` to sibling `T` in one file; one edit removes the + reference and deletes T's section; `review create --base` derives exactly + {metadata-consistency D, subtree-coherence root} (kindScopeSet-pinned; the + deleted T skipped for its changed ancestor), D's item's context exactly + [{T, absent}] via new `assertSoleContext`; resolve no-change; re-author T + → `invalidated` with context presented [{T, present}]. Origin arm: + X→T→D across three files (D beside sibling `e`); the d-list edit on D + (gains `d={"e"}`) derives exactly {metadata-consistency D, + dependency-consistency T, dependency-consistency X} — no `changed` node — + with X's item context [{T, present}] and origin [{D, after-present}] + (`assertSoleOrigin`); resolve no-change; one edit removes T's `d={O.d}` + (import kept — an unused import is valid and records no edges, SPEC 2.1) + and deletes D's section → `invalidated`, post-show pinning scope X + present, context T present, origin D after-side absent. Arm purity is + asserted in-test via `assertHashPremises` (extracted from + runSensitivityArms' inline loops, now fail-loud on a missing capture): + context arm — D.metadataHash the item's only relevant hash, unchanged + across the re-authoring, D still metadata-changed vs baseline (5.5's iff + pins the d-set, so the generated context set stays {T}); origin arm — + X.ownHash/X.metadataHash/T.subtreeHash unchanged (d-prop edits touch no + own content, 1.6/5.5) while T.metadataHash changes and T.effectiveHash + stays changed vs baseline (context set stays {T}). NOT red against this + repo's product: direct CLI probes of both stagings returned exactly the + expected item sets, hash brackets, presence flips, and invalidations (the + product records presence for every scope/context/origin node — its + `current` holds D presence-only with hashes {}), so the suite pass + (section-10.4 5/5) is genuine; teeth live in the purity brackets plus the + presence-flip assertion — a product recording presence for scope nodes + alone (or scope+context, origin arm) has no other divergence channel and + reports no-change where the arms demand invalidated. Typecheck/format + clean; `npm run test:self` unchanged 4 planned mid-loop reds + (certification-document ×3 → FP-091; S-1's 7 unmapped keys → stage G), + S-5 and certification green. Traceability unchanged (["10.4"]; 1.6/5.5/ + 5.6/10.5 are context with home coverage elsewhere; no numbered condition + asserted); T10.4-2 in no certification scope.] - [ ] FP-035 — T10.7-7: assert payload source ranges for EVERY present node. [R2 #22; TEST-SPEC §10.7] diff --git a/test/suite/registry/section-10.4.ts b/test/suite/registry/section-10.4.ts index 4c774b1..f4e684d 100644 --- a/test/suite/registry/section-10.4.ts +++ b/test/suite/registry/section-10.4.ts @@ -424,6 +424,60 @@ async function captureHashes( return captured; } +/** + * Assert the hash premises bracketing a staged edit (SPEC 5.5): every + * `changed` probe must differ between the two captures and every `unchanged` + * probe must not, so no arm passes or fails for the wrong reason (H-8). A + * probe whose node was not captured on both sides fails loudly as a harness + * staging defect. + */ +function assertHashPremises( + before: ReadonlyMap<string, NodeHashes>, + after: ReadonlyMap<string, NodeHashes>, + changed: readonly HashProbe[], + unchanged: readonly HashProbe[], + context: string, +): void { + const probeValue = ( + captures: ReadonlyMap<string, NodeHashes>, + probe: HashProbe, + side: "pre-edit" | "post-edit", + ): string => { + const hashes = captures.get(probe.node); + if (hashes === undefined) { + fail( + `${context}: harness staging defect — no ${side} \`query node\` ` + + `capture exists for ${probe.node}, so its ${probe.hash} premise ` + + `cannot be checked`, + ); + } + return hashes[probe.hash]; + }; + for (const probe of changed) { + const beforeValue = probeValue(before, probe, "pre-edit"); + const afterValue = probeValue(after, probe, "post-edit"); + if (beforeValue === afterValue) { + fail( + `${context}: staging premise — the edit must change ${probe.node}'s ` + + `${probe.hash} (SPEC 5.5) for this arm to exercise it; both ` + + `captures report ${JSON.stringify(afterValue)}`, + ); + } + } + for (const probe of unchanged) { + const beforeValue = probeValue(before, probe, "pre-edit"); + const afterValue = probeValue(after, probe, "post-edit"); + if (beforeValue !== afterValue) { + fail( + `${context}: staging premise — the edit must leave ${probe.node}'s ` + + `${probe.hash} unchanged (SPEC 5.5) so the arm isolates its ` + + `intended sensitivity; got ${JSON.stringify(beforeValue)} -> ` + + JSON.stringify(afterValue), + ); + } + } +} + /** * Run one kind's sensitivity arms against its resolved item: per arm, assert * the staged edit's hash premises (SPEC 5.5), then that the item is reported @@ -459,29 +513,7 @@ async function runSensitivityArms( probedNodes, `${context}, post-edit capture`, ); - for (const probe of arm.changed) { - const beforeValue = before.get(probe.node)?.[probe.hash]; - const afterValue = after.get(probe.node)?.[probe.hash]; - if (beforeValue === afterValue) { - fail( - `${context}: staging premise — the edit must change ${probe.node}'s ` + - `${probe.hash} (SPEC 5.5) for this arm to exercise it; both ` + - `captures report ${JSON.stringify(afterValue)}`, - ); - } - } - for (const probe of arm.unchanged) { - const beforeValue = before.get(probe.node)?.[probe.hash]; - const afterValue = after.get(probe.node)?.[probe.hash]; - if (beforeValue !== afterValue) { - fail( - `${context}: staging premise — the edit must leave ${probe.node}'s ` + - `${probe.hash} unchanged (SPEC 5.5) so the arm isolates its ` + - `intended sensitivity; got ${JSON.stringify(beforeValue)} -> ` + - JSON.stringify(afterValue), - ); - } - } + assertHashPremises(before, after, arm.changed, arm.unchanged, context); await expectItemStatus( product, workspace, @@ -1375,11 +1407,132 @@ function t2Spec(withX: boolean, yText: string): string { return [...lines, '<S id="y">', yText, "</S>", ""].join("\n"); } +// Non-scope presence recordings (SPEC 10.4: presence is recorded for every +// scope, context, and origin node). Each arm is pure — no recorded relevant +// hash of the item changes (query-node brackets assert it) and the generated +// context set stays put — so only the named node's presence divergence can +// invalidate, and a product recording presence for scope nodes alone reports +// the item still resolved. + +// Context arm (`metadata-consistency`): baseline D bears a `d` reference to +// sibling T; one edit removes the reference and deletes T's section, so +// `review create --base` derives D's item with the removed target T as its +// one context node (SPEC 10.5), currently absent. +const T2C_FILE = "specs/C.mdx"; +const T2C_ROOT = "specs/C.mdx"; +const T2C_D = "specs/C.mdx#dd"; +const T2C_T = "specs/C.mdx#tt"; + +function t2cSpec(dAttrs: string, withT: boolean): string { + const t = withT ? ["", '<S id="tt">', "Tee text.", "</S>"] : []; + return [`<S id="dd"${dAttrs}>`, "Dee own text.", "</S>", ...t, ""].join("\n"); +} + +// Origin arm (`dependency-consistency`): X depends on T, T depends on D — a +// section in its own file, beside sibling `e`, the target D's staged d-list +// edit gains. That edit leaves D `metadata-changed` and nothing `changed` +// (SPEC 5.6), so X's item derives as scope X, context {T} (the +// dependency-edge target whose effectiveHash changed), origin {D} (the +// originating node of T's change) (SPEC 10.5). +const T2O_X_FILE = "specs/X.mdx"; +const T2O_T_FILE = "specs/T.mdx"; +const T2O_D_FILE = "specs/O.mdx"; +const T2O_X = "specs/X.mdx#x"; +const T2O_T = "specs/T.mdx#t"; +const T2O_D = "specs/O.mdx#d"; + +const T2O_X_SOURCE = [ + 'import T from "./T.xspec"', + "", + '<S id="x" d={T.t}>', + "Ex own text.", + "</S>", + "", +].join("\n"); + +// The import stays when the `d` reference goes: an import whose binding is +// never used is valid and records no edges (SPEC 2.1), so the reference +// removal is a pure d-prop edit. +function t2oTSpec(withD: boolean): string { + return [ + 'import O from "./O.xspec"', + "", + withD ? '<S id="t" d={O.d}>' : '<S id="t">', + "Tee own text.", + "</S>", + "", + ].join("\n"); +} + +// `dAttrs === null` deletes D's section; sibling `e` remains, so the file +// keeps its root and the deletion touches no other source. +function t2oDSpec(dAttrs: string | null): string { + const d = + dAttrs === null ? [] : [`<S id="d"${dAttrs}>`, "Dee own text.", "</S>", ""]; + return [...d, '<S id="e">', "Ee text.", "</S>", ""].join("\n"); +} + +/** Assert an item's context is exactly one node with the given presence. */ +function assertSoleContext( + item: ReviewItem, + node: string, + present: boolean, + context: string, +): void { + const summary = item.context.map((state) => ({ + node: state.node, + present: state.present, + })); + if ( + summary.length === 1 && + summary[0].node === node && + summary[0].present === present + ) { + return; + } + fail( + `${context}: the item's context must be exactly ` + + `[{node: ${JSON.stringify(node)}, present: ${String(present)}}] — the ` + + `strategy-derived context node presented under its current identity ` + + `and presence (SPEC 10.4, 10.5, 10.7); got ${JSON.stringify(summary)}`, + ); +} + +/** + * Assert an item's origin is exactly one node whose after side carries the + * given presence (the after side reads the current graph, SPEC 10.7). + */ +function assertSoleOrigin( + item: ReviewItem, + node: string, + afterPresent: boolean, + context: string, +): void { + const summary = item.origin.map((entry) => ({ + node: entry.node, + afterPresent: entry.after.present, + })); + if ( + summary.length === 1 && + summary[0].node === node && + summary[0].afterPresent === afterPresent + ) { + return; + } + fail( + `${context}: the item's origin must be exactly one entry for ` + + `${JSON.stringify(node)} with its after side ` + + `${afterPresent ? "present" : "absent"} — the originating node of the ` + + `reviewed change (SPEC 5.6, 10.5), its after side read from the ` + + `current graph (SPEC 10.7); got ${JSON.stringify(summary)}`, + ); +} + const T10_4_2 = defineProductTest({ id: "T10.4-2", title: - "presence changes: deleting a scope node after resolve invalidates the resolution (presence recorded present, node now absent); the item stays resolvable against absence, and a node already absent at resolve time does not invalidate by remaining absent across an unrelated edit — deletion review stays resolvable; restoring the node invalidates the resolution recorded against absence (presence changed in the other direction) (SPEC 10.2, 10.3, 10.4)", - timeoutMs: 240_000, + "presence changes: deleting a scope node after resolve invalidates the resolution (presence recorded present, node now absent); the item stays resolvable against absence, and a node already absent at resolve time does not invalidate by remaining absent across an unrelated edit — deletion review stays resolvable; restoring the node invalidates the resolution recorded against absence (presence changed in the other direction); non-scope presence recordings (presence is recorded for every scope, context, and origin node), each arm pure — no recorded relevant hash of the item and no generated context set changes, so only the named node's presence divergence can invalidate and a product recording presence for scope nodes alone reports the item still resolved: context arm (metadata-consistency) — baseline D bears a `d` reference to sibling T, one edit removes the reference and deletes T's section, `review create --base` derives D's item with the removed target T as context, recorded absent at resolve; re-authoring T reads the item `invalidated` through the context node's absent-to-present flip alone (D's metadataHash and the context set unchanged); origin arm (dependency-consistency) — baseline X depends on T, T depends on D (a section in its own file); a d-list edit on D derives X's item (scope X, context {T}, origin {D}); after resolve, one edit removes T's reference to D and deletes D's section — X's ownHash and metadataHash and T's subtreeHash unchanged (d-prop edits touch no own content, SPEC 1.6/5.5) and the context set stays {T} (T's effectiveHash still changed against the baseline), so the item reads `invalidated` through the origin node's present-to-absent flip alone (SPEC 1.6, 5.5, 5.6, 10.2, 10.3, 10.4, 10.5)", + timeoutMs: 360_000, run: async (product) => { await withWorkspace( SPECS_ONLY_CONFIG, @@ -1521,6 +1674,372 @@ const T10_4_2 = defineProductTest({ } }, ); + + // --- context arm: a context node's absent-to-present flip -------------- + await withWorkspace( + SPECS_ONLY_CONFIG, + { [T2C_FILE]: t2cSpec(' d={"tt"}', true) }, + async (workspace) => { + const prefix = "T10.4-2 context arm (metadata-consistency)"; + await workspace.gitInit(); + const base = await workspace.gitCommitAll("baseline"); + await buildOk(product, workspace, `${prefix} \`build\` at baseline`); + const atBase = await captureHashes( + product, + workspace, + [T2C_D], + `${prefix}, baseline capture`, + ); + + // One edit removes D's `d` reference and deletes T's section. + await workspace.file(T2C_FILE, t2cSpec("", false)); + await buildOk( + product, + workspace, + `${prefix} \`build\` after the reference-removing edit`, + ); + const atCreate = await captureHashes( + product, + workspace, + [T2C_D], + `${prefix}, creation-moment capture`, + ); + assertHashPremises( + atBase, + atCreate, + // D `metadata-changed` (SPEC 5.6) — the item generates. + [{ node: T2C_D, hash: "metadataHash" }], + // d-prop edits touch no own content (SPEC 1.6, 5.5). + [ + { node: T2C_D, hash: "ownHash" }, + { node: T2C_D, hash: "subtreeHash" }, + ], + `${prefix}, staging the metadata-changed premise`, + ); + + await expectExit( + product, + workspace, + ["review", "create", "--base", base, "--name", "s"], + 0, + `${prefix} \`review create --base <baseline> --name s\``, + ); + const status = await sessionStatus(product, workspace, "s", prefix); + assertSameJson( + kindScopeSet(status), + [ + `metadata-consistency ${T2C_D}`, + `subtree-coherence ${T2C_ROOT}`, + ].sort(), + `${prefix}: the one edit yields D's metadata-consistency item ` + + `plus the changed root's subtree-coherence item — the deleted T ` + + `is skipped for its changed ancestor (SPEC 10.5)`, + ); + const dId = requireRow( + status, + "metadata-consistency", + T2C_D, + prefix, + ).id; + assertSoleContext( + await showItem(product, workspace, "s", dId, `${prefix} at create`), + T2C_T, + false, + `${prefix} at create — the item's context is the removed ` + + `\`d\` target, currently absent`, + ); + + await resolveOk( + product, + workspace, + "s", + dId, + "no-change", + `${prefix} \`review resolve s <D item> --status no-change\` ` + + `(context node T absent — its presence recorded so, SPEC 10.4)`, + ); + await expectItemStatus( + product, + workspace, + "s", + dId, + "no-change", + `${prefix} sanity — the fresh resolution matches the graph`, + ); + + // Re-author T. The item's only relevant hash (D's metadataHash) and + // its generated context set are untouched; only the context node's + // presence diverges from the recorded state. + await workspace.file(T2C_FILE, t2cSpec("", true)); + await buildOk( + product, + workspace, + `${prefix} \`build\` after re-authoring T`, + ); + const afterRestore = await captureHashes( + product, + workspace, + [T2C_D], + `${prefix}, post-restore capture`, + ); + assertHashPremises( + atCreate, + afterRestore, + [], + // The item's one relevant hash is unchanged across the edit (the + // pre-edit capture equals the state the resolve recorded: no edit + // intervened). + [{ node: T2C_D, hash: "metadataHash" }], + `${prefix}, purity of the re-authoring edit`, + ); + assertHashPremises( + atBase, + afterRestore, + // D still `metadata-changed` against the baseline, and metadataHash + // equality tracks the `d` target set exactly (SPEC 5.5), so the + // generators still derive the item with context {T} (SPEC 10.5). + [{ node: T2C_D, hash: "metadataHash" }], + [], + `${prefix}, the generated context set stays the removed target`, + ); + await expectItemStatus( + product, + workspace, + "s", + dId, + "invalidated", + `${prefix} after re-authoring T — presence is recorded for the ` + + `context node, and its absent-to-present flip alone invalidates ` + + `(SPEC 10.4); a product recording presence for scope nodes ` + + `alone reports the item still resolved`, + ); + const restored = await showItem( + product, + workspace, + "s", + dId, + `${prefix} post-restore read`, + ); + assertSoleContext( + restored, + T2C_T, + true, + `${prefix} post-restore — the recorded-absent context node is ` + + `presented under its current presence`, + ); + }, + ); + + // --- origin arm: an origin node's present-to-absent flip --------------- + await withWorkspace( + SPECS_ONLY_CONFIG, + { + [T2O_X_FILE]: T2O_X_SOURCE, + [T2O_T_FILE]: t2oTSpec(true), + [T2O_D_FILE]: t2oDSpec(""), + }, + async (workspace) => { + const prefix = "T10.4-2 origin arm (dependency-consistency)"; + await workspace.gitInit(); + const base = await workspace.gitCommitAll("baseline"); + await buildOk(product, workspace, `${prefix} \`build\` at baseline`); + const atBase = await captureHashes( + product, + workspace, + [T2O_X, T2O_T, T2O_D], + `${prefix}, baseline capture`, + ); + + // The d-list edit on D: D `metadata-changed`, nothing `changed`; T + // and X are upstream-changed, attributed to D (SPEC 5.6). + await workspace.file(T2O_D_FILE, t2oDSpec(' d={"e"}')); + await buildOk( + product, + workspace, + `${prefix} \`build\` after the d-list edit on D`, + ); + const atCreate = await captureHashes( + product, + workspace, + [T2O_X, T2O_T, T2O_D], + `${prefix}, creation-moment capture`, + ); + assertHashPremises( + atBase, + atCreate, + [ + // The edit lands on D's metadata... + { node: T2O_D, hash: "metadataHash" }, + // ...and cascades into T's effectiveHash, deriving X's item with + // context {T} (SPEC 10.5). + { node: T2O_T, hash: "effectiveHash" }, + ], + [ + // T itself is untouched — its change is upstream only, so the + // item's origin is D, not T (SPEC 5.6). + { node: T2O_T, hash: "ownHash" }, + { node: T2O_T, hash: "subtreeHash" }, + { node: T2O_T, hash: "metadataHash" }, + { node: T2O_X, hash: "ownHash" }, + { node: T2O_X, hash: "metadataHash" }, + ], + `${prefix}, staging the upstream-change premise`, + ); + + await expectExit( + product, + workspace, + ["review", "create", "--base", base, "--name", "s"], + 0, + `${prefix} \`review create --base <baseline> --name s\``, + ); + const status = await sessionStatus(product, workspace, "s", prefix); + assertSameJson( + kindScopeSet(status), + [ + `dependency-consistency ${T2O_T}`, + `dependency-consistency ${T2O_X}`, + `metadata-consistency ${T2O_D}`, + ].sort(), + `${prefix}: the d-list edit yields D's metadata-consistency item ` + + `plus one dependency-consistency item per dependent of a ` + + `changed-effectiveHash target — no node is \`changed\`, so no ` + + `subtree-coherence item exists (SPEC 5.6, 10.5)`, + ); + const xId = requireRow( + status, + "dependency-consistency", + T2O_X, + prefix, + ).id; + const created = await showItem( + product, + workspace, + "s", + xId, + `${prefix} at create`, + ); + assertSoleContext( + created, + T2O_T, + true, + `${prefix} at create — the item's context is the dependency-edge ` + + `target whose effectiveHash changed`, + ); + assertSoleOrigin( + created, + T2O_D, + true, + `${prefix} at create — the item's origin is the originating node ` + + `of T's change`, + ); + + await resolveOk( + product, + workspace, + "s", + xId, + "no-change", + `${prefix} \`review resolve s <X item> --status no-change\` ` + + `(D present — every origin node's presence recorded, SPEC 10.4)`, + ); + await expectItemStatus( + product, + workspace, + "s", + xId, + "no-change", + `${prefix} sanity — the fresh resolution matches the graph`, + ); + + // One edit removes T's reference to D and deletes D's section. The + // item's relevant hashes (X's ownHash and metadataHash, T's + // subtreeHash) and its generated context set are untouched; only the + // origin node's presence diverges from the recorded state. + await workspace.file(T2O_T_FILE, t2oTSpec(false)); + await workspace.file(T2O_D_FILE, t2oDSpec(null)); + await buildOk( + product, + workspace, + `${prefix} \`build\` after deleting D's section`, + ); + const afterLoss = await captureHashes( + product, + workspace, + [T2O_X, T2O_T], + `${prefix}, post-deletion capture (D's identity no longer resolves)`, + ); + assertHashPremises( + atCreate, + afterLoss, + // The reference removal lands on T's metadata (the pre-edit + // capture equals the state the resolve recorded). + [{ node: T2O_T, hash: "metadataHash" }], + // The item's relevant hashes stay put: d-prop edits touch no own + // content (SPEC 1.6, 5.5), and X is untouched. + [ + { node: T2O_X, hash: "ownHash" }, + { node: T2O_X, hash: "metadataHash" }, + { node: T2O_T, hash: "subtreeHash" }, + ], + `${prefix}, purity of the deletion edit`, + ); + assertHashPremises( + atBase, + afterLoss, + // T's effectiveHash still changed against the baseline, so the + // generators still derive X's item with context {T} (SPEC 10.5). + [{ node: T2O_T, hash: "effectiveHash" }], + [], + `${prefix}, the generated context set stays {T}`, + ); + await expectItemStatus( + product, + workspace, + "s", + xId, + "invalidated", + `${prefix} after deleting D's section — presence is recorded for ` + + `every origin node, and its present-to-absent flip alone ` + + `invalidates (SPEC 10.4); a product recording presence for ` + + `scope (or scope and context) nodes alone reports the item ` + + `still resolved`, + ); + const afterShow = await showItem( + product, + workspace, + "s", + xId, + `${prefix} post-deletion read`, + ); + if ( + afterShow.scope.node !== T2O_X || + afterShow.scope.present !== true + ) { + fail( + `${prefix} post-deletion \`review show\`: the scope node must ` + + `still be the present X — the flip under test is the origin ` + + `node's alone (SPEC 10.4, 10.7); expected {node: ` + + `${JSON.stringify(T2O_X)}, present: true}, got ` + + JSON.stringify(afterShow.scope), + ); + } + assertSoleContext( + afterShow, + T2O_T, + true, + `${prefix} post-deletion — the context node T stays present and ` + + `recorded-matching`, + ); + assertSoleOrigin( + afterShow, + T2O_D, + false, + `${prefix} post-deletion — the origin node is presented under ` + + `its current (absent) presence`, + ); + }, + ); }, }); From 58c9eee1af989b07b1fb9b72b34caedbdce0aec8 Mon Sep 17 00:00:00 2001 From: Claude <claude@anthropic.com> Date: Thu, 13 Aug 2026 16:56:13 +0000 Subject: [PATCH 080/226] sdg(phase-9): assert T10.7-7 payload source ranges for every present node (FP-035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The next --json payload's range clause now has full coverage (TEST-SPEC 10.7, SPEC 10.7/1.7): origin entries decode and assert a node-level current source range (forbidden when the after side — the current presence — is absent), the walk reaches the code-impact item whose present code-location scope is byte-asserted against the named unit's precomputed construct range with no text, and deleting the code file flips the same item's scope to absent — no text, no range. S-5 guards the new origin decode both ways (74/74). Red-as-diagnosed at the first origin-entry range (the product presents no origin or code-location ranges); staging and every downstream value probe-verified against the built product. Self-tests: unchanged 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 37 +++- test/helpers/adapters/model.ts | 9 +- test/helpers/adapters/review.ts | 33 +++- test/self/s5-output-adapters.test.ts | 32 ++++ test/suite/registry/section-10.7-ii.ts | 238 ++++++++++++++++++++++++- 5 files changed, 340 insertions(+), 9 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 7f2acd1..5c7a075 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1419,12 +1419,47 @@ A "new test T<x>" task always means, in one change: 5.6/10.5 are context with home coverage elsewhere; no numbered condition asserted); T10.4-2 in no certification scope.] -- [ ] FP-035 — T10.7-7: assert payload source ranges for EVERY present +- [x] FP-035 — T10.7-7: assert payload source ranges for EVERY present node. [R2 #22; TEST-SPEC §10.7] Currently asserted "for present requirement nodes" only (section-10.7-i/ii — locate the T10.7-7 body): cover every present node's, requirement node and present code location alike; none for absent nodes. + [Done 2026-08-13: the payload arm (section-10.7-ii.ts) now runs on + SPECS_CODE_CONFIG with a v1 code source whose named unit `nextUnit` + references a.k (SPEC 4.6 attribution behind a multi-byte prefix), and + covers the range clause on every payload position: ORIGIN nodes — the + H-3 review adapter's OriginEntry gains a node-level `sourceRange` + (SPEC 10.7 presents every scope/context/origin node under its current + identity and presence with its range when present; the after side IS the + current presence, so the decode forbids a range on an after-absent entry) + asserted via new `assertOriginRange` on every origin entry the test + reads, and carried through `payloadProjection` so the one-payload-rule + compares include it; CODE LOCATIONS — the walk (resolve no-change never + re-derives) reaches the code-impact item after the two spec items and + byte-asserts its present scope's named-unit construct range against + precomputed offsets, no text (SPEC 1.7's review-payload half), context + a.k subtree text + range, origin a.k pair + range; ABSENT nodes — the + code file is then deleted (build exit 0, zero-source code group valid), + and the SAME item id presents its scope absent: identity and absence + alone, no text, no range (assertAbsentState), context/origin still + present and ranged; the parent-consistency middle item's scope (own + text + range) asserted in passing. S-5 gains the origin-range guards + (present entry's range decoded and surfaced; range on a currently-absent + origin node rejected), 74/74. Verified: T10.7-7 turned falsely-green → + red-as-diagnosed at exactly the first new assertion — the first item's + origin-entry range, undefined against a.k's `query node` range (the + product emits no origin range and no code-impact scope range: probes + confirmed the whole walk, item order [sc a.k, pc a, ci], presence flip + after deletion, and every asserted text/range value against the built + product, so downstream arms are sound and unreached until the product + presents ranges). Traceability unchanged (["10.7"] — T1.7-1's entry + names T10.7-7 as field-presence coverage like T11-1/T12.4-1, which + carry no "1.7"; the range-key homes stay T1.7-*/T10.7-12). `npm run + test:self`: unchanged 4 planned mid-loop reds (certification-document + ×3 → FP-091; S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), + S-5 and certification green; T10.7-7 in no certification scope. + Typecheck/format clean.] - [ ] FP-036 — T11-6: add the wrong-kind / unknown-unit / disambiguator arms. [R2 #23; TEST-SPEC §11.1] diff --git a/test/helpers/adapters/model.ts b/test/helpers/adapters/model.ts index bd75121..2dc54b4 100644 --- a/test/helpers/adapters/model.ts +++ b/test/helpers/adapters/model.ts @@ -538,11 +538,18 @@ export type OriginTextSide = | { readonly present: false } | { readonly present: true; readonly text: string }; -/** One origin entry: a node's own text before and after (T10.7-12). */ +/** + * One origin entry: a node's own text before and after (T10.7-12). The after + * side is read from the current graph, so its presence is the node's current + * presence (SPEC.md 10.7) — and like every payload node, a currently-present + * origin node carries its current source range while a currently-absent one + * carries none (SPEC.md 10.7, 1.7; T10.7-7). + */ export interface OriginEntry { readonly node: string; readonly before: OriginTextSide; readonly after: OriginTextSide; + readonly sourceRange?: SourceRange; } /** diff --git a/test/helpers/adapters/review.ts b/test/helpers/adapters/review.ts index 91e246b..15bff79 100644 --- a/test/helpers/adapters/review.ts +++ b/test/helpers/adapters/review.ts @@ -25,7 +25,8 @@ // Item = { "id", "kind", "status", "blocked", "blockedBy": [id...], // "reason", "note"?, // "scope": NodeState, "context": [NodeState], -// "origin": [ { "node", "before": Side, "after": Side } ], +// "origin": [ { "node", "before": Side, "after": Side, +// "sourceRange"? } ], // "baseline", "current" } // NodeState = { "node", "present": bool, "text"?, "sourceRange"? } // (text optional either way: a present node's text is read from the @@ -35,6 +36,12 @@ // an absent node has no current source) // Side = { "present": bool, "text"? } (text required iff present — the // absent side of an origin before/after pair carries no text, SPEC 10.7) +// An origin entry's "sourceRange" is the node's CURRENT range: the after +// side is read from the current graph, so its presence is the node's +// current presence, and only a currently-present origin node may carry a +// range — every payload node, origin nodes included, enters with its +// source range when present and none when absent (SPEC 10.7, 1.7; +// T10.7-7). import type { ExportReport, @@ -243,7 +250,12 @@ function decodeOriginSide(value: unknown, site: DecodeSite): OriginTextSide { function decodeOriginEntry(value: unknown, site: DecodeSite): OriginEntry { const obj = expectObject(value, site); - return { + const entry: { + node: string; + before: OriginTextSide; + after: OriginTextSide; + sourceRange?: OriginEntry["sourceRange"]; + } = { node: expectNonEmptyString( requiredKey(obj, "node", site), at(site, "node"), @@ -254,6 +266,23 @@ function decodeOriginEntry(value: unknown, site: DecodeSite): OriginEntry { ), after: decodeOriginSide(requiredKey(obj, "after", site), at(site, "after")), }; + if (!entry.after.present) { + // The after side is the node's current presence (SPEC 10.7: after from + // the current graph), so a currently-absent origin node has no current + // source and carries no source range (SPEC 10.7, 1.7). + forbiddenKey( + obj, + "sourceRange", + site, + "a currently-absent origin node (absent after side) has no current source, so it carries no source range (SPEC 10.7, 1.7)", + ); + return entry; + } + const sourceRange = optionalKey(obj, "sourceRange"); + if (sourceRange !== undefined) { + entry.sourceRange = decodeSourceRange(sourceRange, at(site, "sourceRange")); + } + return entry; } /** Decode one full review item (10.2 fields plus the payload of 10.7). */ diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index 41276b3..2016c3a 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -2187,6 +2187,33 @@ const DECODERS: readonly DecoderSpec[] = [ }); }, }, + { + label: + "currently-present origin node carrying its source range (SPEC 10.7, 1.7; T10.7-7)", + doc: put( + put( + GOOD_ITEM, + { present: true, text: "new text\n" }, + "origin", + 0, + "after", + ), + { start: 40, end: 90 }, + "origin", + 0, + "sourceRange", + ), + verify: (decoded: ReturnType<typeof decodeItemReport>) => { + expect(decoded.origin[0].after).toEqual({ + present: true, + text: "new text\n", + }); + expect(decoded.origin[0].sourceRange).toEqual({ + start: 40, + end: 90, + }); + }, + }, ], bad: [ { label: "missing id", doc: omit(GOOD_ITEM, "id") }, @@ -2220,6 +2247,11 @@ const DECODERS: readonly DecoderSpec[] = [ label: "origin absent side carrying text (contradiction)", doc: put(GOOD_ITEM, "ghost", "origin", 0, "after", "text"), }, + { + label: + "currently-absent origin node carrying a source range (contradiction)", + doc: put(GOOD_ITEM, { start: 3, end: 9 }, "origin", 0, "sourceRange"), + }, { label: "missing baseline record", doc: omit(GOOD_ITEM, "baseline") }, { label: "missing current record", doc: omit(GOOD_ITEM, "current") }, { diff --git a/test/suite/registry/section-10.7-ii.ts b/test/suite/registry/section-10.7-ii.ts index 2921f7c..27926e3 100644 --- a/test/suite/registry/section-10.7-ii.ts +++ b/test/suite/registry/section-10.7-ii.ts @@ -594,6 +594,7 @@ function payloadProjection(item: ReviewItem): unknown { node: entry.node, before: entry.before, after: entry.after, + sourceRange: entry.sourceRange, })), }; } @@ -782,6 +783,26 @@ function assertOriginPair( } } +/** + * Assert an origin entry's node-level source range (SPEC 10.7, 1.7): every + * payload node — scope, context, and origin alike — enters with its current + * source range when present. (The adapter already rejects a range on a + * currently-absent origin node, whose after side is absent.) + */ +function assertOriginRange( + entry: OriginEntry, + expected: SourceRange, + context: string, +): void { + assertSameJson( + entry.sourceRange, + expected, + `${context}: ${entry.node}'s origin-entry source range — every present ` + + `payload node, origin nodes included, carries its current source ` + + `range (SPEC 10.7, 1.7)`, + ); +} + /** * Walk a session to completion via `next --json` + `resolve --status * no-change` (which never re-derives, SPEC 10.5): every item of `reference` @@ -888,6 +909,31 @@ function n7PbSpec(kidText: string): string { ].join("\n"); } +// The payload arm's impacted code location (SPEC 9.2, 10.5): the marker sits +// inside the function declaration `nextUnit`, so the reference is attributed +// to the named unit (SPEC 4.6) and the impacted location is +// `src/next.ts#nextUnit`. Multi-byte UTF-8 bytes precede the unit, so the +// precomputed byte offsets diverge from code-point and UTF-16 offsets: the +// range assertion is byte-precise (SPEC 1.7; a code location is no +// `query node` operand, so its range is asserted against precomputed offsets +// — the T10.7-12/T1.7-2 pattern). +const N7_CODE_FILE = "src/next.ts"; +const N7_CODE = "src/next.ts#nextUnit"; +const N7_CODE_BEFORE_UNIT = + 'import A from "../specs/A2.xspec";\n\n// Byte-genaue Präambel vor der Einheit (multi-byte prefix).\n\n'; +const N7_CODE_UNIT_DECL = "function nextUnit() {\n A.a.k;\n}"; +const N7_CODE_SOURCE = `${N7_CODE_BEFORE_UNIT}${N7_CODE_UNIT_DECL}\n`; + +// nextUnit's construct range (SPEC 1.7, 4.6): the function declaration's own +// bytes, from the `function` keyword through the closing brace — zero-based, +// start-inclusive, end-exclusive byte offsets into the file. +const N7_CODE_RANGE: SourceRange = { + start: Buffer.byteLength(N7_CODE_BEFORE_UNIT, "utf8"), + end: + Buffer.byteLength(N7_CODE_BEFORE_UNIT, "utf8") + + Buffer.byteLength(N7_CODE_UNIT_DECL, "utf8"), +}; + /** * `review next <name>` (human form) reporting the session fully resolved: * exit 0 and stdout mentioning resolution (H-3: information presence — the @@ -929,7 +975,7 @@ async function expectFullyResolvedBothForms( const T10_7_7 = defineProductTest({ id: "T10.7-7", title: - "`review next`: returns the first needing-review unblocked item in item order — in an audit session with blocked root and parent items it returns the first leaf, then the second, then moves backward in item order to the meanwhile-unblocked parent and root; when all items are resolved, and for a session with no items, it exits 0 and reports fully resolved in the human and `--json` forms with no item in the JSON payload; the `--json` payload is self-contained — scope text, context texts, origin before/after texts, source ranges for present requirement nodes, and the recorded `baseline` and `current` hashes (asserted against distinct `query node` captures at the baseline and creation moments) (SPEC 10.2, 10.3, 10.4, 10.6, 10.7)", + "`review next`: returns the first needing-review unblocked item in item order — in an audit session with blocked root and parent items it returns the first leaf, then the second, then moves backward in item order to the meanwhile-unblocked parent and root; when all items are resolved, and for a session with no items, it exits 0 and reports fully resolved in the human and `--json` forms with no item in the JSON payload; the `--json` payload is self-contained — scope text, context texts, origin before/after texts, source ranges for every present node, requirement node and present code location alike (scope, context, and origin entries; the code-impact scope's named-unit construct range byte-asserted against precomputed offsets) and none for absent nodes (after the code file's deletion the same item's scope presents identity and absence alone — no text, no range), and the recorded `baseline` and `current` hashes (asserted against distinct `query node` captures at the baseline and creation moments) (SPEC 1.7, 4.6, 9.2, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7)", timeoutMs: 360_000, run: async (product) => { // --- arm 1: order walking and the fully-resolved report ----------------- @@ -1060,8 +1106,14 @@ const T10_7_7 = defineProductTest({ }); // --- arm 3: the self-contained --json payload ---------------------------- + // Source ranges are asserted for EVERY present payload node — requirement + // node and present code location alike — and for none of the absent ones + // (SPEC 10.7, 1.7): the walk below reaches the code-impact item, whose + // scope is first a present code location (named-unit construct range, + // precomputed byte offsets) and then, after the code file's deletion, an + // absent one (identity and absence alone — no text, no range). await withWorkspace( - SPECS_ONLY_CONFIG, + SPECS_CODE_CONFIG, { [N7_PB_FILE]: n7PbSpec("Kid line v0.") }, async (workspace) => { const prefix = "T10.7-7 payload arm"; @@ -1075,7 +1127,11 @@ const T10_7_7 = defineProductTest({ `${prefix} baseline capture`, ); + // v1: the reviewed edit, plus the code source whose named unit + // references a.k — the impacted location (SPEC 9.2) whose + // code-impact item the walk below reaches. await workspace.file(N7_PB_FILE, n7PbSpec("Kid line v1.")); + await workspace.file(N7_CODE_FILE, N7_CODE_SOURCE); await buildOk(product, workspace, `${prefix} \`build\` at v1`); const akAtCreate = await queryNode( product, @@ -1129,8 +1185,10 @@ const T10_7_7 = defineProductTest({ // Self-contained payload (SPEC 10.7): scope with subtree text and // source range; context (the ancestor chain) with own texts and - // source ranges; origin with the before/after own-text pair; the - // recorded baseline and current hashes. + // source ranges; origin with the before/after own-text pair AND the + // origin node's current source range (every present payload node + // carries one, SPEC 10.7, 1.7); the recorded baseline and current + // hashes. assertPresentState( item.scope, { @@ -1165,8 +1223,9 @@ const T10_7_7 = defineProductTest({ [N7_AK], `${prefix}: the origin is the changed node (SPEC 10.5)`, ); + const akOrigin = requireOriginEntry(item, N7_AK, prefix); assertOriginPair( - requireOriginEntry(item, N7_AK, prefix), + akOrigin, { before: { present: true, text: akAtBase.ownText }, after: { present: true, text: akAtCreate.ownText }, @@ -1174,6 +1233,11 @@ const T10_7_7 = defineProductTest({ `${prefix} origin pair for a.k — before from the item's baseline ` + `state, after from the current graph`, ); + assertOriginRange( + akOrigin, + akAtCreate.sourceRange, + `${prefix} origin entry a.k`, + ); assertRecordedHolds( item.baseline, akAtBase.hashes.subtreeHash, @@ -1204,6 +1268,170 @@ const T10_7_7 = defineProductTest({ "the baseline subtreeHash", `${prefix} payload \`current\``, ); + + // Walk to the code-impact item (item order: the two spec items + // precede the code location's, whose file path sorts after + // specs/…; `resolve --status no-change` never re-derives, SPEC + // 10.5). The parent's item, unblocked by the leaf's resolve, is + // returned next — its scope a present requirement node with own + // text and source range. + await resolveOk( + product, + workspace, + "s", + item.id, + "no-change", + `${prefix} \`resolve s <a.k's item> --status no-change\``, + ); + const second = requireNextItem( + await nextInSession(product, workspace, "s", `${prefix} second`), + `${prefix} second`, + ); + if ( + second.kind !== "parent-consistency" || + second.scope.node !== N7_A + ) { + fail( + `${prefix}: after resolving a.k's item, the first needing-review ` + + `unblocked item is a's parent-consistency item (SPEC 10.5, ` + + `10.7); got ${second.kind} ${second.scope.node}`, + ); + } + assertPresentState( + second.scope, + { node: N7_A, text: aNow.ownText, sourceRange: aNow.sourceRange }, + `${prefix} second scope (parent-consistency scope text is the ` + + `scope node's own text)`, + ); + await resolveOk( + product, + workspace, + "s", + second.id, + "no-change", + `${prefix} \`resolve s <a's item> --status no-change\``, + ); + + // The code-impact item: its scope is a PRESENT code location — + // identity, presence, and the named unit's construct range, + // byte-asserted against precomputed offsets (review payloads are + // one of the two range-presenting outputs for code locations, + // SPEC 1.7, 4.6), with no text value; its context and origin nodes + // are present requirement nodes carrying their ranges. + const codeItem = requireNextItem( + await nextInSession(product, workspace, "s", `${prefix} code item`), + `${prefix} code item`, + ); + if ( + codeItem.kind !== "code-impact" || + codeItem.scope.node !== N7_CODE + ) { + fail( + `${prefix}: after resolving both spec items, the remaining ` + + `needing-review item is the impacted location's code-impact ` + + `item (SPEC 9.2, 10.5, 10.7); got ` + + `${codeItem.kind} ${codeItem.scope.node}`, + ); + } + const codeLabel = `${prefix} code-impact(${N7_CODE})`; + assertPresentState( + codeItem.scope, + { node: N7_CODE, text: undefined, sourceRange: N7_CODE_RANGE }, + `${codeLabel} scope — a present code location enters the payload ` + + `with its source range (the construct binding the unit's name, ` + + `precomputed byte offsets) and no text (SPEC 10.7, 1.7, 4.6)`, + ); + assertSameJson( + identitySet(codeItem.context), + [N7_AK], + `${codeLabel}: context is the impact-edge target (SPEC 9.2, 10.5)`, + ); + assertPresentState( + requireContextEntry(codeItem, N7_AK, codeLabel), + { + node: N7_AK, + text: akAtCreate.subtreeText, + sourceRange: akAtCreate.sourceRange, + }, + `${codeLabel} context entry a.k — code-impact targets carry ` + + `subtree text, with the present node's source range`, + ); + assertSameJson( + identitySet(codeItem.origin), + [N7_AK], + `${codeLabel}: origin is the originating node (SPEC 5.6, 10.5)`, + ); + const codeItemOrigin = requireOriginEntry(codeItem, N7_AK, codeLabel); + assertOriginPair( + codeItemOrigin, + { + before: { present: true, text: akAtBase.ownText }, + after: { present: true, text: akAtCreate.ownText }, + }, + `${codeLabel} origin pair for a.k`, + ); + assertOriginRange( + codeItemOrigin, + akAtCreate.sourceRange, + `${codeLabel} origin entry a.k`, + ); + + // None for absent nodes (SPEC 10.7, 1.7): delete the code file — + // the location leaves the current graph while the workspace stays + // valid (a zero-source code group, SPEC 7) — and the still- + // unresolved item presents its scope ABSENT: identity and absence + // alone, no text (a code location has none and no recorded state + // supplies one), no source range; the present context and origin + // nodes keep their ranges. Reads never re-derive, so it is the + // same item (SPEC 10.5, 10.7). + await fsp.rm(workspace.path(N7_CODE_FILE)); + await buildOk( + product, + workspace, + `${prefix} \`build\` after the deletion`, + ); + const goneItem = requireNextItem( + await nextInSession(product, workspace, "s", `${prefix} deleted`), + `${prefix} deleted`, + ); + if (goneItem.id !== codeItem.id) { + fail( + `${prefix}: deleting the code file re-derives nothing — \`next\` ` + + `still returns the same unresolved code-impact item (SPEC ` + + `10.5, 10.7); expected ${codeItem.id}, got ${goneItem.id} ` + + `(${goneItem.kind} ${goneItem.scope.node})`, + ); + } + const goneLabel = `${prefix} code-impact(${N7_CODE}) after deletion`; + assertAbsentState( + goneItem.scope, + { node: N7_CODE, text: undefined }, + `${goneLabel} scope — a deleted code location's entry carries no ` + + `source range and no text (SPEC 10.7, 1.7)`, + ); + assertPresentState( + requireContextEntry(goneItem, N7_AK, goneLabel), + { + node: N7_AK, + text: akAtCreate.subtreeText, + sourceRange: akAtCreate.sourceRange, + }, + `${goneLabel} context entry a.k — still present, still ranged`, + ); + const goneOrigin = requireOriginEntry(goneItem, N7_AK, goneLabel); + assertOriginPair( + goneOrigin, + { + before: { present: true, text: akAtBase.ownText }, + after: { present: true, text: akAtCreate.ownText }, + }, + `${goneLabel} origin pair for a.k`, + ); + assertOriginRange( + goneOrigin, + akAtCreate.sourceRange, + `${goneLabel} origin entry a.k`, + ); }, ); }, From ebcf248473a526debecf4536fa0baf0f14f4ced1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 17:12:32 +0000 Subject: [PATCH 081/226] sdg(phase-9): add T11-6 wrong-kind, unknown-unit, and disambiguator arms (FP-036) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 36 ++++++++- test/suite/registry/section-11.ts | 121 +++++++++++++++++++++++++++++- 2 files changed, 154 insertions(+), 3 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 5c7a075..370da85 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1461,13 +1461,47 @@ A "new test T<x>" task always means, in one change: S-5 and certification green; T10.7-7 in no certification scope. Typecheck/format clean.] -- [ ] FP-036 — T11-6: add the wrong-kind / unknown-unit / disambiguator +- [x] FP-036 — T11-6: add the wrong-kind / unknown-unit / disambiguator arms. [R2 #23; TEST-SPEC §11.1] `test/suite/registry/section-11.ts`: (a) `query node`/`show` on a code-group `path`/`path#unit` → exit 2; (b) `query edges --from/--to` and `reachable --from/--to` with `#unspelled-unit` → exit 2; (c) out-of-range `@2`; (d) `@1` unknown at every occurrence count, staged at one and at two occurrences. + [Done 2026-08-13: twelve arms appended to T11-6's existing workspace (no + new files — its code file already stages chain `Box` once and `Box.v` + twice, getter/setter). (a) `query node` and `show` each given + `src/code.ts` and `src/code.ts#Box.v` → 4 arms via `expectUsageError` + (exit 2 exactly + single 12.7 error document under `--json`, the FP-002 + protocol). (b) `#ghost` (no unit spells it) in all four graph-node flag + positions — `edges --from`/`--to`, `reachable --from`/`--to` (valid + `specs/S.mdx#s1` as the counterpart flag). (c)+(d) ride `query edges + --from` (the entry's primary position) behind a new premise control + pinning the once-occurring chain as a spelled unit — `--from + src/code.ts#Box` exit 0 with an EMPTY edge list (the check is parse-local + over named units per TEST-SPEC T11-6, so a spelled unit is never unknown; + empty-at-exit-0 is the module's established valid-identity/no-matching- + edges operationalization), keeping the @-arms sharp (they fail on the + disambiguator, never on an unknown chain): `Box@2` out-of-range, `Box@1` + (once), `Box.v@1` (twice — the discriminating arm: a product resolving + `@1` to the first occurrence would answer the getter's `embeds` edge at + exit 0, an edgeless-graph-node product an empty list at exit 0; the exact + exit-2 assertion forbids both). Title extended; traceability unchanged + (["11.1"] — 12.0/12.4/4.6 are context with home coverage at T12.0-*/ + T12.4-*/T1.7-2; no numbered condition asserted); T11-6's unknown-unit and + `@N` arms are named in CERTIFICATIONS.md's Exclusions (shared machinery), + no fixture scope. Verified red-as-diagnosed by guarded solo executions of + every new arm against the built product: the premise control passes + (probe: `{"edges":[]}` exit 0; `Box.v`/`Box.v@2` resolve to getter embeds + / setter references), and all 11 usage-error arms pass their exact exit-2 + assertion (the product already classifies every one correctly per its + stderr) and fail exactly at the error-document decode ("stdout is empty" + — the FP-002-class product gap, the same first-arm failure T11-6 already + shows at its unknown-path arm, so the new arms are unreached in the suite + until that closes). Section-11 unchanged 4 failed / 3 passed (T11-1/-3/-7 + green); typecheck/format clean; `npm run test:self` unchanged 4 planned + mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys + {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] - [ ] FP-037 — T12.0-1/-3/-4: extend the shared command sweep with the new surfaces. [R2 #24; TEST-SPEC §12.0] diff --git a/test/suite/registry/section-11.ts b/test/suite/registry/section-11.ts index fc025a7..3dc625b 100644 --- a/test/suite/registry/section-11.ts +++ b/test/suite/registry/section-11.ts @@ -1431,7 +1431,8 @@ const T11_5 = defineProductTest({ }); // --------------------------------------------------------------------------- -// T11-6 — identity resolution: bare paths, code units, unknown paths +// T11-6 — identity resolution: bare paths, code units, unknown paths, +// wrong-kind operands, unknown units, and disambiguator range // --------------------------------------------------------------------------- const T11_6_S1 = '<S id="s1">\nS one.\n</S>'; @@ -1462,7 +1463,7 @@ const T11_6_S1_ID = "specs/S.mdx#s1"; const T11_6 = defineProductTest({ id: "T11-6", title: - "identity resolution: a bare `path` resolves to the root node for a spec-group file and to a code location for a code-group file; `path#unit` and `path#unit@N` address code locations (a getter/setter pair as the duplicate unit chain); a path in no configured group is unknown, exit 2 (SPEC 11, 1.5, 4.6, 12.0)", + "identity resolution: a bare `path` resolves to the root node for a spec-group file and to a code location for a code-group file; `path#unit` and `path#unit@N` address code locations (a getter/setter pair as the duplicate unit chain); a path in no configured group is unknown, exit 2; wrong-kind operands — `query node` and `show` given a code-group `path` or `path#unit` — each exit 2; an unspelled unit name on a discovered code source is unknown in every graph-node argument position (`edges --from`/`--to`, `reachable --from`/`--to`), exit 2, as are an out-of-range disambiguator (`@2` on a once-occurring chain) and `@1` at every occurrence count — no occurrence bears `@1`, staged at one and at two occurrences (SPEC 11, 1.5, 4.6, 12.0, 12.4)", run: async (product) => { await withWorkspace( SPEC_AND_CODE_CONFIG, @@ -1578,6 +1579,122 @@ const T11_6 = defineProductTest({ "a path in no configured group (as a `<graph-node>` argument)", "T11-6 `query edges --from docs/N.mdx`", ); + + // Wrong-kind operands (SPEC 12.0; 11.1, 12.4): `query node` and + // `show` take a requirement-node identity, so a code-group `path` or + // `path#unit` — a code source named where a requirement-node identity + // is required — is a usage error for each command, in each form. + for (const operand of ["src/code.ts", "src/code.ts#Box.v"]) { + await expectUsageError( + product, + workspace, + ["query", "node", operand], + "a code source named where a requirement-node identity is " + + "required (wrong-kind operand)", + `T11-6 \`query node ${operand}\``, + ); + await expectUsageError( + product, + workspace, + ["show", operand], + "a code source named where a requirement-node identity is " + + "required (wrong-kind operand)", + `T11-6 \`show ${operand}\``, + ); + } + + // Unknown code units (SPEC 12.0, 4.6): the check is judged + // parse-local over the named file's named units, so a unit name no + // unit of the discovered code source spells is unknown in every + // graph-node argument position. + const unspelled = "src/code.ts#ghost"; + await expectUsageError( + product, + workspace, + ["query", "edges", "--from", unspelled], + "an unspelled unit name on a discovered code source", + `T11-6 \`query edges --from ${unspelled}\``, + ); + await expectUsageError( + product, + workspace, + ["query", "edges", "--to", unspelled], + "an unspelled unit name on a discovered code source", + `T11-6 \`query edges --to ${unspelled}\``, + ); + await expectUsageError( + product, + workspace, + ["query", "reachable", "--from", unspelled, "--to", T11_6_S1_ID], + "an unspelled unit name on a discovered code source", + `T11-6 \`query reachable --from ${unspelled} --to ${T11_6_S1_ID}\``, + ); + await expectUsageError( + product, + workspace, + ["query", "reachable", "--from", T11_6_S1_ID, "--to", unspelled], + "an unspelled unit name on a discovered code source", + `T11-6 \`query reachable --from ${T11_6_S1_ID} --to ${unspelled}\``, + ); + + // Disambiguator-range premise (SPEC 4.6): the chain `Box` — the + // class declaration — occurs exactly once in the file, so the bare + // `src/code.ts#Box` IS a spelled named unit: a valid graph-node + // identity whose edge answer is empty at exit 0 (both staged + // references lie in the getter and setter, the innermost enclosing + // units, so no edge has `Box` itself as an endpoint). Pinning this + // keeps the `@`-arms below sharp — they fail on the disambiguator, + // never on an unknown chain. + const boxLabel = + "T11-6 `query edges --from src/code.ts#Box` — the once-occurring " + + "chain is a spelled unit (its sole occurrence's identity is the " + + "bare `path#unit`, SPEC 4.6), answered with an empty edge list"; + const boxEdges = decodeEdgesReport( + await runJson( + product, + workspace, + ["query", "edges", "--from", "src/code.ts#Box", "--json"], + boxLabel, + ), + boxLabel, + ); + assertEdgeSetEqual(boxEdges, [], boxLabel); + + // An out-of-range disambiguator is equally unknown (SPEC 4.6, 12.0): + // `@2` names a second occurrence, and the chain `Box` has only one. + await expectUsageError( + product, + workspace, + ["query", "edges", "--from", "src/code.ts#Box@2"], + "an out-of-range disambiguator (`@2` where the chain occurs once)", + "T11-6 `query edges --from src/code.ts#Box@2`", + ); + + // `@1` is unknown at EVERY occurrence count (SPEC 4.6, 12.0): 4.6 + // suffixes only occurrences after the first, so no occurrence bears + // `@1` — the first occurrence's identity is the bare `path#unit`, + // and identities compare byte-wise. Staged where the chain occurs + // once (`Box`) and where it occurs twice (`Box.v`); the + // two-occurrence arm discriminates a product that resolves `@1` to + // the first occurrence — the getter, whose resolved answer would be + // its `embeds` edge at exit 0 — and one answering a bare edgeless + // graph node's empty list at exit 0: the exact exit-2 assertion + // (H-5) forbids both. + await expectUsageError( + product, + workspace, + ["query", "edges", "--from", "src/code.ts#Box@1"], + "`@1` on a once-occurring chain (no occurrence bears `@1`)", + "T11-6 `query edges --from src/code.ts#Box@1`", + ); + await expectUsageError( + product, + workspace, + ["query", "edges", "--from", "src/code.ts#Box.v@1"], + "`@1` on a twice-occurring chain (no occurrence bears `@1`; the " + + "first occurrence's identity is the bare `path#unit`, byte-wise)", + "T11-6 `query edges --from src/code.ts#Box.v@1`", + ); }, ); }, From 2eb5d3d051d83086347bd2823d084a7a4c573b04 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 17:33:15 +0000 Subject: [PATCH 082/226] sdg(phase-9): extend the 12.0 command sweep with the new surfaces (FP-037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T12.0-1/-3/-4's shared full-surface sweep gains occurrences, view, at, inventory, and version as clean-domain read steps, and T12.0-1 gains the JSON-only parity arm: the surfaces of SPEC 10.7, 11, and 12.6 (the query subcommands, the five new surfaces, review export) rerun without --json and must emit the same single document — byte-identical stdout at the same exit code (product-to-itself, H-4). Red-as-diagnosed: T12.0-1 green -> red at `occurrences --json` (exit 2 unknown command, the new-surface product gap), query parity arms passing before it; T12.0-3 red at the same step; T12.0-4 keeps its pre-existing FP-002-class first failure. test:self unchanged: 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 31 +++++++- test/suite/registry/section-12.0-i.ts | 108 +++++++++++++++++++++++--- 2 files changed, 128 insertions(+), 11 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 370da85..0ce0bf2 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1503,12 +1503,41 @@ A "new test T<x>" task always means, in one change: mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-037 — T12.0-1/-3/-4: extend the shared command sweep with the new +- [x] FP-037 — T12.0-1/-3/-4: extend the shared command sweep with the new surfaces. [R2 #24; TEST-SPEC §12.0] `test/suite/registry/section-12.0-i.ts` `SWEEP_STEPS` (~line 235): add `occurrences`, `view`, `at`, `inventory`, `version`. T12.0-1 must also assert the JSON-only surfaces emit the same single document with the `--json` flag as without. + [Done 2026-08-13: SWEEP_STEPS gains the five surfaces after the query + family (mutations still last) — `occurrences`, `view`, `at specs/A.mdx 0`, + `inventory`, `version`, each a clean-domain read over the valid story + workspace (complete finding-free answer, exit 0; 11.2/11.6/12.6) — so all + three sweep users drive them with `--json` (T12.0-1), with a cwd-relative + `--config` (T12.0-3; accepted-not-consulted on version, 12.6), and under + T12.0-4's doubled-`--config` exit-2 protocol. T12.0-1's parity arm: + SweepStep gains a `jsonOnly` marker on the 12 JSON-only steps (the six + query subcommands, the five new surfaces, review export — SPEC 11 + preamble, 12.6, 10.7), and under assertJsonOnlyParity (T12.0-1 only) each + such step reruns without `--json` asserting exit 0, a single JSON document + as the entire stdout (H-5's JSON-only clause), and stdout byte-identical + to the flagged run's — "the same single document" operationalized as a + product-to-itself byte compare (H-4; module header documents the reading: + a single document as the surface's ONLY output form makes the flag inert). + Verified: T12.0-1 turned green → red-as-diagnosed exactly at + `occurrences --json` (exit 2 "unknown command" — the new-surface product + gap), the six query parity arms passing before it (probe: the product + already emits byte-identical stdout with/without the flag on query node + and review export, so the parity assertion is satisfiable); T12.0-3 now + red at the same step of its sweep (previously a later FP-002-class arm); + T12.0-4 keeps its pre-existing first failure (doubled-`--config` + error-document decode on build), new steps unreached there; + T12.0-2/-5/-6 unchanged. Typecheck/format clean; traceability unchanged + (all three ["12.0"]; 10.7/11/12.6 are context with home coverage at stage + G); T12.0-1/-3/-4 in no certification scope. `npm run test:self` + unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; + S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and + certification green.] - [ ] FP-038 — T12.0-9: add the new exit-partition representatives. [R2 #25; TEST-SPEC §12.0] diff --git a/test/suite/registry/section-12.0-i.ts b/test/suite/registry/section-12.0-i.ts index a594530..f5fdb73 100644 --- a/test/suite/registry/section-12.0-i.ts +++ b/test/suite/registry/section-12.0-i.ts @@ -23,15 +23,26 @@ // // The full-surface sweep (T12.0-1, T12.0-3, T12.0-4) drives every command and // subcommand this specification covers over one evolving fixture story: -// build, check, ids, show, coverage, impact, the six query subcommands, the -// eight review subcommands, rename, and file-form move — mutations last, so -// every step runs at a state its arguments are valid in. +// build, check, ids, show, coverage, impact, the six query subcommands, +// occurrences, view, at, inventory, version, the eight review subcommands, +// rename, and file-form move — mutations last, so every step runs at a state +// its arguments are valid in. // // Conservative operationalizations (noted per H-3/H-4): // - T12.0-1 asserts, per command, the specified exit code and that the entire // stdout parses as exactly one JSON document; information parity with the // human report is adapter-verified by the per-command tests in the sections // above (the test's own text delegates it there). +// - T12.0-1's JSON-only parity arms: the JSON-only surfaces of 10.7, 11, and +// 12.6 — review export; the query subcommands, occurrences, view, at, and +// inventory; version — emit the same single document with the flag as +// without. Each such step (all reads, so rerunnable) is rerun without +// `--json` at the same story state, asserting the same exit code (0), a +// single JSON document as the entire stdout (H-5's JSON-only clause), and +// stdout byte-identical to the flagged run's: a single JSON document is +// the surface's ONLY output form with or without the flag, so the flag is +// inert there and "the same single document" is a product-to-itself byte +// comparison (H-4). // - T12.0-4 doubles `--config` with an identical value across the whole sweep // — a repetition regardless of value, and the strictest probe (it fails a // product that dedupes repeated identical values). Each doubled run's argv @@ -195,6 +206,13 @@ interface SweepStep { readonly argv: (state: SweepState) => readonly string[]; /** Harvest from the step's decoded `--json` document. */ readonly harvest?: (doc: unknown, state: SweepState, context: string) => void; + /** + * The step drives a JSON-only surface (SPEC 10.7, 11, 12.6): a single JSON + * document is its only output form, with or without `--json`. T12.0-1's + * parity arm reruns the step without the flag and asserts the same single + * document (byte-identical stdout; see the module header). + */ + readonly jsonOnly?: true; } /** A harvested id the story guarantees is set by the time it is consumed. */ @@ -247,11 +265,23 @@ const SWEEP_STEPS: readonly SweepStep[] = [ { what: "show", argv: () => ["show", SWEEP_ALPHA] }, { what: "coverage", argv: () => ["coverage"] }, { what: "impact", argv: (state) => ["impact", "--base", state.baseRef] }, - { what: "query node", argv: () => ["query", "node", SWEEP_ALPHA] }, - { what: "query nodes", argv: () => ["query", "nodes"] }, - { what: "query edges", argv: () => ["query", "edges"] }, - { what: "query subtree", argv: () => ["query", "subtree", SWEEP_ALPHA] }, - { what: "query ancestors", argv: () => ["query", "ancestors", SWEEP_KID] }, + { + what: "query node", + argv: () => ["query", "node", SWEEP_ALPHA], + jsonOnly: true, + }, + { what: "query nodes", argv: () => ["query", "nodes"], jsonOnly: true }, + { what: "query edges", argv: () => ["query", "edges"], jsonOnly: true }, + { + what: "query subtree", + argv: () => ["query", "subtree", SWEEP_ALPHA], + jsonOnly: true, + }, + { + what: "query ancestors", + argv: () => ["query", "ancestors", SWEEP_KID], + jsonOnly: true, + }, { what: "query reachable", argv: () => [ @@ -262,7 +292,16 @@ const SWEEP_STEPS: readonly SweepStep[] = [ "--to", SWEEP_OMEGA, ], + jsonOnly: true, }, + // The JSON-only read surfaces of SPEC 11.3–11.6 and 12.6 — clean-domain + // invocations over the valid story workspace, so each is a complete, + // finding-free answer, exit 0 (11.2, 11.6, 12.6). + { what: "occurrences", argv: () => ["occurrences"], jsonOnly: true }, + { what: "view", argv: () => ["view"], jsonOnly: true }, + { what: "at", argv: () => ["at", SWEEP_FILE, "0"], jsonOnly: true }, + { what: "inventory", argv: () => ["inventory"], jsonOnly: true }, + { what: "version", argv: () => ["version"], jsonOnly: true }, { what: "review create", argv: () => [ @@ -281,6 +320,7 @@ const SWEEP_STEPS: readonly SweepStep[] = [ what: "review export", argv: () => ["review", "export", SWEEP_SESSION], harvest: harvestItemIds, + jsonOnly: true, }, { what: "review show", @@ -341,6 +381,13 @@ interface SweepStoryOptions { readonly extraFlags?: readonly string[]; /** Runs before each step (T12.0-4's repeated-flag variant). */ readonly beforeStep?: (step: SweepStep, state: SweepState) => Promise<void>; + /** + * T12.0-1's parity arm: rerun each JSON-only step (SPEC 10.7, 11, 12.6) + * without `--json` and assert it emits the same single document — same + * exit code, one JSON document as the entire stdout, byte-identical to the + * flagged run's (product-to-itself, H-4; see the module header). + */ + readonly assertJsonOnlyParity?: boolean; /** Test id labelling every diagnosis (e.g. "T12.0-1"). */ readonly label: string; } @@ -372,6 +419,41 @@ async function runSweepStory(options: SweepStoryOptions): Promise<void> { `${context} — under --json the single JSON document is the entire ` + `standard output (SPEC 12.0, H-5)`, ); + if (options.assertJsonOnlyParity === true && step.jsonOnly === true) { + // All JSON-only steps are reads, so the rerun observes the same story + // state the flagged run did and evolves nothing. + const bareArgv = [ + ...step.argv(options.state), + ...(options.extraFlags ?? []), + ]; + const bareContext = + `${options.label} \`${bareArgv.join(" ")}\` ` + + `(JSON-only surface, no --json)`; + const bare = await expectExit( + options.product, + options.workspace, + bareArgv, + 0, + `${bareContext} — ${step.what} is a JSON-only surface (SPEC 10.7, ` + + `11, 12.6), and the output form never changes an exit code ` + + `(SPEC 12.0)`, + ); + parseJsonStdout( + bare, + `${bareContext} — on a JSON-only surface a single JSON document is ` + + `the entire standard output with or without --json (SPEC 10.7, ` + + `11, 12.6, H-5)`, + ); + assertBytesEqual( + bare.stdoutBytes, + result.stdoutBytes, + `${bareContext} — the JSON-only surfaces of 10.7, 11, and 12.6 emit ` + + `the same single document with the flag as without: a single JSON ` + + `document is the surface's only output form, so the flag is inert ` + + `and stdout is byte-identical across the pair (SPEC 10.7, 11, ` + + `12.6; product-to-itself, H-4)`, + ); + } step.harvest?.(doc, options.state, context); } } @@ -383,12 +465,18 @@ async function runSweepStory(options: SweepStoryOptions): Promise<void> { const T12_0_1 = defineProductTest({ id: "T12.0-1", title: - "`--json` everywhere: every command and subcommand — build, check, ids, show, coverage, impact, all six query subcommands, all eight review subcommands, rename, and file-form move — accepts the flag and emits exactly one JSON document as the entire standard output at its specified exit code; information parity with the human report is adapter-verified per command by the per-section tests (SPEC 12.0)", + "`--json` everywhere: every command and subcommand — build, check, ids, show, coverage, impact, all six query subcommands, occurrences, view, at, inventory, version, all eight review subcommands, rename, and file-form move — accepts the flag and emits exactly one JSON document as the entire standard output at its specified exit code; the JSON-only surfaces of 10.7, 11, and 12.6 (review export; the query subcommands, occurrences, view, at, and inventory; version) emit the same single document with the flag as without — byte-identical stdout at the same exit code (product-to-itself, H-4); information parity with the human report is adapter-verified per command by the per-section tests (SPEC 12.0, 11, 12.6, 10.7)", timeoutMs: 240_000, run: async (product) => { const { workspace, state } = await createSweepWorkspace(); try { - await runSweepStory({ product, workspace, state, label: "T12.0-1" }); + await runSweepStory({ + product, + workspace, + state, + assertJsonOnlyParity: true, + label: "T12.0-1", + }); } finally { await workspace.dispose(); } From 797f29e2a077ca09ff365c473446c9b8e2d61cc6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 17:48:04 +0000 Subject: [PATCH 083/226] sdg(phase-9): add T12.0-9's new exit-partition representatives (FP-038) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 43 ++++++- test/suite/registry/section-12.0-ii.ts | 171 ++++++++++++++++++++++++- 2 files changed, 206 insertions(+), 8 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 0ce0bf2..5a357fe 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1539,12 +1539,53 @@ A "new test T<x>" task always means, in one change: S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-038 — T12.0-9: add the new exit-partition representatives. +- [x] FP-038 — T12.0-9: add the new exit-partition representatives. [R2 #25; TEST-SPEC §12.0] Exit 0: `version`, clean `occurrences`/`view`/`at`, `inventory`, successful previews. Exit 1: refused previews and answers carrying findings/explicitly-unavailable data (emitted in full). Exit 2: wrong-kind operands and the `occurrences --to` malformed-only exception. + [Done 2026-08-13 (`test/suite/registry/section-12.0-ii.ts`): exit-0 rows + on the story workspace after its exit-0 build (the clean-domain premise) — + `version`, `occurrences`, `occurrences --to specs/NoSuch.mdx#nope` (the + exception's accepted side: well-formed unknown selects the empty set, + exit 0), `view`, `at specs/A.mdx 0`, `inventory`, and the successful + rename preview `alpha → gamma`; exit-1 rows — refused rename and move + previews beside their real refused twins (SPEC 6.6 refusal equivalence on + the same state), and `occurrences`/`view`/`at` answering on the findings + workspace (id-less `specs/U.mdx` staged beside the unresolved-reference + file: findings 14.5 + 14.1, the section's identity explicitly + unavailable), each such row asserting emitted-in-full at H-5's protocol + grain via the new `emitsAnswer` row marker (stdout exactly one JSON + document — T11.2-5 pins the full-answer contract); exit-2 rows — + `occurrences --to a#b..c` (the exception's usage-error side: an empty + segment), and a new wrong-kind workspace (SPEC_AND_CODE_CONFIG, + reference-free `src/app.ts`) with `show src/app.ts` (code source where a + requirement-node identity is required) and `view src/app.ts` (where a + spec source is required), discovery pinned by the premise probe `query + edges --from src/app.ts` → empty answer exit 0 (an unconfigured path + would be unknown, exit 2). Title updated to the refreshed TEST-SPEC + entry; module-header operationalization note extended (premise probes; + emitted-in-full grain; previews exit-code-only, T6.6-* owning + modifies-nothing/content). Traceability unchanged (["12.0"] — 11.2/11.6/ + 6.6/12.6 are context with home coverage at stages G/H); no certification + scope. Verified: T12.0-9 turned green → red-as-diagnosed exactly at the + first new row (`version` → exit 2 "unknown command", the new-surface + product gap); later arms unreached in the suite, proven sound by direct + probes against the built product — wrong-kind arm: build exit 0, edges + premise `{"edges":[]}` exit 0, `show src/app.ts` exit 2 with the + wrong-kind diagnosis (genuinely green today); findings arm: build/check + exit 1 with exactly the staged 14.5 + 14.1; all three `--preview` rows + exit 2 "unknown flag", `occurrences`/`view`/`at`/`inventory`/`version` + exit 2 "unknown command" — every 0/1-class row fails as diagnosed until + the product grows the surfaces, and the two coincidental exit-2 matches + (`view src/app.ts`, malformed `--to`) are toothed jointly by the same + surfaces' 0/1-class rows plus T11.4-2/T11.3-3's full semantics. + Section-12.0-ii now 4 passed / 1 failed (T12.0-9 the one red; + T12.0-7/-8/-11/-12 stay green); typecheck/format clean; `npm run + test:self` unchanged 4 planned mid-loop reds (certification-document ×3 → + FP-091; S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and + certification green.] - [ ] FP-039 — T12.0-10: implement the precedence arms (test stops being alias-only). [R2 #26; TEST-SPEC §12.0] diff --git a/test/suite/registry/section-12.0-ii.ts b/test/suite/registry/section-12.0-ii.ts index aabfd78..ad33ec9 100644 --- a/test/suite/registry/section-12.0-ii.ts +++ b/test/suite/registry/section-12.0-ii.ts @@ -47,9 +47,15 @@ // - T12.0-9 asserts exact exit codes (the partition is the contract under // test); stream separation is T12.0-2's. Rows whose class is only // meaningful under a premise (impact *with differences*, coverage with an -// uncovered node, fully-resolved `next`, a *blocked* resolve) carry a -// light adapter-decoded premise probe so the asserted exit code is -// attributable to its class. +// uncovered node, fully-resolved `next`, a *blocked* resolve, a code +// source *discovered* so a wrong-kind exit 2 is attributable to operand +// kind rather than to an unconfigured path) carry a light premise probe so +// the asserted exit code is attributable to its class. The class-1 +// "answers carrying findings or explicitly-unavailable data — emitted in +// full" rows assert emission at H-5's protocol grain — stdout parses as +// exactly one JSON document (the 11.2 surfaces are JSON-only) — T11.2-5 +// pinning the full-answer contract; preview rows assert exit codes only, +// T6.6-* owning modifies-nothing and report content. // - T12.0-11 partitions a whole-workspace byte diff around each git-reading // invocation: any change under `.git/` fails (same file set, same bytes), // and every change outside it must be a write the command's own @@ -825,6 +831,15 @@ interface PartitionRow { readonly what: string; readonly argv: readonly string[]; readonly expect: 0 | 1 | 2; + /** + * Assert the answer document is still emitted beside the exit code: stdout + * parses as exactly one JSON document. For the class-1 rows of the 11.2 + * surfaces (JSON-only, SPEC 11), whose class is "answers carrying findings + * or explicitly-unavailable data — emitted in full": exit 1 signals + * imperfection and never withholds the answer (SPEC 11.2), asserted here + * at H-5's protocol grain — T11.2-5 pins the full-answer contract. + */ + readonly emitsAnswer?: true; } async function runPartitionRows( @@ -833,7 +848,7 @@ async function runPartitionRows( rows: readonly PartitionRow[], ): Promise<void> { for (const row of rows) { - await expectExit( + const result = await expectExit( product, workspace, row.argv, @@ -842,13 +857,23 @@ async function runPartitionRows( `partition all outcomes, and this outcome is in the ` + `${String(row.expect)} class (SPEC 12.0)`, ); + if (row.emitsAnswer === true) { + parseJsonStdout( + result, + `T12.0-9 \`${row.argv.join(" ")}\` — ${row.what}: the answer is ` + + `emitted in full beside exit ${String(row.expect)} — exit 1 ` + + `signals imperfection and never withholds the answer, and the ` + + `surface is JSON-only, so stdout is exactly one JSON document ` + + `(SPEC 11.2, 11, H-5)`, + ); + } } } const T12_0_9 = defineProductTest({ id: "T12.0-9", title: - "exit-code partition: a table-driven sweep asserting one representative per class per command family — 0 for success and informational reports (`ids`, `show`, `impact` with differences, `query`, review reads including fully-resolved `next`, `coverage` without `--check`); 1 for findings (failing `build`, `check` findings, `coverage --check` uncovered, refused `rename`/`move`, refused review operations, corrupt-session reports); 2 for usage and configuration errors (unknown command/flag, missing required flag and argument, invalid flag value, unknown profile/session/group/item/node/file, invalid session name, configuration errors, unreadable baseline, mutual-exclusion refusal) (SPEC 12.0)", + "exit-code partition: a table-driven sweep asserting one representative per class per command family — 0 for success and informational reports (`ids`, `show`, `impact` with differences, `query`, review reads including fully-resolved `next`, `coverage` without `--check`, `version`, and complete finding-free answers: `occurrences`/`view`/`at` over a clean domain, `inventory`, a successful preview); 1 for findings (failing `build`, `check` findings, `coverage --check` uncovered, refused `rename`/`move` and their refused previews, refused review operations, corrupt-session reports, and answers carrying findings or explicitly-unavailable data — emitted in full); 2 for usage and configuration errors (unknown command/flag, missing required flag and argument, invalid flag value, unknown profile/session/group/item/node/file — except `occurrences --to`, where only a malformed spelling is a usage error — wrong-kind operands: a code source where a spec source or a requirement-node identity is required, invalid session name, configuration errors, unreadable baseline, mutual-exclusion refusal) (SPEC 12.0, 11.2, 11.6, 6.6, 12.6)", timeoutMs: 360_000, run: async (product) => { // --- The valid story workspace: informational, refusal, and usage rows. @@ -1066,6 +1091,44 @@ const T12_0_9 = defineProductTest({ argv: ["coverage"], expect: 0, }, + // Complete finding-free answers over the clean domain (SPEC 11.2): + // the premise — every discovered source finding-free — is the + // staging `build`'s exit 0 above. + { + what: "workspace-independent `version` (SPEC 12.6)", + argv: ["version"], + expect: 0, + }, + { + what: "complete finding-free `occurrences` answer over the clean domain (SPEC 11.2, 11.3)", + argv: ["occurrences"], + expect: 0, + }, + { + what: "`occurrences --to` accepts a well-formed unknown identity — unknown is not a usage error on this filter, the selection empty over the finding-free domain (SPEC 11.3)", + argv: ["occurrences", "--to", "specs/NoSuch.mdx#nope"], + expect: 0, + }, + { + what: "complete finding-free `view` answer over the clean domain (SPEC 11.2, 11.4)", + argv: ["view"], + expect: 0, + }, + { + what: "complete finding-free `at` answer over the clean domain (SPEC 11.2, 11.5)", + argv: ["at", STORY_FILE_A, "0"], + expect: 0, + }, + { + what: "finding-free `inventory` (SPEC 11.6)", + argv: ["inventory"], + expect: 0, + }, + { + what: "successful preview — the real rename would proceed (`gamma` claimed by nothing in the fixture), so its `--preview` succeeds, modifying nothing (SPEC 6.6)", + argv: ["rename", STORY_FILE_A, "alpha", "gamma", "--preview"], + expect: 0, + }, // 1 — findings. { what: "`coverage --check` with uncovered requirements", @@ -1082,6 +1145,19 @@ const T12_0_9 = defineProductTest({ argv: ["move", STORY_FILE_A, STORY_FILE_B], expect: 1, }, + // Refused previews: a preview is refused exactly when the real + // operation would be (SPEC 6.6) — each twin rides the refusal its + // real row above just demonstrated on this same state. + { + what: "refused rename preview (the same ID collision as the real refusal, SPEC 6.6, 6.4)", + argv: ["rename", STORY_FILE_A, "alpha", "omega", "--preview"], + expect: 1, + }, + { + what: "refused move preview (the same occupied destination as the real refusal, SPEC 6.6, 6.5)", + argv: ["move", STORY_FILE_A, STORY_FILE_B, "--preview"], + expect: 1, + }, { what: "refused review operation (resolving a blocked item, SPEC 10.7)", argv: [ @@ -1159,6 +1235,11 @@ const T12_0_9 = defineProductTest({ argv: ["show", "specs/NoSuch.mdx"], expect: 2, }, + { + what: "`occurrences --to` malformed spelling (an empty segment) — the exception to the unknown class: on this filter only a malformed spelling is a usage error, the well-formed unknown row above exiting 0 (SPEC 11.3)", + argv: ["occurrences", "--to", "a#b..c"], + expect: 2, + }, { what: "invalid session name (a leading `.`, SPEC 10.1)", argv: ["review", "create", "--strategy", "audit", "--name", ".bad"], @@ -1226,7 +1307,11 @@ const T12_0_9 = defineProductTest({ }, ); - // --- Findings (exit 1): failing build and check over invalid sources. + // --- Findings (exit 1): failing build and check over invalid sources, + // and the 11.2 surfaces answering on the same failing workspace — the + // domain's findings accompany, an id-less section's identity is + // explicitly unavailable (SPEC 11.2), and each answer is emitted in + // full beside its exit 1 (`emitsAnswer`). await withWorkspace( { files: { @@ -1237,12 +1322,20 @@ const T12_0_9 = defineProductTest({ "</S>", "", ].join("\n"), + // A parseable section spelling no identity: its 14.1 finding and + // its explicitly-unavailable identity ride the answers below. + "specs/U.mdx": [ + "<S>", + "Section spelling no identity.", + "</S>", + "", + ].join("\n"), }, }, async (invalidWorkspace) => { await runPartitionRows(product, invalidWorkspace, [ { - what: "failing `build` (an unresolved reference, SPEC 14.5)", + what: "failing `build` (an unresolved reference, SPEC 14.5; a missing ID, SPEC 14.1)", argv: ["build"], expect: 1, }, @@ -1251,6 +1344,70 @@ const T12_0_9 = defineProductTest({ argv: ["check"], expect: 1, }, + { + what: "`occurrences` answer carrying the consulted domain's findings — emitted in full (SPEC 11.2, 11.3)", + argv: ["occurrences"], + expect: 1, + emitsAnswer: true, + }, + { + what: "`view` answer carrying findings and an explicitly-unavailable identity (the id-less section) — emitted in full (SPEC 11.2, 11.4)", + argv: ["view"], + expect: 1, + emitsAnswer: true, + }, + { + what: "`at` answer carrying an explicitly-unavailable identity and its file's finding — emitted in full (SPEC 11.2, 11.5)", + argv: ["at", "specs/U.mdx", "0"], + expect: 1, + emitsAnswer: true, + }, + ]); + }, + ); + + // --- Wrong-kind operands (exit 2, SPEC 12.0): a code source named where + // a spec source or a requirement-node identity is required. The premise + // probe pins `src/app.ts` as discovered: `query edges --from` on it + // answers an edgeless known graph node with an empty answer, exit 0, + // where a path in no configured group would be unknown, exit 2 (SPEC + // 11.1) — so the rows' exit 2 is attributable to operand kind alone. + await withWorkspace( + { + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + "specs/A.mdx": ['<S id="alpha">', "Alpha text.", "</S>", ""].join( + "\n", + ), + // Valid, reference-free TypeScript: discovered through the code + // group's glob, bearing no requirement nodes (SPEC 7.2). + "src/app.ts": "export function noop(): void {}\n", + }, + }, + async (kindWorkspace) => { + await buildOk(product, kindWorkspace, "T12.0-9 wrong-kind-arm `build`"); + await expectExit( + product, + kindWorkspace, + ["query", "edges", "--from", "src/app.ts"], + 0, + "T12.0-9 wrong-kind-arm premise `query edges --from src/app.ts` — " + + "the reference-free code source is discovered, a known graph " + + "node answering an empty edge set (SPEC 11.1, 7.2), so the " + + "wrong-kind rows are attributable to operand kind, not to an " + + "unconfigured path", + ); + await runPartitionRows(product, kindWorkspace, [ + { + what: "wrong-kind operand: a code source named where a requirement-node identity is required (`show`, SPEC 12.4, 12.0)", + argv: ["show", "src/app.ts"], + expect: 2, + }, + { + what: "wrong-kind operand: a code source named where a spec source is required (`view`, SPEC 11.4, 12.0)", + argv: ["view", "src/app.ts"], + expect: 2, + }, ]); }, ); From 5a7db4809820cc65abe6bed44aa4ef6381330287 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 18:11:25 +0000 Subject: [PATCH 084/226] sdg(phase-9): implement T12.0-10's own precedence arms (FP-039) T12.0-10 stops being alias-only: register the gated-read (failing workspace + valid-twin byte-compare), masking, past-the-gate corrupt-session, and within-class-2 no-configuration / configuration-error-precedes arms in section-12.0-ii.ts; the rename/move/baseline arms stay cross-references to T6.4-4/T6.5-5/ T6.3-4. H-7 map gains "T12.0-10": ["12.0"] and the alias comment is updated per FP-003's note. Red-as-diagnosed at the failing-premise form-exact findings decode (FP-001-class); direct probes prove all later arms sound and expose two real precedence gaps (parse-local checks gated; malformed value reported after configuration load). test:self unchanged: 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 50 ++- test/suite/registry/section-12.0-ii.ts | 497 ++++++++++++++++++++++++- test/suite/registry/traceability.ts | 11 +- 3 files changed, 540 insertions(+), 18 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 5a357fe..c5689a6 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1587,13 +1587,61 @@ A "new test T<x>" task always means, in one change: FP-091; S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-039 — T12.0-10: implement the precedence arms (test stops being +- [x] FP-039 — T12.0-10: implement the precedence arms (test stops being alias-only). [R2 #26; TEST-SPEC §12.0] Add: gated-read usage-error precedence on a failing workspace with the valid-twin comparison; the `show <unparseable>#id` masking arm; the past-the-gate corrupt-session `resolve` arm; the within-class-2 no-configuration arms; the configuration-error-precedes arm. Update its H-7 entry (see FP-003's note). + [Done 2026-08-13 (`test/suite/registry/section-12.0-ii.ts`): T12.0-10 + registered with the four own-arm groups; the rename/move/baseline arms + stay cross-references to T6.4-4/T6.5-5/T6.3-4 (module header rewritten; + traceability map comment updated per FP-003's note; entry + `"T12.0-10": ["12.0"]` — the FP-016 precedent: in no TEST-SPEC 14 + staging record, so no "14"). Gated reads: a failing workspace (valid + A.mdx + src/app.ts with named unit `known` + unparseable Broken.mdx as + the premise-pinned single 14.20) and a valid twin identical in + everything the six checks consult; rows unknown-profile, code-group + `--group`, unknown-session, `show A.mdx#unspelled`, `query node + src/app.ts`, `edges --from src/app.ts#unspelled` each exit 2 with + stdout exactly the 12.7 error document ("reports no validation + findings" at H-5's protocol grain), stderr nonempty, failing/twin + documents byte-identical ("the same exit-2 errors", H-4 + product-to-itself; stderr wording left free per the module-header + operationalization note); five twin controls pin every name as + resolving. Masking: `show Broken.mdx#broken` exit 1, form-exact + findings report = exactly the one 14.20 located in Broken.mdx (the file + contains `id="broken"`, failing scrape-and-answer and unknown-id + products both ways). Past the gate: product-written audit session + corrupted by garbage overwrite; premise `resolve corrupt <no-such-item>` + exit 2 pre-corruption (T10.7-10's contract), the same argv exit 1 with + /corrupt/i post-corruption (T10.1-4's operationalization). Within class + 2: unknown command, `ids --json --json`, `show a#b#c` on invalid-config + and missing-config workspaces — exit 2, plain usage error (code and + path null, 12.7), byte-identical documents across the two states — then + `coverage no-such-profile` under invalid configuration via + expectConfigurationError (14.14, not the unknown profile). Verified: + suite file 12.0-ii 2 failed / 4 passed (T12.0-9 the pre-existing red; + T12.0-10 red-as-diagnosed at the failing-premise form-exact findings + decode — the FP-001-class gap); later arms unreached in the suite, + proven sound by direct probes against the built product: all five twin + controls exit 0; failing rows 1–3 already exit 2 (red only at the + FP-002-class empty exit-2 stdout) while rows 4–6 expose a real + precedence gap (the product gates the parse-local identity/kind/unit + checks — exit 1 where the arms demand 2); twin rows exit 2 with the + right classifications; masking exits 1 with the gated report (old + shape); the corrupt arm runs genuinely green end to end; syntax rows + 1–2 already precede configuration (identical stderr across states, red + at the missing error document) while `show a#b#c` exposes the second + real gap (the product reports the configuration error, not the + malformed value); config-error precedence already holds (red at the + empty stdout alone). Typecheck/format clean; `npm run test:self` + unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; + S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and + certification green. No certification scope: T12.0-10 sits in + CERTIFICATIONS.md's Exclusions only, which name exactly the in-test + valid-twin comparison this body carries.] - [ ] FP-040 — T12.0-12: extend the git-less sweep. [R2 #27; TEST-SPEC §12.0] diff --git a/test/suite/registry/section-12.0-ii.ts b/test/suite/registry/section-12.0-ii.ts index ad33ec9..c1d3995 100644 --- a/test/suite/registry/section-12.0-ii.ts +++ b/test/suite/registry/section-12.0-ii.ts @@ -1,15 +1,14 @@ // TEST-SPEC §12.0 II (global command conventions, second half) — SUITE-42: -// T12.0-7, T12.0-8, T12.0-9, T12.0-11, T12.0-12. +// T12.0-7, T12.0-8, T12.0-9, T12.0-10, T12.0-11, T12.0-12. // -// T12.0-10 (check ordering) is a pure cross-reference in TEST-SPEC — "Covered -// by T6.4-4/T6.5-5 (rename/move existence checks precede source validation; -// unparseable-file masking flips to exit 1) and T6.3-4's precedence arm -// (baseline resolution precedes source validation)" — so no separate body is -// registered here: its content runs as the ordering/masking arms of -// section-6.4.ts, section-6.5.ts, and section-6.3.ts, and the H-7 map ties -// SPEC 12.0's ordering bullet to those tests. A registered T12.0-10 body -// would either re-run those bodies (duplicated execution) or pass vacuously -// against the stub, violating H-8. +// T12.0-10's rename/move and baseline arms stay cross-references in +// TEST-SPEC ("Rename/move and baseline arms: T6.4-4/T6.5-5 (existence, +// kind, and masking) and T6.3-4"): that content runs as the ordering/masking +// arms of section-6.4.ts, section-6.5.ts, and section-6.3.ts — the H-7 map +// keeps "12.0" on those three — and a re-registration here would re-run +// those bodies (duplicated execution). The gated-read, masking, +// past-the-gate, and within-class-2 precedence arms are T12.0-10's own +// registered body below. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -56,6 +55,20 @@ // exactly one JSON document (the 11.2 surfaces are JSON-only) — T11.2-5 // pinning the full-answer contract; preview rows assert exit codes only, // T6.6-* owning modifies-nothing and report content. +// - T12.0-10 operationalizes "the same names on a valid twin workspace +// giving the same exit-2 errors" and "identically with the workspace's +// configuration file invalid or missing" as byte-identical exit-2 stdout — +// the entire 12.7 error document (H-5) — across the paired workspaces: +// H-4's product-to-itself compare, sound because each check consults +// identical state in both (configuration, the session directory, the +// named files' parses) and a plain usage error describes the invocation, +// never workspace content (SPEC 14). Stderr is asserted nonempty on each +// side only — its wording, like all diagnostic text, is unpinned (H-3). +// "Reports no validation findings" is asserted at H-5's protocol grain: +// the exit-2 stdout is exactly the one 12.7 error document, a form with +// no findings member (12.7). "Reports the corruption" reuses T10.1-4's +// operationalization (exit 1, stdout matching /corrupt/i — SPEC.md's +// fixed vocabulary for the state; information presence, not wording). // - T12.0-11 partitions a whole-workspace byte diff around each git-reading // invocation: any change under `.git/` fails (same file set, same bytes), // and every change outside it must be a write the command's own @@ -68,13 +81,16 @@ import { Buffer } from "node:buffer"; import * as path from "node:path"; import { + assertReportMentions, decodeCoverageReport, decodeExportReport, + decodeFindingsReport, decodeNextReport, decodeReachableReport, } from "../../helpers/adapters/index.js"; -import type { ExportReport } from "../../helpers/adapters/index.js"; +import type { ExportReport, Finding } from "../../helpers/adapters/index.js"; import { + assertBytesEqual, assertExitCode, fail, parseJsonStdout, @@ -91,7 +107,7 @@ import { snapshotDirectory, } from "../../helpers/snapshot.js"; import type { SnapshotChange } from "../../helpers/snapshot.js"; -import type { ProductBinding } from "../../helpers/subprocess.js"; +import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; import { pathExists, releaseHoldFile, @@ -103,9 +119,13 @@ import type { WorkspaceDecl } from "../../helpers/workspace.js"; import { impactAgainst, SPECS_ONLY_CONFIG } from "./section-5.6.js"; import { assertImpactedCode, SPEC_AND_CODE_CONFIG } from "./section-9.js"; import { + assertConditionCounts, + assertFindingLocated, assertSameJson, + buildFindings, buildOk, expectConfigurationError, + expectErrorDocument, expectExit, runCli, runJson, @@ -1506,6 +1526,458 @@ export default defineConfig({ }, }); +// --------------------------------------------------------------------------- +// T12.0-10 — argument-check precedence +// --------------------------------------------------------------------------- + +// The precedence pair: a failing workspace and its valid twin, identical in +// everything the six gated argument checks consult — the configuration (a +// spec group, a code group, one coverage profile), the parseable named spec +// source, and the discovered code source with one named unit — differing +// exactly in the unparseable file that makes `build` fail (14.20). +const PRECEDENCE_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + }, + coverage: [ + { + name: "prof", + target: "main", + boundary: "main", + mode: "direct" + } + ] +}) +`; + +const PREC_SPEC_FILE = "specs/A.mdx"; +const PREC_CODE_FILE = "src/app.ts"; +const PREC_BROKEN_FILE = "specs/Broken.mdx"; + +const PRECEDENCE_TWIN_FILES: Readonly<Record<string, string>> = { + "xspec.config.ts": PRECEDENCE_CONFIG, + [PREC_SPEC_FILE]: ['<S id="alpha">', "Alpha text.", "</S>", ""].join("\n"), + [PREC_CODE_FILE]: "export function known(): void {}\n", +}; + +const PRECEDENCE_FAILING_FILES: Readonly<Record<string, string>> = { + ...PRECEDENCE_TWIN_FILES, + // An unclosed section tag: unparseable MDX (14.20), the workspace's one + // validation finding — staged in a file no gated row names, so every + // argument check below is judged from consulted state identical to the + // twin's; only the masking arm names this file, deliberately. + [PREC_BROKEN_FILE]: ['<S id="broken">', "Text that never closes.", ""].join( + "\n", + ), +}; + +/** One gated-read row: a usage-error argument checked before the 13.3 gate. */ +interface GatedUsageRow { + /** What the row's check consults and why the argument is a usage error. */ + readonly what: string; + readonly argv: readonly string[]; +} + +const GATED_USAGE_ROWS: readonly GatedUsageRow[] = [ + { + what: "an unknown profile, judged against the configuration (SPEC 7.4)", + argv: ["coverage", "no-such-profile"], + }, + { + what: + "a code group's name where `--group` requires a configured spec " + + "group's — an invalid flag value (SPEC 11.1)", + argv: ["query", "nodes", "--group", "app"], + }, + { + what: "an unknown session, judged against the session directory (SPEC 10.1)", + argv: ["review", "status", "no-such-session"], + }, + { + what: + "an unknown id, judged parse-local over the named file's spelled " + + "identities (SPEC 11.2)", + argv: ["show", `${PREC_SPEC_FILE}#unspelled`], + }, + { + what: + "a wrong-kind operand — a code source where a requirement-node " + + "identity is required (SPEC 11.1, 12.0)", + argv: ["query", "node", PREC_CODE_FILE], + }, + { + what: + "an unknown code unit, judged parse-local over the named file's " + + "named units (SPEC 4.6)", + argv: ["query", "edges", "--from", `${PREC_CODE_FILE}#unspelled`], + }, +]; + +/** + * Run one usage-error invocation (the caller's argv puts JSON output in + * effect): exit 2 exactly; stdout exactly the single 12.7 error document — + * a form with no findings member, so no validation finding rides the error + * report (SPEC 12.0, 12.7, H-5) — and a nonempty stderr (usage and + * configuration error messages are standard-error content, their wording + * free, H-3). + */ +async function expectUsageErrorDocument( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + context: string, +): Promise<{ readonly result: RunResult; readonly error: Finding }> { + const result = await expectExit(product, workspace, argv, 2, context); + const error = expectErrorDocument(result, context); + if (result.stderrBytes.length === 0) { + fail( + `${context}: usage and configuration error messages are ` + + `standard-error content (SPEC 12.0), but stderr is empty`, + ); + } + return { result, error }; +} + +// The unknown item ID named by the past-the-gate arm (no session ever +// contains it; harness-prefixed so a collision is impossible by staging). +const PRECEDENCE_NO_SUCH_ITEM = "xspec-harness-no-such-item"; + +const T12_0_10 = defineProductTest({ + id: "T12.0-10", + title: + "argument-check precedence: the rename/move and baseline arms ride on T6.4-4/T6.5-5/T6.3-4; on one workspace failing `build`'s validations each gated read given a usage-error argument exits 2 with that error and reports no validation findings (the exit-2 stdout is exactly the one 12.7 error document) — `coverage <unknown-profile>`, `query nodes --group <code-group>`, `review status <unknown-session>`, `show <file>#<unspelled-id>`, `query node <code-source-path>`, `query edges --from <code-source-path>#<unspelled-unit>` — each check judged from what it consults (configuration; the session directory; parse-local spelled identities or named units of the named file), the same names on a valid twin workspace giving the same exit-2 errors (byte-identical error documents); masking: `show <unparseable-file>#<id>` on the failing workspace yields the gated report of 13.3, exit 1, carrying exactly the workspace's findings; past the gate: on a passing workspace `review resolve <corrupt-session> <any-item-id> --status updated` reports the corruption, exit 1 — the item ID judged only against session content, which the corruption withholds (the same unknown item ID in the well-formed session exits 2 as the pre-corruption premise); within class 2: an unknown command, a repeated flag, and the malformed value `show a#b#c` are reported without loading configuration — byte-identical error documents with the configuration file invalid or missing, each the plain usage error (`code` and `path` null) — while a configuration error precedes every check that consults configuration: `coverage <unknown-profile>` with invalid configuration reports 14.14 (`configuration-error`), not the unknown profile (SPEC 12.0, 13.3, 11.1, 11.2, 4.6, 10.1, 14.14, 14.20, 14.21, 12.7)", + timeoutMs: 240_000, + run: async (product) => { + // --- Gated reads: usage-error arguments precede the 13.3 gate, judged + // from what they consult, identically on the failing workspace and its + // valid twin; masking flips `show` on the unparseable file to the gated + // report. + await withWorkspace( + { files: PRECEDENCE_FAILING_FILES }, + async (failing) => { + await withWorkspace({ files: PRECEDENCE_TWIN_FILES }, async (twin) => { + // Twin premises: the twin is valid, and every name the rows turn + // on resolves there — the profile, the spec group, the named + // file's spelled id, the discovered code source (a known graph + // node, SPEC 11.1) and its named unit — so each row's exit 2 is + // attributable to its staged usage error alone. + await buildOk(product, twin, "T12.0-10 valid-twin `build`"); + const controls: readonly (readonly string[])[] = [ + ["coverage", "prof"], + ["query", "nodes", "--group", "main"], + ["show", `${PREC_SPEC_FILE}#alpha`], + ["query", "edges", "--from", PREC_CODE_FILE], + ["query", "edges", "--from", `${PREC_CODE_FILE}#known`], + ]; + for (const argv of controls) { + await expectExit( + product, + twin, + argv, + 0, + `T12.0-10 twin control \`${argv.join(" ")}\` — the configured ` + + `profile, the spec group, the named file's spelled id, and ` + + `the discovered code source with its named unit all resolve ` + + `on the valid twin (SPEC 8.2, 11.1, 11.2, 4.6), so each ` + + `precedence row's exit 2 is attributable to its staged ` + + `usage error alone`, + ); + } + + // Failing-workspace premise: the workspace fails `build`'s + // validations with exactly the staged 14.20 — the finding whose + // non-appearance the exit-2 rows assert and whose report the + // masking arm expects. + const premiseContext = + "T12.0-10 failing-workspace `build --json` premise"; + const premiseFindings = await buildFindings( + product, + failing, + `${premiseContext} — the staged workspace fails build ` + + `validation (an unparseable source, SPEC 14.20)`, + ); + assertConditionCounts( + premiseFindings, + { "14.20": 1 }, + `${premiseContext}: the unparseable file is the workspace's ` + + `one validation finding (SPEC 14, 14.20)`, + ); + assertFindingLocated( + premiseFindings[0]!, + { file: PREC_BROKEN_FILE }, + `${premiseContext}: the 14.20 finding locates the parse ` + + `failure in the staged unparseable file (SPEC 14, 14.20)`, + ); + + for (const row of GATED_USAGE_ROWS) { + const argv = [...row.argv, "--json"]; + const command = argv.join(" "); + const onFailing = await expectUsageErrorDocument( + product, + failing, + argv, + `T12.0-10 \`${command}\` on the failing workspace — ` + + `${row.what}: a gated read's argument checks precede the ` + + `invalid-workspace report of 13.3, so the usage error is ` + + `reported, exit 2, whatever findings the workspace ` + + `carries, and no validation finding rides the report ` + + `(SPEC 12.0, 13.3)`, + ); + const onTwin = await expectUsageErrorDocument( + product, + twin, + argv, + `T12.0-10 \`${command}\` on the valid twin — ${row.what}: ` + + `the same name is the same usage error on a valid ` + + `workspace (SPEC 12.0)`, + ); + assertBytesEqual( + onFailing.result.stdoutBytes, + onTwin.result.stdoutBytes, + `T12.0-10 \`${command}\`: the check is judged from what it ` + + `consults — configuration, the session directory, the ` + + `named file's parse, identical in both workspaces — ` + + `identically on valid and failing workspaces, so the same ` + + `name gives the same exit-2 error document (SPEC 12.0, 14: ` + + `a plain usage error describes the invocation, never ` + + `workspace content; H-4's product-to-itself compare)`, + ); + } + + // Masking: the named file itself is unparseable, so the id check + // cannot be judged — the gated report of 13.3 takes its place, + // exit 1 (as in 6.4). The file even contains the bytes + // `id="broken"`, so a product scraping identities out of the + // unparseable text and answering (exit 0), or reporting an + // unknown id (exit 2), fails either way. + const maskCommand = `show ${PREC_BROKEN_FILE}#broken --json`; + const maskContext = `T12.0-10 \`${maskCommand}\` (masking)`; + const maskResult = await expectExit( + product, + failing, + ["show", `${PREC_BROKEN_FILE}#broken`, "--json"], + 1, + `${maskContext} — an unparseable named file masks the ` + + `parse-local id check as in 6.4: the gated report of 13.3 is ` + + `emitted and the command exits 1, never 2 (SPEC 12.0, 13.3, ` + + `14.20)`, + ); + const maskFindings = decodeFindingsReport( + parseJsonStdout(maskResult, maskContext), + maskContext, + ).findings; + assertConditionCounts( + maskFindings, + { "14.20": 1 }, + `${maskContext}: the gated report carries exactly the findings ` + + `a \`build\` would now report — the one unparseable-source ` + + `condition (SPEC 13.3, 14.20)`, + ); + assertFindingLocated( + maskFindings[0]!, + { file: PREC_BROKEN_FILE }, + `${maskContext}: the 14.20 finding locates the parse failure ` + + `in the unparseable named file (SPEC 14, 14.20)`, + ); + }); + }, + ); + + // --- Past the gate: an item ID is judged only against session content, + // which a corrupt session withholds (SPEC 12.0, 10.1, 14.21). + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/A.mdx": ['<S id="a">', "Alpha text.", "</S>", ""].join("\n"), + }, + }, + async (workspace) => { + await buildOk(product, workspace, "T12.0-10 past-the-gate `build`"); + await runJson( + product, + workspace, + [ + "review", + "create", + "--strategy", + "audit", + "--name", + "corrupt", + "--json", + ], + "T12.0-10 staging `review create --strategy audit --name corrupt`", + ); + const sessionRel = ".xspec/reviews/corrupt.json"; + if ((await workspace.kind(sessionRel)) !== "file") { + fail( + `T12.0-10 staging: \`review create\` must store the session at ` + + `${sessionRel} (SPEC 10.1) — the corruption arm overwrites ` + + `the file the product wrote`, + ); + } + // Premise: with the session well-formed, the unknown item ID stays + // a usage error (SPEC 10.7, 12.0; T10.7-10's contract) — so the + // exit-1 flip below is attributable to the corruption withholding + // the session content the ID would be judged against. + await expectExit( + product, + workspace, + [ + "review", + "resolve", + "corrupt", + PRECEDENCE_NO_SUCH_ITEM, + "--status", + "updated", + ], + 2, + "T12.0-10 pre-corruption premise `review resolve corrupt " + + "<no-such-item> --status updated` — an unknown item ID in a " + + "well-formed session is a usage error, exit 2 (SPEC 10.7, " + + "12.0; T10.7-10)", + ); + await workspace.file(sessionRel, "this is not a JSON document {{{\n"); + const context = + "T12.0-10 `review resolve corrupt <no-such-item> --status " + + "updated` (corrupt session)"; + const result = await runCli(product, workspace, [ + "review", + "resolve", + "corrupt", + PRECEDENCE_NO_SUCH_ITEM, + "--status", + "updated", + ]); + assertExitCode( + result, + 1, + `${context} — one check runs past the gate: the item ID is ` + + `judged only against session content, which the corruption ` + + `withholds, so the corruption is reported in the check's ` + + `place, exit 1 — never the well-formed session's exit-2 ` + + `unknown-item error (SPEC 12.0, 10.1, 14.21)`, + ); + assertReportMentions( + result, + [/corrupt/i], + `${context} — the report identifies the session as corrupt ` + + `(SPEC 10.1/14.21 vocabulary; T10.1-4's operationalization: ` + + `information presence, never exact wording, H-3)`, + ); + }, + ); + + // --- Within class 2: an error the invocation's syntax alone determines + // is reported without loading configuration — identically with the + // configuration file invalid or missing — while a configuration error + // precedes every check that consults configuration (SPEC 12.0, 14.14). + await withWorkspace( + { + files: { + "xspec.config.ts": `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + bogus: true +}) +`, + }, + }, + async (invalidConfig) => { + await withWorkspace({}, async (missingConfig) => { + const syntaxRows: readonly { + readonly what: string; + readonly argv: readonly string[]; + }[] = [ + { + what: "an unknown command", + argv: ["definitely-not-a-command", "--json"], + }, + { + what: "a repeated flag", + argv: ["ids", "--json", "--json"], + }, + { + what: "the malformed multi-`#` value (T12.0-13's spelling)", + argv: ["show", "a#b#c", "--json"], + }, + ]; + for (const row of syntaxRows) { + const command = row.argv.join(" "); + const onInvalid = await expectUsageErrorDocument( + product, + invalidConfig, + row.argv, + `T12.0-10 \`${command}\` with the configuration file invalid ` + + `— ${row.what} is determined by the invocation's syntax ` + + `alone and reported without loading configuration ` + + `(SPEC 12.0)`, + ); + const onMissing = await expectUsageErrorDocument( + product, + missingConfig, + row.argv, + `T12.0-10 \`${command}\` with the configuration file missing ` + + `— ${row.what} is reported without loading configuration ` + + `(SPEC 12.0)`, + ); + for (const [error, state] of [ + [onInvalid.error, "invalid"], + [onMissing.error, "missing"], + ] as const) { + if (error.code !== null || error.path !== null) { + fail( + `T12.0-10 \`${command}\` (configuration ${state}): the ` + + `reported error must be the plain usage error — ` + + `\`code\` and \`path\` null (SPEC 12.7, 14) — never a ` + + `configuration error: the syntax-alone check loads no ` + + `configuration (SPEC 12.0); got code ` + + `${JSON.stringify(error.code)}, path ` + + `${JSON.stringify(error.path)} (message: ` + + `${JSON.stringify(error.message)})`, + ); + } + } + assertBytesEqual( + onInvalid.result.stdoutBytes, + onMissing.result.stdoutBytes, + `T12.0-10 \`${command}\`: reported identically with the ` + + `workspace's configuration file invalid or missing — the ` + + `error document depends on the invocation's syntax alone, ` + + `never on configuration state (SPEC 12.0; H-4's ` + + `product-to-itself compare)`, + ); + } + + // A configuration error precedes every check that consults + // configuration or discovery: the unknown-profile check of the + // gated-read arm, run under invalid configuration, reports 14.14 + // — the stable code `configuration-error`, where the unknown + // profile's plain usage error carries a null code. + await expectConfigurationError( + product, + invalidConfig, + ["coverage", "no-such-profile"], + "T12.0-10 `coverage no-such-profile` with invalid " + + "configuration — a configuration error precedes every " + + "argument check that consults configuration or discovery: " + + "14.14 is reported, not the unknown profile (SPEC 12.0, " + + "14.14)", + ); + }); + }, + ); + }, +}); + // --------------------------------------------------------------------------- // T12.0-11 — git is read-only // --------------------------------------------------------------------------- @@ -1925,6 +2397,7 @@ export const section120iiTests: readonly ProductTestEntry[] = [ T12_0_7, T12_0_8, T12_0_9, + T12_0_10, T12_0_11, T12_0_12, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 445d456..b305119 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -46,11 +46,11 @@ // - Alias entries: TEST-SPEC's pointer-only tests are not separately // implemented, so their coverage rides on the implementing tests — // T12.0-10's rename/move and baseline arms ride on T6.4-4/T6.5-5 and -// T6.3-4, putting "12.0" on those three (alias-only for now: TEST-SPEC -// 12.0 also specifies gated-read and precedence arms as T12.0-10's own -// body, and once those are implemented it becomes a registered test with -// its own entry, ending the aliasing); T12.1-2 ("T7.5-6") puts "12.1" on -// T7.5-6; T13.4-7 ("T7-6") puts "13.4" on T7-6. +// T6.3-4, putting "12.0" on those three (no longer alias-only: its +// gated-read, masking, past-the-gate, and within-class-2 precedence arms +// are implemented as the registered T12.0-10, which carries its own entry +// below); T12.1-2 ("T7.5-6") puts "12.1" on T7.5-6; T13.4-7 ("T7-6") +// puts "13.4" on T7-6. // - "preamble": per H-7's own citation, T12.0-11 (git is read-only) and // T12.0-12 (git-less operation) cover the preamble's git contract; its // no-network clause is enforced at CI level (E-1), which needs no map @@ -306,6 +306,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T12.0-7": ["12.0"], "T12.0-8": ["12.0"], "T12.0-9": ["12.0"], + "T12.0-10": ["12.0"], "T12.0-11": ["preamble", "12.0"], "T12.0-12": ["preamble", "12.0"], "T12.1-1": ["12.1"], From d3ab5fdcfa706e3376863928f4061a43c7bb5164 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 18:23:37 +0000 Subject: [PATCH 085/226] sdg(phase-9): extend T12.0-12's git-less sweep to the new surfaces (FP-040) Seven steps join GITLESS_STEPS: occurrences, bare view, at, inventory, version after the query steps, and the --preview invocation of rename and of file-form move each directly before its real operation. Title extended to the full TEST-SPEC surface; traceability and certification untouched. T12.0-12 turns red-as-diagnosed at the first new step (occurrences: exit 2 unknown command against the stub product); the later steps are proven sound by direct probes in sweep order. test:self keeps its unchanged 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 28 +++++++++++++++++++++++++- test/suite/registry/section-12.0-ii.ts | 25 ++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index c5689a6..b861f87 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1643,10 +1643,36 @@ A "new test T<x>" task always means, in one change: CERTIFICATIONS.md's Exclusions only, which name exactly the in-test valid-twin comparison this body carries.] -- [ ] FP-040 — T12.0-12: extend the git-less sweep. [R2 #27; TEST-SPEC +- [x] FP-040 — T12.0-12: extend the git-less sweep. [R2 #27; TEST-SPEC §12.0] Add `occurrences`, `view`, `at`, `inventory`, `version`, and the `--preview` invocations of `rename`/`move`. + [Done 2026-08-13: seven steps joined GITLESS_STEPS + (section-12.0-ii.ts) — `occurrences`, bare `view`, `at specs/A.mdx 0`, + `inventory`, `version` after the query steps (clean-domain finding-free + answers per SPEC 11.2 and the workspace-independent 12.6 report; each a + JSON-only surface accepting the sweep's uniform `--json` per T12.0-1), + plus `rename … --preview` and `move … --preview` each directly before + its real operation with identical operands (a preview succeeds exactly + when the real operation would proceed and modifies nothing, SPEC 6.6, + so each real step still runs at the state it saw before). Title + extended to the full T12.0-12 surface. Traceability unchanged + (["preamble", "12.0"]; 11.2–11.6/12.6/6.6 are carriage context with + home coverage at stage G's §11/§12.6 tests and T6.6-*; no numbered + condition asserted); no certification scope (T12.0-12 appears nowhere + in CERTIFICATIONS.md). Verified: T12.0-12 turned green → + red-as-diagnosed at the first new step (`occurrences --json` exit 2 + "unknown command" — the patch-new surface); section-12.0-ii went 2 + failed/4 passed → 3 failed/3 passed (T12.0-9/T12.0-10 keep their + pre-existing reds); later steps unreached in the suite, proven sound by + direct probes against the built product in sweep order on a replica + staging: the five reads each exit 2 unknown-command, both previews exit + 2 unknown-flag `--preview` (each failure exactly the diagnosed product + gap), while the premise build and the real rename/move at the probed + states exit 0 — every new argv valid at its insertion point. + Typecheck/format clean; `npm run test:self` unchanged 4 planned + mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped + keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] - [ ] FP-041 — T12.2-2: add occupant-kind staleness and graph-data unit-form arms. [R2 #28; TEST-SPEC §12.2] diff --git a/test/suite/registry/section-12.0-ii.ts b/test/suite/registry/section-12.0-ii.ts index c1d3995..7e612c5 100644 --- a/test/suite/registry/section-12.0-ii.ts +++ b/test/suite/registry/section-12.0-ii.ts @@ -2250,6 +2250,16 @@ const GITLESS_STEPS: readonly GitlessStep[] = [ { what: "coverage", argv: () => ["coverage"] }, { what: "query node", argv: () => ["query", "node", GITLESS_ALPHA] }, { what: "query edges", argv: () => ["query", "edges"] }, + // The 11.3–11.6 surfaces answer over the clean domain — complete, + // finding-free, exit 0 (SPEC 11.2) — and `version` (12.6) is + // workspace-independent; none consults git. All five are JSON-only + // surfaces that accept `--json` per T12.0-1, so the sweep's uniform + // `--json` append holds for them too. + { what: "occurrences", argv: () => ["occurrences"] }, + { what: "view", argv: () => ["view"] }, + { what: "at", argv: () => ["at", GITLESS_FILE, "0"] }, + { what: "inventory", argv: () => ["inventory"] }, + { what: "version", argv: () => ["version"] }, { what: "review create (audit)", argv: () => ["review", "create", "--strategy", "audit", "--name", "aud"], @@ -2346,17 +2356,30 @@ const GITLESS_STEPS: readonly GitlessStep[] = [ ], }, { what: "review list (both sessions)", argv: () => ["review", "list"] }, + // Each `--preview` invocation performs the real operation's full + // validation and planning while modifying nothing (SPEC 6.6) — a + // git-less planning run at the same state as the real operation that + // follows it, and a successful preview since the real operation + // proceeds (exit 0, T12.0-9). + { + what: "rename --preview", + argv: () => ["rename", GITLESS_FILE, "omega", "omega2", "--preview"], + }, { what: "rename", argv: () => ["rename", GITLESS_FILE, "omega", "omega2"], }, + { + what: "move --preview", + argv: () => ["move", GITLESS_FILE, "specs/B.mdx", "--preview"], + }, { what: "move", argv: () => ["move", GITLESS_FILE, "specs/B.mdx"] }, ]; const T12_0_12 = defineProductTest({ id: "T12.0-12", title: - "git-less operation: the non-baseline surface — `build`, `check`, `ids`, `show`, `coverage`, `query`, `rename`, file-form `move`, and `review` with the audit and coverage strategies through create/list/status/next/show/split/resolve/export (an `updated` resolve re-running the recorded-profile generator included) — runs to its specified outcomes in a workspace that is not a git repository and has no enclosing repository; only baseline-taking invocations require git (SPEC 12.0, SPEC.md preamble; T10.6-1's git-less audit is one instance)", + "git-less operation: the non-baseline surface — `build`, `check`, `ids`, `show`, `coverage`, `query`, `occurrences`, `view`, `at`, `inventory`, `version`, `rename` and file-form `move` (their `--preview` invocations included), and `review` with the audit and coverage strategies through create/list/status/next/show/split/resolve/export (an `updated` resolve re-running the recorded-profile generator included) — runs to its specified outcomes in a workspace that is not a git repository and has no enclosing repository; only baseline-taking invocations require git (SPEC 12.0, 11.2, 12.6, 6.6, SPEC.md preamble; T10.6-1's git-less audit is one instance)", timeoutMs: 240_000, run: async (product) => { await withWorkspace( From c160785f4d7dc770fea0e283efce6b44c67a75a5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 18:45:33 +0000 Subject: [PATCH 086/226] sdg(phase-9): add T12.2-2 occupant-kind and graph-data unit-form arms (FP-041) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 49 ++- test/helpers/adapters/forms.ts | 31 ++ test/self/s5-output-adapters.test.ts | 85 +++++ test/suite/registry/section-12.1-12.2.ts | 403 ++++++++++++++++++++++- 4 files changed, 549 insertions(+), 19 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index b861f87..3965c5c 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1674,7 +1674,7 @@ A "new test T<x>" task always means, in one change: mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-041 — T12.2-2: add occupant-kind staleness and graph-data +- [x] FP-041 — T12.2-2: add occupant-kind staleness and graph-data unit-form arms. [R2 #28; TEST-SPEC §12.2] `test/suite/registry/section-12.1-12.2.ts`: occupant kinds (symlink to a byte-identical target; directory); graph-data unit forms — missing @@ -1682,6 +1682,53 @@ A "new test T<x>" task always means, in one change: finding), mismatch (isolated via refresh-then-revert), unreadable-record (FP-032's staging → unit form alone; `build` replaces; `check` clean; `inventory` recovers). + [Done 2026-08-13: occupant arms landed inside the per-file staleness + family between the hand-deleted and edited-source arms — the module path + occupied by a symlink to a root-level byte-identical copy (identity + through the link pinned as the staging premise: only occupant-kind + judgment can find it, the arm a link-following product wrongly passes) + and by a directory, each asserted via the existing + exactly-one-14.10-naming-the-module protocol, cleaned up between arms. + Two new families follow (the TEST-SPEC list reading: unit forms are their + own families): graph-data unit form — missing via section-13.3's + `deleteGraphData` (T13.3-2's operational definition) on the freshly + built, otherwise clean workspace; mismatch via build → text-only edit → + one refreshing read (`ids`, exit 0) → revert, the refresh premise pinned + by whole graph-data byte comparison before/after (graph data carries all + four hashes, 13.3; H-4 self-comparison carve-out, content otherwise + unread) — and unreadable record via `corruptGraphDataShapeBlind` + (FP-032's H-3 record-staging adapter), then `build` exit 0, `check` exit + 0, and `inventory` decoded through the new scoped forms.ts + `decodeInventoryRecordedDatum` (the `recorded` member alone as the + FP-001 three-state datum; S-5 DECODERS guards added — marker/empty-list/ + byte-form positives, absent-member/non-marker/fabricated-value + rejections) asserting a plain list naming specs/A.xspec.ts. Each + unit-form state asserts `assertSingleUnitFormFinding`: exactly one + condition-10 finding (no per-file finding beside, never the mismatch + form beside), concerned path exactly `.xspec` (GRAPH_DATA_AREA_PATH), + locations [], message instructing rebuilding — the T6.6-6 + operationalization, recorded in the module header. Title extended; + timeout 240s → 300s; traceability unchanged (["12.2", "14"]; + 13.3/13.4/11.6 are carriage context with home coverage at + T13.3-*/T13.4-*/T11.6-*); no certification scope (T12.2-2 sits in + CERTIFICATIONS.md prose only). Verified: suite file unchanged 3 failed / + 3 passed — T12.2-2 red-as-diagnosed at family 1's pre-existing + FP-001-class form-exact decode, new arms suite-unreached — with + soundness proven by scratch solo executions of the exact arm code plus + direct CLI probes against the built product: both occupant stagings run + clean and the product already judges occupants itself (exit 1, exactly + one old-shape 14.10 naming the module both ways — red only at the form + gap); missing/mismatch/unreadable each exit 1 with exactly one old-shape + condition-10 finding whose concerned file is `.xspec/graph.json` — a + path INSIDE the area where the arms demand the area path `.xspec` + itself, a real value gap beyond the form gap; the mismatch premise held + (refresh rewrote graph data); build-over-corrupt exit 0, check-after + clean, and `inventory` exit 2 unknown command (the patch-new surface — + the recovery assertion's diagnosed red at runJson's exit-0 gate). + Typecheck/format clean; `npm run test:self` unchanged 4 planned mid-loop + reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys + {11.2–11.6, 12.6, 12.7} → stage G), S-5 green including the new + inventory-recorded-datum guards, certification green.] - [ ] FP-042 — T12.2-3: pin never-refreshes per state. [R2 #29; TEST-SPEC §12.2] diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts index 55e6b08..a4a6468 100644 --- a/test/helpers/adapters/forms.ts +++ b/test/helpers/adapters/forms.ts @@ -505,6 +505,37 @@ export function decodeDatum<T>( return { state: "value", value: decodeValue(value, site) }; } +// --- scoped inventory decode: the `recorded` datum (11.6, 12.7) --------------- + +/** + * Scoped decode of the inventory document's `recorded` member (SPEC 11.6, + * 12.7): the record-supplied datum — the recorded derived-file paths, each a + * 12.7 path value — as a three-state datum: a plain list, `null`, or the + * explicit-unavailability marker (14.23). Which states are legitimate for + * this member is the caller's value assertion (a conforming inventory + * reports the plain list or unavailability, never `null`, 11.6/12.7). + * Deliberately scoped: SPEC 12.7 fixes the whole inventory form and the + * T11.6-* tests pin it entirely; this decoder reads exactly the one pinned + * member the record-recovery contract needs (T12.2-2's unreadable-record + * arm: after a successful `build` replaces the corrupt state, `inventory` + * reports `recorded` again) — the top level must be an object and the member + * present (`null` is never omission, 12.7) while every other member stays + * unread. Form-exact (H-3): never adjustable to a product's shape. + */ +export function decodeInventoryRecordedDatum( + doc: unknown, + context?: string, +): DecodedDatum<readonly PathValue[]> { + const site = rootSite("11.6 inventory (recorded datum)", context); + const obj = expectObject(doc, site); + const recordedSite = at(site, "recorded"); + return decodeDatum(obj["recorded"], recordedSite, (value, valueSite) => + expectArray(value, valueSite).map((element, index) => + decodePathValue(element, at(valueSite, index)), + ), + ); +} + // --- the occurrences document (5.7, 11.3, 12.7) ------------------------------- const OCCURRENCE_RECORD_MEMBERS = [ diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index 2016c3a..fb79eaa 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -44,6 +44,7 @@ import { decodeIdsReport, decodeIdsTreeReport, decodeImpactReport, + decodeInventoryRecordedDatum, decodeItemReport, decodeNextReport, decodeNodeMetadataSummary, @@ -1833,6 +1834,90 @@ const DECODERS: readonly DecoderSpec[] = [ }, ], }, + { + // The scoped inventory decode (SPEC 11.6, 12.7): exactly the `recorded` + // member as a three-state datum — a plain list of path values, `null`, + // or the unavailability marker (14.23) — with every other member unread + // (the full inventory form is T11.6-*'s subject). Which states a + // conforming inventory may report is the caller's value assertion; the + // decoder's job is that no state ever collapses into a defaulted or + // fabricated value (S-5). + name: "11.6 inventory (recorded datum)", + decode: decodeInventoryRecordedDatum, + good: { + findings: [], + recorded: ["specs/A.md", "specs/A.xspec.ts"], + graphData: ".xspec", + }, + verify: (decoded: ReturnType<typeof decodeInventoryRecordedDatum>) => { + expect(decoded).toEqual({ + state: "value", + value: ["specs/A.md", "specs/A.xspec.ts"], + }); + }, + alsoGood: [ + { + label: + "explicit unavailability (14.23) decodes as the marker state — " + + "never as an empty or fabricated record", + doc: { recorded: { unavailable: true } }, + verify: ( + decoded: ReturnType<typeof decodeInventoryRecordedDatum>, + ): void => { + expect(decoded).toEqual({ state: "unavailable" }); + }, + }, + { + label: + "an empty recorded list stays [] (empty before any generation, " + + "SPEC 11.6; [] is never null, 12.7)", + doc: { recorded: [] }, + verify: ( + decoded: ReturnType<typeof decodeInventoryRecordedDatum>, + ): void => { + expect(decoded).toEqual({ state: "value", value: [] }); + }, + }, + { + label: "a non-UTF-8 recorded path arrives in the marked byte form", + doc: { recorded: [{ bytes: "ff2e6d64" }] }, + verify: ( + decoded: ReturnType<typeof decodeInventoryRecordedDatum>, + ): void => { + expect(decoded).toEqual({ + state: "value", + value: [{ bytes: "ff2e6d64" }], + }); + }, + }, + ], + bad: [ + { + label: "absent recorded member (null is never omission, SPEC 12.7)", + doc: { findings: [], graphData: ".xspec" }, + }, + { + label: "a non-marker object carrying `unavailable` (SPEC 12.7)", + doc: { recorded: { unavailable: false } }, + }, + { + label: "the marker with an extra member (SPEC 12.7: exactly one)", + doc: { recorded: { unavailable: true, paths: [] } }, + }, + { + label: "a non-array plain value", + doc: { recorded: "specs/A.xspec.ts" }, + }, + { + label: "a non-path element", + doc: { recorded: [42] }, + }, + { + label: "a valid-UTF-8 path in the byte form (SPEC 12.7 forbids it)", + doc: { recorded: [{ bytes: "612e6d64" }] }, + }, + ], + }, { name: "coverage", decode: decodeCoverageReport, diff --git a/test/suite/registry/section-12.1-12.2.ts b/test/suite/registry/section-12.1-12.2.ts index 1faec24..13df423 100644 --- a/test/suite/registry/section-12.1-12.2.ts +++ b/test/suite/registry/section-12.1-12.2.ts @@ -60,13 +60,36 @@ // 14.10 findings aside. The staleness family itself asserts the reverse: // every finding is 14.10, names its file, and instructs rebuilding. // - The 14.10 arms pin the exact finding where the fixture has exactly one -// stale file (hand-edited module, hand-deleted module: sources, config, -// and every other derived file stay fresh). The edited-source and -// disabled-emission arms cannot enumerate the product's stale set (which -// companions embed text, and how graph data records derived paths, are -// opaque — 13.1/13.3), so they assert: all findings are 14.10 and the one -// file SPEC fixes as stale/orphaned — the emitted Markdown, whose bytes -// are the compiled source (3, 13.2) — is among the named files. +// stale file (hand-edited module, hand-deleted module, and the +// occupant-kind arms — symlink to a byte-identical target, directory: +// sources, config, graph data, and every other derived file stay fresh). +// The edited-source and disabled-emission arms cannot enumerate the +// product's stale set (which companions embed text, and how graph data +// records derived paths, are opaque — 13.1/13.3), so they assert: all +// findings are 14.10 and the one file SPEC fixes as stale/orphaned — the +// emitted Markdown, whose bytes are the compiled source (3, 13.2) — is +// among the named files. +// - The 14.10 unit form ("concerned path the graph-data area, no path +// inside it named") is operationalized as: exactly one finding, its +// concerned path exactly `.xspec` (the area's workspace-relative path, +// no trailing separator, SPEC 11.6) and its locations [] (a +// path-concerned condition is unlocated, 12.7) — the T6.6-6 precedent. +// "No per-file finding beside it" and "never the mismatch form beside +// it" are both the exactly-one count: any second condition-10 finding, +// whatever its concerned path, fails it. +// - The mismatch arm's premise (refresh-then-revert leaves graph data +// reflecting the edited sources) is pinned by whole comparison of the +// graph-data byte state (T13.3-2's operational path set) before the edit +// and after the refreshing read: graph data carries all four hashes +// (13.3), so a text edit must change it, and comparing the product's +// bytes against the product's own earlier bytes is the H-4 +// self-comparison carve-out — content stays otherwise unread. +// - The unreadable-record recovery arm reads `inventory` through the +// scoped `recorded`-datum decode (forms.ts): `recorded` is the one +// member the recovery contract needs ("`inventory` reports `recorded` +// again", 14.10 → 11.6), asserted as a plain list naming the generated +// module; the full inventory form and the corrupt-state unavailability +// report are T11.6-*'s subject (T11.6-4). // - 14.21 identification: the corrupt-session finding must let the user find // the session — accepted as the finding naming the session file path or // the message naming the session (H-3 information presence, never exact @@ -84,15 +107,36 @@ import * as fsp from "node:fs/promises"; import type { Finding } from "../../helpers/adapters/index.js"; -import { decodeFindingsReport } from "../../helpers/adapters/index.js"; -import { fail, parseJsonStdout } from "../../helpers/assertions.js"; +import { + GRAPH_DATA_AREA_PATH, + corruptGraphDataShapeBlind, + decodeFindingsReport, + decodeInventoryRecordedDatum, + isGraphDataKey, +} from "../../helpers/adapters/index.js"; +import { + assertBytesEqual, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; -import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import type { + DirectorySnapshot, + SnapshotEntry, +} from "../../helpers/snapshot.js"; +import { + assertLeavesUnchanged, + diffSnapshots, + snapshotDirectory, +} from "../../helpers/snapshot.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import { assertGraphDataPresent, deleteGraphData } from "./section-13.3.js"; import { assertConditionCounts, + assertFindingConcernsPath, + assertSameJson, buildFindings, buildOk, expectConfigurationError, @@ -252,6 +296,64 @@ function assertStaleFileNamed( } } +/** + * Exactly one 14.10 finding in the unit form (SPEC 14.10): concerned path + * the graph-data area — `.xspec`, its workspace-relative path with no + * trailing separator (11.6) — with no path inside the area named (the + * record's layout is deliberately unenumerated, 13.3: locations [], and the + * concerned path is exactly the area), instructing rebuilding. The + * exactly-one count is "no per-file finding beside it" and "never the + * mismatch form beside it" at once: one finding either way (14.10). + */ +function assertSingleUnitFormFinding( + findings: readonly Finding[], + context: string, +): void { + assertAllStale(findings, context); + if (findings.length !== 1) { + fail( + `${context}: the graph-data unit form is one condition-10 finding — ` + + `never a per-file finding or a second unit-form finding beside it ` + + `(SPEC 14.10: one finding either way; the unit forms are ` + + `exclusive); got ` + + JSON.stringify( + findings.map(({ condition, path }) => ({ condition, path })), + ), + ); + } + const finding = findings[0]!; + assertFindingConcernsPath( + finding, + GRAPH_DATA_AREA_PATH, + `${context}: the unit form's concerned path is the graph-data area — ` + + `the .xspec directory spelled as its workspace-relative path, no ` + + `trailing separator (SPEC 14.10, 11.6)`, + ); + assertSameJson( + finding.locations, + [], + `${context}: no path inside the area is named — the record's layout is ` + + `deliberately unenumerated (SPEC 14.10, 13.3), and a path-concerned ` + + `condition is unlocated: locations [] (SPEC 12.7)`, + ); +} + +/** + * The graph-data entries of a whole-root snapshot, viewed as a snapshot — + * T13.3-2's operational path set (every path under `.xspec/` except the + * durable journal and reviews paths; the predicate's one home is the H-3 + * adapter layer). Used only for whole comparison against the product's own + * earlier bytes (H-4: graph-data content is opaque; the self-comparison + * carve-out). + */ +function graphDataStateOf(snapshot: DirectorySnapshot): DirectorySnapshot { + const entries = new Map<string, SnapshotEntry>(); + for (const [key, entry] of snapshot.entries) { + if (isGraphDataKey(key)) entries.set(key, entry); + } + return { root: snapshot.root, entries }; +} + /** Assert a plain file exists at `rel`, diagnosed with the SPEC cite. */ async function expectFile( workspace: TestWorkspace, @@ -725,8 +827,8 @@ const CORRUPT_SESSION_PATH = ".xspec/reviews/bad.json"; const T12_2_2 = defineProductTest({ id: "T12.2-2", title: - "one workspace per finding family, each reported by `check` with exit 1: build validations re-validated from the current sources against persisting derived state; stale generated output and orphaned recorded derived files (14.10) after hand-editing, hand-deleting, editing a source, and disabling emission; unresolved/non-static references; cycles; journal integrity (14.13); policy (14.12); corrupt sessions (14.21) (SPEC 12.2, 14)", - timeoutMs: 240_000, + "one workspace per finding family, each reported by `check` with exit 1: build validations re-validated from the current sources against persisting derived state; stale generated output and orphaned recorded derived files (14.10) after hand-editing, hand-deleting, editing a source, and disabling emission, plus the occupant-kind arms — the per-file comparison judges the path's occupant itself, never traversing a symbolic link: a generated module's path occupied by a symlink whose target holds byte-identical generated content, and by a directory, each stale; the graph-data unit form, missing and mismatch arms each positively isolated — exactly one condition-10 finding, concerned path the graph-data area, no path inside it named, no per-file finding beside it; the unreadable-record unit form (14.23) reported alone, a successful `build` replacing the state (`check` clean afterward, `inventory` reports `recorded` again); unresolved/non-static references; cycles; journal integrity (14.13); policy (14.12); corrupt sessions (14.21) (SPEC 12.2, 13.3, 13.4, 11.6, 14)", + timeoutMs: 300_000, run: async (product) => { // Family 1 — build validations, re-validated from the current sources. // Derived state from a prior valid build persists while the sources are @@ -762,7 +864,9 @@ const T12_2_2 = defineProductTest({ }, ); - // Family 2 — 14.10 staleness and orphans, check-only, four arms. + // Family 2 — 14.10 per-file staleness and orphans, check-only: the + // hand-edit/hand-delete/edited-source/disabled-emission arms plus the + // occupant-kind arms. await withWorkspace( { "xspec.config.ts": markdownConfig(true), @@ -816,6 +920,70 @@ const T12_2_2 = defineProductTest({ "generate: a 14.10 finding naming it (SPEC 12.2, 14.10)", ); + // Occupant-kind arm A — the module's path occupied by a symbolic + // link whose target holds byte-identical generated content: the + // per-file comparison judges the path's occupant itself, never + // traversing a symbolic link (SPEC 14.10, 13.4), so the link is + // stale exactly as a missing or content-differing file — the + // discriminating arm a link-following product wrongly passes. The + // link target lives at the workspace root under a name no group + // matches and no derived path claims, so the copy itself changes + // nothing else `check` consults. + await buildOk( + product, + workspace, + "T12.2-2 (staleness) rebuild between arms — restores the deleted " + + "module (SPEC 12.1)", + ); + const generatedBytes = await workspace.readBytes(moduleRel); + const linkTargetRel = "module-copy.txt"; + await workspace.file(linkTargetRel, generatedBytes); + await fsp.rm(workspace.path(moduleRel)); + await workspace.symlink(moduleRel, `../${linkTargetRel}`); + // Staging premise: reading THROUGH the link yields byte-identical + // generated content — only occupant-kind judgment can find this + // arm's staleness, so a link-following product wrongly passes. + assertBytesEqual( + await workspace.readBytes(moduleRel), + generatedBytes, + "T12.2-2 (staleness, symlink occupant) staging premise — the " + + "link's target holds byte-identical generated content " + + "(TEST-SPEC T12.2-2: the discriminating arm)", + ); + assertSingleStaleFile( + await checkFindings( + product, + workspace, + "T12.2-2 (staleness, symlink occupant) `check --json`", + ), + moduleRel, + "T12.2-2 (staleness, symlink occupant) — the per-file comparison " + + "matches only a plain file holding exactly the generated " + + "content, never traversing a symbolic link: a symlink whose " + + "target holds byte-identical generated content is stale, " + + "exactly as a missing or content-differing file " + + "(SPEC 12.2, 14.10, 13.4)", + ); + await fsp.rm(workspace.path(moduleRel)); + await fsp.rm(workspace.path(linkTargetRel)); + + // Occupant-kind arm B — the module's path occupied by a directory: + // a non-plain-file occupant is stale whatever it holds (SPEC 14.10). + await fsp.mkdir(workspace.path(moduleRel)); + assertSingleStaleFile( + await checkFindings( + product, + workspace, + "T12.2-2 (staleness, directory occupant) `check --json`", + ), + moduleRel, + "T12.2-2 (staleness, directory occupant) — a directory at a " + + "generated module's path is a non-plain-file occupant: stale, " + + "exactly as a missing or content-differing file " + + "(SPEC 12.2, 14.10, 13.4)", + ); + await fsp.rm(workspace.path(moduleRel), { recursive: true }); + // Arm 3 — source edited without rebuilding: the emitted Markdown's // bytes are the compiled source (SPEC 3, 13.2), so it is stale for // certain; which further derived files change is opaque (module and @@ -866,7 +1034,206 @@ const T12_2_2 = defineProductTest({ }, ); - // Family 3 — unresolved and non-static references (14.5, 14.6, 14.7, + // Family 3 — the graph-data unit form (14.10), missing and mismatch + // arms, each positively isolated: every generated file present and + // matching, so any per-file finding beside the one unit-form finding + // is a phantom. + await withWorkspace( + { + "xspec.config.ts": markdownConfig(true), + "specs/A.mdx": FAILED_BUILD_VALID_SOURCE, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T12.2-2 (graph-data unit form) initial `build` (SPEC 12.1)", + ); + + // Missing arm — on the freshly built, otherwise clean workspace, + // delete the graph data (T13.3-2's operational definition: every + // path under .xspec/ except the durable journal and reviews + // paths). Every generated file stays present and matching, and the + // absent record leaves the recorded-file form nothing to report — + // so exactly one condition-10 finding, the unit form, + // discriminates a product that treats absent graph data as + // nothing to verify. + await deleteGraphData( + workspace, + "T12.2-2 (graph-data unit form, missing) staging", + ); + assertSingleUnitFormFinding( + await checkFindings( + product, + workspace, + "T12.2-2 (graph-data unit form, missing) `check --json`", + ), + "T12.2-2 (graph-data unit form, missing) — `check` verifies " + + "graph data against the current sources and configuration: " + + "deleted graph data is missing graph data, exactly one " + + "condition-10 finding in the unit form with no per-file " + + "finding beside it (SPEC 12.2, 13.3, 14.10)", + ); + + // Mismatch arm — positively isolated via refresh-then-revert + // (TEST-SPEC T12.2-2): build, edit the source, run one refreshing + // read — graph data then reflects the edit while the generated + // files go stale (SPEC 13.3) — and revert the edit: the generated + // files again match the current sources while graph data does not. + await buildOk( + product, + workspace, + "T12.2-2 (graph-data unit form) rebuild between arms — restores " + + "the deleted graph data (SPEC 12.1)", + ); + const wholeFresh = await snapshotDirectory(workspace.root); + assertGraphDataPresent( + wholeFresh, + "T12.2-2 (graph-data unit form, mismatch) staging premise after " + + "the rebuild", + ); + const freshGraph = graphDataStateOf(wholeFresh); + await workspace.file( + "specs/A.mdx", + [ + '<S id="a1">', + "Alpha behavior, edited for the mismatch arm.", + "</S>", + "", + ].join("\n"), + ); + await expectExit( + product, + workspace, + ["ids"], + 0, + "T12.2-2 (graph-data unit form, mismatch) one refreshing read " + + "(`ids`) over the edited, still-valid sources — the read " + + "refreshes graph data before answering (SPEC 13.3, 12.3)", + ); + const refreshedGraph = graphDataStateOf( + await snapshotDirectory(workspace.root), + ); + // Staging premise: the refresh rewrote graph data to reflect the + // edit — graph data carries all four hashes (SPEC 13.3), so the + // text edit must change its bytes (whole comparison against the + // product's own earlier bytes; H-4 self-comparison carve-out). + if (diffSnapshots(freshGraph, refreshedGraph).length === 0) { + fail( + "T12.2-2 (graph-data unit form, mismatch) staging premise: " + + "graph data is byte-identical before the edit and after the " + + "refreshing read — the refresh must rewrite graph data to " + + "reflect the edited sources (SPEC 13.3: read results never " + + "come from stale data; graph data carries all four hashes, " + + "so a text edit changes it), leaving the mismatch arm " + + "nothing to stage", + ); + } + await workspace.file("specs/A.mdx", FAILED_BUILD_VALID_SOURCE); + assertSingleUnitFormFinding( + await checkFindings( + product, + workspace, + "T12.2-2 (graph-data unit form, mismatch) `check --json` after " + + "reverting the edit", + ), + "T12.2-2 (graph-data unit form, mismatch) — the generated files " + + "again match the current sources while graph data does not: " + + "exactly one condition-10 finding in the unit form with no " + + "per-file finding beside it, discriminating a product that " + + "runs the per-file and record-readability checks but never " + + "compares graph data against the current sources and " + + "configuration (SPEC 12.2, 13.3, 14.10)", + ); + }, + ); + + // Family 4 — the unreadable-record unit form (14.10/14.23): graph data + // corrupted shape-blind (T6.6-6's staging; H-3 record-staging adapter, + // garbage over T13.3-2's operational path set, product-written files + // only), then replaced by a successful `build`. + await withWorkspace( + { + "xspec.config.ts": markdownConfig(true), + "specs/A.mdx": FAILED_BUILD_VALID_SOURCE, + }, + async (workspace) => { + const moduleRel = "specs/A.xspec.ts"; + await buildOk( + product, + workspace, + "T12.2-2 (unreadable record) initial `build` — the corruption " + + "applies to a record the product itself wrote (SPEC 12.1, 13.3)", + ); + await corruptGraphDataShapeBlind( + workspace.root, + "T12.2-2 (unreadable record) staging", + ); + assertSingleUnitFormFinding( + await checkFindings( + product, + workspace, + "T12.2-2 (unreadable record) `check --json`", + ), + "T12.2-2 (unreadable record) — recorded generation state that " + + "exists but cannot be read as a record reports under the " + + "unreadable-record unit form alone: never the mismatch form " + + "beside it (the unit forms are exclusive), and the " + + "recorded-file form, consulting no readable record, is " + + "undetectable while the state holds (SPEC 12.2, 14.10, 14.23)", + ); + + // A successful `build` replaces the state (SPEC 14.10, 12.1, + // 13.4: a corrupted derived file is correctly resolved by + // rebuilding). + await buildOk( + product, + workspace, + "T12.2-2 (unreadable record) `build` over the corrupt-record " + + "state — a successful build replaces the record " + + "(SPEC 12.1, 13.4, 14.10)", + ); + await expectExit( + product, + workspace, + ["check"], + 0, + "T12.2-2 (unreadable record) `check` after the rebuild — clean " + + "(SPEC 14.10: a successful build replaces the state)", + ); + // `inventory` reports `recorded` again — the record-supplied datum + // is the plain recorded derived-file paths, naming the generated + // module (the corrupt-state unavailability report is T11.6-4's + // subject; `inventory` is a JSON-only surface, one document, + // exit 0 on the clean workspace, SPEC 11.6, 12.0). + const inventoryContext = + "T12.2-2 (unreadable record) `inventory` after the rebuild"; + const recorded = decodeInventoryRecordedDatum( + await runJson(product, workspace, ["inventory"], inventoryContext), + inventoryContext, + ); + if (recorded.state !== "value") { + fail( + `${inventoryContext}: after a successful \`build\` replaces ` + + `the corrupt record, the record-supplied datum is the plain ` + + `recorded derived-file paths again — never unavailability, ` + + `never null (SPEC 14.10, 14.23, 11.6, 12.7); got state ` + + `${JSON.stringify(recorded.state)}`, + ); + } + if (!recorded.value.includes(moduleRel)) { + fail( + `${inventoryContext}: the recorded derived-file paths — the ` + + `paths as last generated, companions included — must name ` + + `the generated module ${JSON.stringify(moduleRel)} ` + + `(SPEC 11.6, 13.1, 13.3); got ` + + JSON.stringify(recorded.value), + ); + } + }, + ); + + // Family 5 — unresolved and non-static references (14.5, 14.6, 14.7, // 14.8), each staged against a distinct missing name. await withWorkspace(REFERENCES_FAMILY_FILES, async (workspace) => { await checkFamilyFindings( @@ -879,7 +1246,7 @@ const T12_2_2 = defineProductTest({ ); }); - // Family 4 — cycles: a self-`depends` cycle of length one (no import + // Family 6 — cycles: a self-`depends` cycle of length one (no import // cycle co-staged). await withWorkspace(CYCLE_FAMILY_FILES, async (workspace) => { await checkFamilyFindings( @@ -891,7 +1258,7 @@ const T12_2_2 = defineProductTest({ ); }); - // Family 5 — journal integrity (14.13): a malformed journal line. + // Family 7 — journal integrity (14.13): a malformed journal line. await withWorkspace( { "xspec.config.ts": markdownConfig(false), @@ -916,7 +1283,7 @@ const T12_2_2 = defineProductTest({ }, ); - // Family 6 — policy (14.12, check-only): one forbidden rule, one + // Family 8 — policy (14.12, check-only): one forbidden rule, one // violating edge, freshly built so the violation is the only finding. await withWorkspace(POLICY_FAMILY_FILES, async (workspace) => { await buildOk( @@ -934,7 +1301,7 @@ const T12_2_2 = defineProductTest({ ); }); - // Family 7 — corrupt sessions (14.21): a session file that cannot be + // Family 9 — corrupt sessions (14.21): a session file that cannot be // parsed is corrupt categorically (SPEC 10.1). await withWorkspace( { From f66579dd9a2290e5639fd35274e629f016b171cb Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 18:59:19 +0000 Subject: [PATCH 087/226] sdg(phase-9): pin T12.2-3's never-refreshes per state (FP-042) Rebuild T12.2-3 as TEST-SPEC's three per-state pins: on T12.2-2's missing-arm state graph data stays absent around `check` (absence pinned as a staging premise, whole-root compare-around); on its isolated mismatch state (refresh-then-revert staging, both premises pinned) and on an edited-source-without-rebuild state carrying per-file and unit staleness together, graph data and every derived file stay byte-identical around both the human and the `--json` invocation. Each state asserts its own staleness report (unit-form condition-10 finding for missing/mismatch, all-14.10 for edited source), so state reachability is positively established. Suite file unchanged 3 failed / 3 passed (T12.2-3 red-as-diagnosed at the FP-001-class form-exact decode; arm soundness and the byte pins proven by direct probes against the built product). test:self unchanged: 4 planned mid-loop reds (certification-document x3 -> FP-091; S-1's 7 unmapped keys -> stage G). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 35 +++- test/suite/registry/section-12.1-12.2.ts | 194 ++++++++++++++++++++--- 2 files changed, 203 insertions(+), 26 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 3965c5c..6f3d912 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1730,10 +1730,43 @@ A "new test T<x>" task always means, in one change: {11.2–11.6, 12.6, 12.7} → stage G), S-5 green including the new inventory-recorded-datum guards, certification green.] -- [ ] FP-042 — T12.2-3: pin never-refreshes per state. [R2 #29; TEST-SPEC +- [x] FP-042 — T12.2-3: pin never-refreshes per state. [R2 #29; TEST-SPEC §12.2] Missing-arm state (graph data stays absent), isolated mismatch state, combined per-file+unit state. + [Done 2026-08-13: T12.2-3 rebuilt as the three per-state pins on one + workspace (section-12.1-12.2.ts) — (1) T12.2-2's missing-arm staging + (fresh build, `deleteGraphData`), absence pinned as a staging premise so + the whole-root compare-around positively proves graph data STAYS absent + (`check` never rewrites it, where every refreshing read would — + T13.3-2's cite); (2) the isolated mismatch state via T12.2-2's + refresh-then-revert staging with both premise pins (graph data present + after rebuild; the refresh rewrote it — H-4 self-comparison carve-out); + (3) the edited-source-without-rebuild state, per-file + unit staleness + together (generated files compile the old source; graph data carries all + four hashes — the state 2 premise pin shows this edit class rewrites it). + Each state runs plain `check` (exit 1) and `check --json` inside one + whole-root `assertLeavesUnchanged`; the staleness report is asserted per + state — exactly one unit-form condition-10 finding (states 1–2, + T12.2-2's shared helper) / `assertAllStale` (state 3) — so each state's + reachability is positively established, never assumed. No new helpers or + adapters (S-5 untouched); traceability unchanged (["12.2"] — the + T13.3-2/T13.3-3 precedent: the condition assertions establish the state, + 14.10's primary record is T12.2-2's). Verified: suite file unchanged 3 + failed / 3 passed — T12.2-3 red-as-diagnosed at state 1's FP-001-class + form-exact decode, later arms suite-unreached — with soundness proven by + a scratch probe of the exact state sequence against the built product: + every staging premise holds (build/ids exit 0, delete leaves zero graph + entries, the refresh rewrites graph data), all three states exit 1 on + both `check` forms reporting only old-shape condition-10 findings + (concerned file `.xspec/graph.json`, INSIDE the area where the unit form + demands `.xspec` itself — the FP-041-diagnosed value gap behind the form + gap), and the whole-root diff around the invocations is empty in every + state — the byte pins themselves already hold, graph data staying absent + in state 1. Typecheck/format clean; `npm run test:self` unchanged 4 + planned mid-loop reds (certification-document ×3 → FP-091; S-1's 7 + unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification + green.] - [ ] FP-043 — T12.5-1: extend the dispatch sweep. [R2 #30; TEST-SPEC §12.5] diff --git a/test/suite/registry/section-12.1-12.2.ts b/test/suite/registry/section-12.1-12.2.ts index 13df423..2faf844 100644 --- a/test/suite/registry/section-12.1-12.2.ts +++ b/test/suite/registry/section-12.1-12.2.ts @@ -1349,13 +1349,38 @@ const T12_2_2 = defineProductTest({ }); // --------------------------------------------------------------------------- -// T12.2-3 — check never refreshes +// T12.2-3 — check never refreshes, pinned per state // --------------------------------------------------------------------------- +// The per-state pins (TEST-SPEC T12.2-3; SPEC 13.3: "`check` never +// refreshes — it reports staleness instead"): +// - Missing state (T12.2-2's missing-arm staging): graph data stays absent +// around `check` — `check` never rewrites the record, where every +// refreshing read on this same state would (T13.3-2's deleted-graph-data +// arms). Absence is pinned as a staging premise before the invocations, so +// the whole-root compare-around proves "stays absent" positively. +// - Isolated mismatch state (T12.2-2's refresh-then-revert staging, premise +// pinned the same way): graph data and every derived file byte-identical +// around the invocation. +// - Edited-source-without-rebuild state: one content edit after a build +// leaves the generated files stale (their bytes compile the old source — +// SPEC 3, 13.1, 13.2) and graph data mismatched against the current +// sources (it carries all four hashes, SPEC 13.3 — the mismatch premise +// pin above shows exactly this edit class rewrites graph data on refresh), +// so the state carries per-file and unit staleness together; byte-identity +// around the invocation pins that neither form's detection refreshes +// anything. +// Each state's staleness report is asserted in-test, so the state's +// reachability is positively established, never assumed: the missing and +// mismatch states report exactly the one unit-form condition-10 finding +// (T12.2-2's contract), the edited-source state 14.10 findings only. Both +// output forms run inside each compare, so the byte pin covers the human +// and the `--json` invocation alike. + const T12_2_3 = defineProductTest({ id: "T12.2-3", title: - "`check` on a stale workspace reports the staleness (exit 1, 14.10) and never refreshes: graph data and derived files — the whole workspace — stay byte-identical around both the human and the `--json` invocation (SPEC 12.2, 13.3, 14.10)", + "`check` reports staleness and never refreshes, pinned per state: on the missing-graph-data state graph data stays absent, where every refreshing read would rewrite it; on the isolated mismatch state and on an edited-source state carrying per-file and unit staleness together, graph data and every derived file — the whole workspace — stay byte-identical around both the human and the `--json` invocation (SPEC 12.2, 13.3, 14.10)", run: async (product) => { await withWorkspace( { @@ -1363,42 +1388,161 @@ const T12_2_3 = defineProductTest({ "specs/A.mdx": FAILED_BUILD_VALID_SOURCE, }, async (workspace) => { + // The content edit shared by the mismatch staging and the + // edited-source state: a text-only edit to the one source (still + // valid, same node set). + const editedSource = [ + '<S id="a1">', + "Alpha behavior, edited without rebuilding.", + "</S>", + "", + ].join("\n"); + + // Both `check` output forms on the current stale state: plain + // `check` exits 1, then `check --json` decodes as the findings + // report (SPEC 12.2, 12.0; H-3). + const checkStale = async ( + context: string, + ): Promise<readonly Finding[]> => { + await expectExit( + product, + workspace, + ["check"], + 1, + `${context} \`check\` — staleness is a finding, exit 1 ` + + `(SPEC 12.2, 14.10)`, + ); + return await checkFindings( + product, + workspace, + `${context} \`check --json\``, + ); + }; + + // State 1 — T12.2-2's missing-arm state: freshly built, otherwise + // clean workspace with the graph data deleted (T13.3-2's + // operational definition). await buildOk( product, workspace, - "T12.2-3 initial `build` (SPEC 12.1)", + "T12.2-3 (missing) initial `build` (SPEC 12.1)", ); - // Stale: the source is edited (still valid) without rebuilding. - await workspace.file( - "specs/A.mdx", - ['<S id="a1">', "Alpha behavior, edited.", "</S>", ""].join("\n"), + await deleteGraphData(workspace, "T12.2-3 (missing) staging"); + const missingBefore = graphDataStateOf( + await snapshotDirectory(workspace.root), ); + if (missingBefore.entries.size > 0) { + fail( + "T12.2-3 (missing) staging premise: deleting the graph data — " + + "every path under .xspec/ except the durable journal and " + + "reviews paths (T13.3-2's operational definition) — must " + + "leave none; found " + + JSON.stringify([...missingBefore.entries.keys()].sort()), + ); + } await assertLeavesUnchanged( workspace.root, async () => { - await expectExit( - product, - workspace, - ["check"], - 1, - "T12.2-3 `check` on a stale workspace — staleness is a " + - "finding, exit 1 (SPEC 12.2, 14.10)", + assertSingleUnitFormFinding( + await checkStale("T12.2-3 (missing)"), + "T12.2-3 (missing) — `check` reports the absent graph data: " + + "exactly one condition-10 finding in the unit form " + + "(SPEC 12.2, 13.3, 14.10)", ); - const findings = await checkFindings( - product, - workspace, - "T12.2-3 `check --json` on a stale workspace", + }, + "T12.2-3 (missing): graph data stays absent — `check` reports " + + "staleness and never rewrites the record, where every " + + "refreshing read on this state would (SPEC 13.3, 12.2; " + + "TEST-SPEC T13.3-2) — and nothing else changes either", + ); + + // State 2 — T12.2-2's isolated mismatch state: rebuild, edit the + // source, run one refreshing read (graph data then reflects the + // edit while the generated files go stale, SPEC 13.3), revert the + // edit — the generated files again match the current sources while + // graph data does not. + await buildOk( + product, + workspace, + "T12.2-3 (mismatch) rebuild — restores the deleted graph data " + + "(SPEC 12.1)", + ); + const wholeFresh = await snapshotDirectory(workspace.root); + assertGraphDataPresent( + wholeFresh, + "T12.2-3 (mismatch) staging premise after the rebuild", + ); + const freshGraph = graphDataStateOf(wholeFresh); + await workspace.file("specs/A.mdx", editedSource); + await expectExit( + product, + workspace, + ["ids"], + 0, + "T12.2-3 (mismatch) one refreshing read (`ids`) over the edited, " + + "still-valid sources — the read refreshes graph data before " + + "answering (SPEC 13.3, 12.3)", + ); + // Staging premise: the refresh rewrote graph data to reflect the + // edit — graph data carries all four hashes (SPEC 13.3), so the + // text edit must change its bytes (whole comparison against the + // product's own earlier bytes; H-4 self-comparison carve-out). + if ( + diffSnapshots( + freshGraph, + graphDataStateOf(await snapshotDirectory(workspace.root)), + ).length === 0 + ) { + fail( + "T12.2-3 (mismatch) staging premise: graph data is " + + "byte-identical before the edit and after the refreshing " + + "read — the refresh must rewrite graph data to reflect the " + + "edited sources (SPEC 13.3: read results never come from " + + "stale data; graph data carries all four hashes, so a text " + + "edit changes it), leaving the mismatch state nothing to " + + "stage", + ); + } + await workspace.file("specs/A.mdx", FAILED_BUILD_VALID_SOURCE); + await assertLeavesUnchanged( + workspace.root, + async () => { + assertSingleUnitFormFinding( + await checkStale("T12.2-3 (mismatch)"), + "T12.2-3 (mismatch) — the generated files again match the " + + "current sources while graph data does not: exactly one " + + "condition-10 finding in the unit form (SPEC 12.2, 13.3, " + + "14.10)", ); + }, + "T12.2-3 (mismatch): `check` never refreshes — on the isolated " + + "mismatch state, graph data and every derived file (the whole " + + "workspace) byte-identical around both invocations " + + "(SPEC 13.3, 12.2)", + ); + + // State 3 — edited source without rebuild: per-file and unit + // staleness together. + await buildOk( + product, + workspace, + "T12.2-3 (edited source) rebuild (SPEC 12.1)", + ); + await workspace.file("specs/A.mdx", editedSource); + await assertLeavesUnchanged( + workspace.root, + async () => { assertAllStale( - findings, - "T12.2-3 — `check` reports the staleness: every finding is " + - "14.10, naming its file and instructing rebuilding " + - "(SPEC 12.2, 14.10)", + await checkStale("T12.2-3 (edited source)"), + "T12.2-3 (edited source) — `check` reports the staleness: " + + "every finding is 14.10, naming its file and instructing " + + "rebuilding (SPEC 12.2, 14.10)", ); }, - "T12.2-3: `check` never refreshes — graph data and derived files " + - "(the whole workspace) byte-identical around both invocations " + - "(SPEC 13.3, 12.2)", + "T12.2-3 (edited source): `check` never refreshes — on the state " + + "carrying per-file and unit staleness together, graph data and " + + "every derived file (the whole workspace) byte-identical " + + "around both invocations (SPEC 13.3, 12.2)", ); }, ); From c3e311a424f702ce3ba3d25be3f919634ae2db64 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 19:18:34 +0000 Subject: [PATCH 088/226] =?UTF-8?q?sdg(phase-9):=20extend=20T12.5-1's=20di?= =?UTF-8?q?spatch=20sweep=20to=20the=20=C2=A711=20surfaces=20(FP-043)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four minimal exit-0 dispatch arms on the existing fixture — occurrences, view, at, inventory — each invoked bare (JSON-only surfaces, SPEC 11): occurrences through the existing form-exact decoder (definitive empty enumeration), view through a new scoped decodeViewFilesReport, at through a new full-form decodeAtReport, inventory through the scoped recorded datum. S-5 gains both decoders' guards; traceability unchanged. Suite file stays 2 failed / 2 passed with T12.5-1 red-as-diagnosed at the patch-new occurrences command; test:self keeps its 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 42 +++- test/helpers/adapters/forms.ts | 168 +++++++++++++++ test/helpers/adapters/model.ts | 46 ++++ test/self/s5-output-adapters.test.ts | 258 +++++++++++++++++++++++ test/suite/registry/section-12.3-12.5.ts | 114 +++++++++- 5 files changed, 621 insertions(+), 7 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 6f3d912..b712c9b 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1768,10 +1768,50 @@ A "new test T<x>" task always means, in one change: unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-043 — T12.5-1: extend the dispatch sweep. [R2 #30; TEST-SPEC +- [x] FP-043 — T12.5-1: extend the dispatch sweep. [R2 #30; TEST-SPEC §12.5] `test/suite/registry/section-12.3-12.5.ts`: add `occurrences`, `view`, `at`, `inventory`. + [Done 2026-08-13: four dispatch arms appended between the `query nodes` + arm and the unknown-command arms (reads before the mutating rename/move), + each a minimal exit-0 probe on the existing fixture, invoked bare — the + §11 surfaces are JSON-only, one document with or without `--json` (SPEC + 11): `occurrences` decoded via the existing form-exact + `decodeOccurrencesReport` and asserted exactly `{findings: [], + occurrences: []}` (no reference spelling staged — the definitive empty + enumeration, SPEC 11.3); `view` via new scoped forms.ts + `decodeViewFilesReport` (top level `{"findings","views"}` exact, each + per-file wrapper's five members exact and present, `file` decoded, + path-byte order strict — a set; `root`/`imports`/`occurrences`/`comments` + deliberately unread, T11.4-*'s subject) asserted `{findings: [], files: + ["specs/D.mdx"]}` — the whole-domain request; `at specs/D.mdx 20` via new + forms.ts `decodeAtReport` (the full 12.7 at form: + `{"findings","resolution"}`, resolution and section-identity datums + value-or-marker never null, occurrence record-or-null) asserted exactly — + section identity specs/D.mdx#anchor, construct range {0,69}, occurrence + null (offset 20 inside "Anchor line.": inside anchor 0..69, outside + anchor.sub 30..64; offsets computed independently and matching `query + node`'s probed sourceRanges); `inventory` via the existing scoped + `decodeInventoryRecordedDatum` — recorded a plain list naming + specs/D.xspec.ts (T12.2-2's precedent; membership, companions + unpinned). Title, module header, and the T12.5-1 operationalization note + updated to the ten-command list; S-5 gains both new decoders' DECODERS + guards (positives incl. resolution/identity unavailability, byte-form + view file, containing-occurrence record, empty views; rejections: + absent/null members, widened markers, extra members at every level, + out-of-order and duplicate per-file views, null section identity). + Traceability unchanged (["12.5"]; 11.2–11.6 are dispatch context with + home coverage at stage G's T11.*-*). Verified: suite file unchanged 2 + failed / 2 passed — T12.5-1 red-as-diagnosed now at the first new arm + (`occurrences` exits 2 "unknown command": the whole §11 surface is + patch-new, so all four arms hit the same dispatch gap; T12.3-1 keeps its + pre-existing FP-002-class red), later arms suite-unreached with decode + satisfiability proven by S-5's positive controls over the same document + shapes and the fixture byte facts probed directly (build generates + specs/D.xspec.ts; D.mdx spells no reference; one discovered spec + source). Typecheck/format clean; `npm run test:self` unchanged 4 planned + mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys + {11.2–11.6, 12.6, 12.7} → stage G), S-5 (80) and certification green.] - [ ] FP-044 — T13.3-1/T13.3-2: extend read sweeps; add the record-discipline arm. [R2 #31; TEST-SPEC §13.3] diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts index a4a6468..9e0c22d 100644 --- a/test/helpers/adapters/forms.ts +++ b/test/helpers/adapters/forms.ts @@ -24,6 +24,9 @@ // {"unavailable": true} (11.4, 12.7) // - the occurrence-record form {"file","range","kind","source","target"} // and the occurrences document {"findings","occurrences"} (5.7, 11.3) +// - the at document {"findings","resolution"} (11.5) +// - the scoped view decode: top level {"findings","views"} and each +// per-file wrapper's form with its `file` member (11.4) // - the rename/move preview document {"findings","mapping","files","delta"} // (6.6) with the ten edit classes and the pinned orders // - the unavailability-marker structural walk T12.7-1 relies on: no object @@ -32,6 +35,9 @@ import { Buffer, isUtf8 } from "node:buffer"; import type { AppliedMappingPair, + AtReport, + AtResolution, + AtSection, ErrorDocument, Finding, FindingLocation, @@ -48,6 +54,7 @@ import type { PreviewFileEntry, PreviewReport, SourceRange, + ViewFilesReport, } from "./model.js"; import { CONDITION_CODE_TOKENS, @@ -681,6 +688,167 @@ export function decodeOccurrencesReport( return { findings, occurrences }; } +// --- the at document (11.5, 12.7) --------------------------------------------- + +/** + * The resolution's section member: `{"identity", "range"}` exactly — the + * innermost enclosing section construct's range, and its node identity per + * 11.2: a plain identity string, or the unavailability marker where 11.2 + * leaves it undefined; never `null`. + */ +function decodeAtSectionForm(value: unknown, site: DecodeSite): AtSection { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["identity", "range"], site); + const identitySite = at(site, "identity"); + const identityDatum = decodeDatum( + obj["identity"], + identitySite, + expectNonEmptyString, + ); + if (identityDatum.state === "null") { + formFail( + identitySite, + "the section's node identity — a plain identity string, or the " + + "unavailability marker where 11.2 leaves it undefined, never null " + + "(SPEC 11.5, 11.2, 12.7)", + null, + ); + } + return { + identity: + identityDatum.state === "value" + ? identityDatum.value + : { unavailable: true as const }, + range: decodeRangeForm(requiredKey(obj, "range", site), at(site, "range")), + }; +} + +/** + * The `at` document (11.5) — `{"findings", "resolution"}` exactly (SPEC + * 12.7): the consulted domain's findings, and `resolution` as + * `{"section", "occurrence"}` — the innermost enclosing section construct + * and the containing occurrence's record, `occurrence` `null` when the + * offset lies within none — or the unavailability marker on an unparseable + * file; never `null`. Form-exact (H-3): 11.5 is a JSON-only surface, no + * adapter in the path. + */ +export function decodeAtReport(doc: unknown, context?: string): AtReport { + const site = rootSite("12.7 at document", context); + const obj = expectObject(doc, site); + expectOnlyMembers(obj, ["findings", "resolution"], site); + const findings = decodeFindingsArray( + requiredKey(obj, "findings", site), + at(site, "findings"), + ); + const resolutionSite = at(site, "resolution"); + const resolutionDatum = decodeDatum( + obj["resolution"], + resolutionSite, + (value, valueSite): AtResolution => { + const res = expectObject(value, valueSite); + expectOnlyMembers(res, ["section", "occurrence"], valueSite); + const occurrenceValue = requiredMember(res, "occurrence", valueSite); + return { + section: decodeAtSectionForm( + requiredKey(res, "section", valueSite), + at(valueSite, "section"), + ), + occurrence: + occurrenceValue === null + ? null + : decodeOccurrenceRecordForm( + occurrenceValue, + at(valueSite, "occurrence"), + ), + }; + }, + ); + if (resolutionDatum.state === "null") { + formFail( + resolutionSite, + 'the resolution {"section", "occurrence"}, or the unavailability ' + + "marker on an unparseable file — never null (SPEC 11.5, 12.7)", + null, + ); + } + return { + findings, + resolution: + resolutionDatum.state === "value" + ? resolutionDatum.value + : { unavailable: true as const }, + }; +} + +// --- scoped view decode: the per-file `file` members (11.4, 12.7) ------------- + +const VIEW_FILE_ENTRY_MEMBERS = [ + "file", + "root", + "imports", + "occurrences", + "comments", +] as const; + +/** + * Scoped decode of the `view` document (SPEC 11.4, 12.7): the top level — + * `{"findings", "views"}` exactly — and each per-file view's wrapper form — + * `{"file", "root", "imports", "occurrences", "comments"}` exactly, every + * member present — with `file` decoded as a 12.7 path value and the + * per-file order enforced: byte order of workspace-relative path, strictly + * ascending, since the requested files form a set (11.4). Deliberately + * scoped (the `decodeInventoryRecordedDatum` pattern): the T11.4-* tests + * pin the full per-file view; this decoder reads exactly what a + * whole-domain dispatch or membership assertion needs, `root`, `imports`, + * `occurrences`, and `comments` staying unread. Form-exact (H-3): never + * adjustable to a product's shape. + */ +export function decodeViewFilesReport( + doc: unknown, + context?: string, +): ViewFilesReport { + const site = rootSite("12.7 view document (files)", context); + const obj = expectObject(doc, site); + expectOnlyMembers(obj, ["findings", "views"], site); + const findings = decodeFindingsArray( + requiredKey(obj, "findings", site), + at(site, "findings"), + ); + const viewsSite = at(site, "views"); + const files = expectArray(requiredKey(obj, "views", site), viewsSite).map( + (element, index) => { + const entrySite = at(viewsSite, index); + const entry = expectObject(element, entrySite); + expectOnlyMembers(entry, VIEW_FILE_ENTRY_MEMBERS, entrySite); + for (const member of VIEW_FILE_ENTRY_MEMBERS) { + if (member === "file") continue; + requiredMember(entry, member, entrySite); + } + return decodePathValue( + requiredKey(entry, "file", entrySite), + at(entrySite, "file"), + ); + }, + ); + for (let i = 1; i < files.length; i += 1) { + if ( + Buffer.compare( + pathValueBytes(files[i - 1]!), + pathValueBytes(files[i]!), + ) >= 0 + ) { + formFail( + at(viewsSite, i), + "per-file views ordered by byte order of workspace-relative path — " + + "the requested files form a set, so the order is strict " + + "(SPEC 11.4, 12.7)", + obj["views"], + ); + } + } + return { findings, files }; +} + // --- the rename/move preview document (6.6, 12.7) ----------------------------- /** One `mapping` entry: `{"from", "to"}` exactly, identities are strings. */ diff --git a/test/helpers/adapters/model.ts b/test/helpers/adapters/model.ts index 2dc54b4..002e274 100644 --- a/test/helpers/adapters/model.ts +++ b/test/helpers/adapters/model.ts @@ -341,6 +341,52 @@ export interface OccurrencesReport { readonly occurrences: readonly OccurrenceRecord[]; } +/** + * The `at` resolution's section member (SPEC.md 11.5, 12.7): the innermost + * section construct whose range contains the offset — the root when no + * narrower section does — as `{"identity", "range"}` exactly: its construct + * range, and its node identity per 11.2 — defined, or explicitly unavailable + * as the marker; never `null`. + */ +export interface AtSection { + readonly identity: string | { readonly unavailable: true }; + readonly range: SourceRange; +} + +/** + * The `at` resolution: `{"section", "occurrence"}` exactly (SPEC.md 12.7) — + * the containing occurrence's record, `null` when the offset lies within + * none. + */ +export interface AtResolution { + readonly section: AtSection; + readonly occurrence: OccurrenceRecord | null; +} + +/** + * The `at` document (SPEC.md 11.5) — `{"findings", "resolution"}` exactly + * (12.7): the consulted domain's findings (the named file's), and the + * resolution — or, on an unparseable file, explicitly unavailable as one + * datum; never `null`. + */ +export interface AtReport { + readonly findings: readonly Finding[]; + readonly resolution: AtResolution | { readonly unavailable: true }; +} + +/** + * Scoped projection of the `view` document (SPEC.md 11.4, 12.7 — decoded by + * `decodeViewFilesReport`): the consulted domain's findings, and each + * per-file view's `file` member in the reported order (byte order of + * workspace-relative path); the per-file `root`, `imports`, `occurrences`, + * and `comments` members are presence-checked and left undecoded — the + * T11.4-* tests pin the full per-file view. + */ +export interface ViewFilesReport { + readonly findings: readonly Finding[]; + readonly files: readonly PathValue[]; +} + /** `coverage` (T8.2-1): all profiles by default, one when named. */ export interface CoverageReport { readonly profiles: readonly CoverageProfileReport[]; diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index fb79eaa..1923ccd 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -35,6 +35,7 @@ import { conditionMention, corruptGraphDataShapeBlind, decodeAppliedMappingReport, + decodeAtReport, decodeCoverageReport, decodeDatum, decodeEdgesReport, @@ -59,6 +60,7 @@ import { decodeReachableReport, decodeSessionListReport, decodeSessionStatusReport, + decodeViewFilesReport, expectNonNegativeInteger, isGraphDataKey, rootSite, @@ -327,6 +329,40 @@ const GOOD_OCCURRENCES = { ], }; +const GOOD_AT = { + findings: [], + resolution: { + section: { + identity: "specs/A.mdx#login", + range: { start: 10, end: 90 }, + }, + occurrence: null, + }, +}; + +// The scoped view decode reads the top level, each wrapper's form, and the +// `file` members; `root`/`imports`/`occurrences`/`comments` are +// presence-checked placeholders here (their values stay unread by design). +const GOOD_VIEWS = { + findings: [], + views: [ + { + file: "specs/A.mdx", + root: { placeholder: true }, + imports: [], + occurrences: [], + comments: [], + }, + { + file: "specs/B.mdx", + root: { placeholder: true }, + imports: [], + occurrences: [], + comments: [], + }, + ], +}; + const GOOD_COVERAGE = { profiles: [ { @@ -1449,6 +1485,228 @@ const DECODERS: readonly DecoderSpec[] = [ }, ], }, + { + name: "12.7 at document", + decode: decodeAtReport, + good: GOOD_AT, + verify: (decoded: ReturnType<typeof decodeAtReport>) => { + expect(decoded.findings).toEqual([]); + // The resolution decodes literally (form-exact, H-3): the innermost + // enclosing section construct with its defined identity, and no + // containing occurrence (`null` is spelled, never omitted). + expect(decoded.resolution).toEqual({ + section: { + identity: "specs/A.mdx#login", + range: { start: 10, end: 90 }, + }, + occurrence: null, + }); + }, + alsoGood: [ + { + label: + "the resolution explicitly unavailable on an unparseable file " + + "(11.5) — never a defaulted section", + doc: { findings: [], resolution: { unavailable: true } }, + verify: (decoded: ReturnType<typeof decodeAtReport>): void => { + expect(decoded.resolution).toEqual({ unavailable: true }); + }, + }, + { + label: + "the section's identity unavailable per 11.2 while its " + + "construct range stays on view", + doc: { + findings: [], + resolution: { + section: { + identity: { unavailable: true }, + range: { start: 0, end: 40 }, + }, + occurrence: null, + }, + }, + verify: (decoded: ReturnType<typeof decodeAtReport>): void => { + expect(decoded.resolution).toEqual({ + section: { + identity: { unavailable: true }, + range: { start: 0, end: 40 }, + }, + occurrence: null, + }); + }, + }, + { + label: "a containing occurrence's record decodes literally (5.7, 12.7)", + doc: put( + GOOD_AT, + structuredClone(GOOD_OCCURRENCES.occurrences[0]), + "resolution", + "occurrence", + ), + verify: (decoded: ReturnType<typeof decodeAtReport>): void => { + const resolution = decoded.resolution; + if ("unavailable" in resolution) { + throw new Error("resolution unexpectedly unavailable"); + } + expect(resolution.occurrence).toEqual( + GOOD_OCCURRENCES.occurrences[0], + ); + }, + }, + ], + bad: [ + { label: "missing findings member", doc: omit(GOOD_AT, "findings") }, + { + label: "missing resolution member (null is never omission, SPEC 12.7)", + doc: omit(GOOD_AT, "resolution"), + }, + { + label: + "null resolution (a value or the unavailability marker, never null)", + doc: put(GOOD_AT, null, "resolution"), + }, + { + label: + "an extra member on the document (12.7: exactly " + + "{findings, resolution})", + doc: put(GOOD_AT, 20, "offset"), + }, + { + label: "resolution missing its section", + doc: omit(GOOD_AT, "resolution", "section"), + }, + { + label: + "resolution missing its occurrence member (null is spelled, " + + "never omitted, SPEC 12.7)", + doc: omit(GOOD_AT, "resolution", "occurrence"), + }, + { + label: "an extra member on the resolution", + doc: put(GOOD_AT, 1, "resolution", "extra"), + }, + { + label: "section missing its range", + doc: omit(GOOD_AT, "resolution", "section", "range"), + }, + { + label: "section missing its identity", + doc: omit(GOOD_AT, "resolution", "section", "identity"), + }, + { + label: + "null section identity (defined or explicitly unavailable, " + + "never null — SPEC 11.2, 12.7)", + doc: put(GOOD_AT, null, "resolution", "section", "identity"), + }, + { + label: "an extra member on the section", + doc: put(GOOD_AT, "x", "resolution", "section", "note"), + }, + { + label: + "a widened unavailability marker as the resolution (12.7: the " + + 'marker is exactly {"unavailable": true})', + doc: put(GOOD_AT, { unavailable: true, section: null }, "resolution"), + }, + ], + }, + { + name: "12.7 view document (files)", + decode: decodeViewFilesReport, + good: GOOD_VIEWS, + verify: (decoded: ReturnType<typeof decodeViewFilesReport>) => { + expect(decoded.findings).toEqual([]); + // The per-file `file` members in the reported (path-byte) order; the + // unread wrapper members are presence-checked only (module scope). + expect(decoded.files).toEqual(["specs/A.mdx", "specs/B.mdx"]); + }, + alsoGood: [ + { + label: + "an empty request (a glob admitting none — an empty, " + + "finding-free answer, 11.4)", + doc: { findings: [], views: [] }, + verify: (decoded: ReturnType<typeof decodeViewFilesReport>): void => { + expect(decoded.findings).toEqual([]); + expect(decoded.files).toEqual([]); + }, + }, + { + label: "a non-UTF-8 view file in the marked byte form (SPEC 12.0)", + doc: { + findings: [], + views: [ + { + file: { bytes: "ff2e6d6478" }, + root: { placeholder: true }, + imports: [], + occurrences: [], + comments: [], + }, + ], + }, + verify: (decoded: ReturnType<typeof decodeViewFilesReport>): void => { + expect(decoded.files).toEqual([{ bytes: "ff2e6d6478" }]); + }, + }, + ], + bad: [ + { label: "missing findings member", doc: omit(GOOD_VIEWS, "findings") }, + { label: "missing views member", doc: omit(GOOD_VIEWS, "views") }, + { + label: "null views (null never encodes emptiness, SPEC 12.7)", + doc: put(GOOD_VIEWS, null, "views"), + }, + { + label: + "an extra member on the document (12.7: exactly {findings, views})", + doc: put(GOOD_VIEWS, 2, "count"), + }, + { + label: "a per-file view missing its file", + doc: omit(GOOD_VIEWS, "views", 0, "file"), + }, + { + label: + "a per-file view missing its root member (every wrapper member " + + "is present, SPEC 12.7)", + doc: omit(GOOD_VIEWS, "views", 0, "root"), + }, + { + label: "a per-file view missing its comments member", + doc: omit(GOOD_VIEWS, "views", 1, "comments"), + }, + { + label: + "an extra member on a per-file view (12.7: exactly " + + "{file, root, imports, occurrences, comments})", + doc: put(GOOD_VIEWS, 1, "views", 0, "extra"), + }, + { + label: "per-file views out of file-path byte order (SPEC 11.4, 12.7)", + doc: { + findings: [], + views: [ + structuredClone(GOOD_VIEWS.views[1]), + structuredClone(GOOD_VIEWS.views[0]), + ], + }, + }, + { + label: + "duplicate per-file views (11.4: the requested files form a set)", + doc: { + findings: [], + views: [ + structuredClone(GOOD_VIEWS.views[0]), + structuredClone(GOOD_VIEWS.views[0]), + ], + }, + }, + ], + }, { name: "12.7 preview document", decode: decodePreviewReport, diff --git a/test/suite/registry/section-12.3-12.5.ts b/test/suite/registry/section-12.3-12.5.ts index 4fd6d2a..510673c 100644 --- a/test/suite/registry/section-12.3-12.5.ts +++ b/test/suite/registry/section-12.3-12.5.ts @@ -21,9 +21,9 @@ // (root) and prints identity, source range (1.7), own and subtree text, // hashes, tags, coverage attribute (absent for a root node, 11), and edges by // kind; `query node` is the machine-facing equivalent. SPEC 12.5: `coverage`, -// `impact`, `review`, `query`, `rename`, `move` behave as sections 8, 9, 10, -// 11, and 6 specify; an unknown subcommand or command is a usage error -// (exit 2, 12.0). +// `impact`, `review`, `query`, `occurrences`, `view`, `at`, `inventory`, +// `rename`, `move` behave as sections 8, 9, 10, 11, and 6 specify; an +// unknown subcommand or command is a usage error (exit 2, 12.0). // // Conservative operationalizations (noted per H-3/H-4): // - Tree node IDs are the full requirement IDs (`zeta.minor`), not bare @@ -62,7 +62,12 @@ // so the unknown-command arms discriminate "unknown → exit 2" from a CLI // that exits 2 for everything. Unknown arms assert exit 2 exactly and, // under `--json`, the 12.7 error document as the entire stdout (SPEC -// 12.0). +// 12.0). The four §11 read surfaces are JSON-only — one document with or +// without `--json` (SPEC 11), invoked bare here: `occurrences` and `at` +// decode through their form-exact 12.7 document decoders; `view` through +// the scoped file-members decode and `inventory` through the scoped +// `recorded` datum (the full per-file view and inventory forms are +// T11.4-*'s and T11.6-*'s subjects). // - T12.3-2's coverage arm asserts the demonstration facts (the profile's // uncovered set, the referenced-yet-uncovered node among it) — full §8 // report content is T8-*'s subject. @@ -75,13 +80,17 @@ import type { } from "../../helpers/adapters/index.js"; import { assertReportMentions, + decodeAtReport, decodeCoverageReport, decodeIdsReport, decodeIdsTreeReport, decodeImpactReport, + decodeInventoryRecordedDatum, decodeNodeReport, decodeNodeRowsReport, + decodeOccurrencesReport, decodeSessionListReport, + decodeViewFilesReport, } from "../../helpers/adapters/index.js"; import type { Mention } from "../../helpers/adapters/index.js"; import type { GraphEdge } from "../../helpers/adapters/index.js"; @@ -910,7 +919,7 @@ const T12_4_1 = defineProductTest({ }); // --------------------------------------------------------------------------- -// T12.5-1 — dispatch: the six commands reach their sections; unknown → 2 +// T12.5-1 — dispatch: the ten commands reach their sections; unknown → 2 // --------------------------------------------------------------------------- const T12_5_1_D = [ @@ -927,7 +936,7 @@ const T12_5_1_D = [ const T12_5_1 = defineProductTest({ id: "T12.5-1", title: - "`coverage`, `impact`, `review`, `query`, `rename`, and `move` dispatch into their sections' specified outcomes (behavior covered in sections 8, 9, 10, 11, 6); an unknown command or an unknown `query`/`review` subcommand is a usage error, exit 2 (SPEC 12.5, 12.0)", + "`coverage`, `impact`, `review`, `query`, `occurrences`, `view`, `at`, `inventory`, `rename`, and `move` dispatch into their sections' specified outcomes (behavior covered in sections 8, 9, 10, 11, 6; the four §11 read surfaces are JSON-only, answering one document when invoked bare); an unknown command or an unknown `query`/`review` subcommand is a usage error, exit 2 (SPEC 12.5, 12.0, 11)", run: async (product) => { const workspace = await TestWorkspace.create({ files: { @@ -1021,6 +1030,99 @@ const T12_5_1 = defineProductTest({ `requirement nodes, the root included (SPEC 11, 1.2)`, ); + // `occurrences` (SPEC 11.3): a JSON-only surface — one document with + // or without `--json` (SPEC 11), invoked bare. Nothing in the + // workspace spells a reference, so the enumeration is the definitive + // empty, finding-free answer, exit 0. + const occurrencesContext = "T12.5-1 `occurrences` (dispatch)"; + const occurrences = decodeOccurrencesReport( + await runJson(product, workspace, ["occurrences"], occurrencesContext), + occurrencesContext, + ); + assertSameJson( + occurrences, + { findings: [], occurrences: [] }, + `${occurrencesContext}: no reference spelling exists in the ` + + `workspace, so the enumeration is empty and finding-free — ` + + `definitive over the whole discovered set (SPEC 11.3, 5.7, 12.7)`, + ); + + // `view` (SPEC 11.4): with neither `<file>` operands nor `--file`, + // the request covers every discovered spec source — here exactly + // specs/D.mdx, finding-free (scoped decode; the full per-file view + // is T11.4-*'s subject). + const viewContext = "T12.5-1 `view` (dispatch)"; + const view = decodeViewFilesReport( + await runJson(product, workspace, ["view"], viewContext), + viewContext, + ); + assertSameJson( + view, + { findings: [], files: ["specs/D.mdx"] }, + `${viewContext}: with neither operands nor \`--file\`, the request ` + + `covers every discovered spec source — one per-file view, for ` + + `specs/D.mdx, finding-free (SPEC 11.4, 12.7)`, + ); + + // `at` (SPEC 11.5): byte offset 20 lies inside "Anchor line." — + // within `anchor`'s construct range (bytes 0..69: `<S id="anchor">` + // opens at byte 0 and its closing `</S>` ends at byte 69), outside + // `anchor.sub`'s (bytes 30..64) — so the innermost enclosing section + // construct is `anchor`; the offset lies within no occurrence. + const atContext = "T12.5-1 `at specs/D.mdx 20` (dispatch)"; + const atReport = decodeAtReport( + await runJson( + product, + workspace, + ["at", "specs/D.mdx", "20"], + atContext, + ), + atContext, + ); + assertSameJson( + atReport, + { + findings: [], + resolution: { + section: { + identity: "specs/D.mdx#anchor", + range: { start: 0, end: 69 }, + }, + occurrence: null, + }, + }, + `${atContext}: the offset resolves to the innermost enclosing ` + + `section construct — \`anchor\`, its construct range bytes 0..69 ` + + `(1.7) — with no containing occurrence and no finding ` + + `(SPEC 11.5, 11.2, 12.7)`, + ); + + // `inventory` (SPEC 11.6): parses no sources and answers the + // workspace's shape; the record-supplied datum names the module the + // `build` above generated (scoped decode; the full inventory form is + // T11.6-*'s subject). + const inventoryContext = "T12.5-1 `inventory` (dispatch)"; + const recorded = decodeInventoryRecordedDatum( + await runJson(product, workspace, ["inventory"], inventoryContext), + inventoryContext, + ); + if (recorded.state !== "value") { + fail( + `${inventoryContext}: after the successful \`build\` above, the ` + + `record-supplied datum is the plain recorded derived-file ` + + `paths — never unavailability, never null (SPEC 11.6, 12.7); ` + + `got state ${JSON.stringify(recorded.state)}`, + ); + } + if (!recorded.value.includes("specs/D.xspec.ts")) { + fail( + `${inventoryContext}: the recorded derived-file paths — the ` + + `paths as last generated, companions included — name the ` + + `generated module specs/D.xspec.ts (SPEC 11.6, 13.1, 13.3); ` + + `got ${JSON.stringify(recorded.value)}`, + ); + } + // Unknown command and unknown subcommands → exit 2 (SPEC 12.5, 12.0). await expectUsageError( product, From 102abe4d5cdeb97cf7b49ccb2eb6780c29ec8dde Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 19:50:28 +0000 Subject: [PATCH 089/226] =?UTF-8?q?sdg(phase-9):=20extend=20T13.3-1/-2=20s?= =?UTF-8?q?weeps=20to=20the=20=C2=A711=20surfaces;=20add=20the=20record-di?= =?UTF-8?q?scipline=20arm=20(FP-044)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T13.3-1 and both T13.3-2 refresh arms now sweep occurrences/view/at at the identity/membership level (spans and per-file view content stay T11.3-*/T11.4-*/T11.5-*'s home); T13.3-2's edit arm stages a d reference so the three surfaces discriminate stale data; a new record-discipline arm corrupts the record shape-blind and asserts each of the nine refreshing reads answers finding-free at exit 0 with the corrupt state neither read nor replaced — inventory reports recorded unavailable at exit 1 after every read — until a successful build replaces it. T13.3-2's traceability gains "14" (TEST-SPEC 14 lists it under 14.23). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 55 ++- test/suite/registry/section-13.3.ts | 674 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 2 +- 3 files changed, 715 insertions(+), 16 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index b712c9b..48a3673 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1813,12 +1813,65 @@ A "new test T<x>" task always means, in one change: mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 (80) and certification green.] -- [ ] FP-044 — T13.3-1/T13.3-2: extend read sweeps; add the +- [x] FP-044 — T13.3-1/T13.3-2: extend read sweeps; add the record-discipline arm. [R2 #31; TEST-SPEC §13.3] `test/suite/registry/section-13.3.ts`: sweeps gain `occurrences`, `view`, `at`. T13.3-2 gains: shape-blind record corruption → refreshing reads answer finding-free exit 0, state neither read nor replaced, `inventory` reports `recorded` unavailable until `build`. + [Done 2026-08-13: T13.3-1's serving sweep gains the three probes inside + its leaves-unchanged block — `occurrences` (the complete record set at + identity level via new module helpers `occurrenceIdentitySummaries`/ + `assertAtAnswer`: the one d occurrence alpha→beta, finding-free), `view` + (scoped decode, exactly {findings [], files [specs/A.mdx]}), `at` offset + 30 → alpha with occurrence null — the identity/membership altitude + documented in the module header (byte-precise spans and per-file view + content are T11.3-*/T11.4-*/T11.5-*'s home; ranges still form-validated + by the S-5-guarded decoders). T13.3-2 arm A gains the same three + (definitive empty enumeration; both files; offset 20 → alpha), each + followed by the existing rewritten-exactly-as-build w0 compare; arm B's + edit now stages `<S id="added" d={["alpha"]}>` — the edited sources' + one occurrence — so `occurrences` (added→alpha) and `coverage` + (uncovered exactly [added, beta], covered exactly alpha via + [added, alpha]) answer values the stale pre-edit graph cannot produce, + `at` 75 resolves the edited-source-only identity added, `view` answers + the whole domain finding-free. New record-discipline arm on its own + workspace (alpha d=["beta"]+beta, git baseline, audit session): + `corruptGraphDataShapeBlind` (T6.6-6's staging, the H-3 record-staging + adapter), then all nine refreshing reads, each asserting the exit-0 + finding-free contentful answer, then `inventory` exit 1 with `recorded` + still explicitly unavailable (persistence per read — neither read, + repaired, nor replaced; the finding's full form stays T11.6-4's home), + then outside-graph-data byte-identity against the post-corruption + snapshot (graph-data bytes deliberately unpinned: the record's location + inside the area is unenumerated, so a conforming refresh may rewrite + non-record files around the preserved record state — module header + note); after the sweep, `build` exit 0 → `inventory` exit 0 with + `recorded` a plain list naming specs/A.xspec.ts. Traceability: T13.3-2 + gains "14" (TEST-SPEC 14's per-condition record lists it under 14.23; + the map's construction note carries that record at passage granularity). + Verified: typecheck/format clean; section-13.3 went 2 failed/2 passed → + 3 failed/1 passed — T13.3-1 falsely-green → red-as-diagnosed at its + `occurrences` probe and T13.3-2's red moved earlier to arm A's + `occurrences` probe (both exit 2 "unknown command 'occurrences'" — the + whole §11 surface is patch-new, the FP-043-diagnosed dispatch gap; + T13.3-3 keeps its FP-001-class red, T13.3-4 green). Suite-unreached + arms probe-verified directly against the built product: arm B's + review-status invalidation table, the V1 coverage/ids/show answers + (added's construct range 41..94 confirmed), and the deleted-source + sub-arm end-to-end under V1 (B's module survives the read, `check` + reports 14.10 ×4 on B's derived files, the final build removes them, A + survives); record arm: `ids` on the corrupt record answers exit 0 while + the current product rewrites the whole record — exactly the + replace-the-state behavior the arm discriminates — and `inventory` + exits 2 (unknown), the arm's first diagnosed failure if reached; + at-offsets computed mechanically (30/20/75 inside alpha/alpha/added, + outside the occurrence spans 18..24/none/59..66). `npm run test:self`: + unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; + S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and + certification green (CONF-CORE 9/9, CONF-VALID 12/12, CONF-MD 8/8, + CONF-DISC 3/3; violators failing exactly as certified). No new decoder + (S-5 unchanged); T13.3-1/-2 in no certification scope.] - [ ] FP-045 — T13.3-3: add the whole-gate arms and the never-gated contrast. [R2 #32; TEST-SPEC §13.3] diff --git a/test/suite/registry/section-13.3.ts b/test/suite/registry/section-13.3.ts index 5da715a..f34a0ae 100644 --- a/test/suite/registry/section-13.3.ts +++ b/test/suite/registry/section-13.3.ts @@ -50,23 +50,52 @@ // over the whole workspace root, `.git/` included (SPEC 13.3, 12.1; // `.git/` byte-identity around git-reading invocations is also T12.0-11's // subject). +// - The T13.3-1/T13.3-2 sweeps include the 11.2 surfaces (`occurrences`, +// `view`, `at`) per their TEST-SPEC command lists. Their answers are +// asserted at the identity/membership level — complete record sets with +// endpoints, resolved section identities, the scoped per-file list of the +// view document — because byte-precise span and per-file view semantics +// are T11.3-*/T11.4-*/T11.5-*'s home; this section owns the +// serving/refresh behaviors those answers demonstrate. +// - T13.3-2's record-discipline arm (record corrupted shape-blind, T6.6-6's +// staging via the H-3 record-staging adapter): SPEC 13.3 pins the record — +// the recorded derived-file paths — as neither read, repaired, nor +// replaced by a refresh, while the record's location inside the graph-data +// area is deliberately unenumerated (13.3, 11.6), so the harness cannot +// byte-pin which files under `.xspec/` a conforming refresh may rewrite +// around the preserved record state. The arm therefore asserts persistence +// through the record-consulting surface TEST-SPEC names: after every +// refreshing read, `inventory` still reports `recorded` explicitly +// unavailable (exit 1, the 14.23 outcome; the finding's full form is +// T11.6-4's home), while outside the graph data the workspace stays +// byte-identical (no TypeScript or Markdown generated or removed, sources +// and durable files untouched); a successful `build` then replaces the +// state (`recorded` a plain list again at exit 0, SPEC 13.3, 14.10, 12.1). import { Buffer } from "node:buffer"; import * as fsp from "node:fs/promises"; import type { + AtReport, Finding, + OccurrenceRecord, + PathValue, SessionStatusReport, SessionStatusRow, } from "../../helpers/adapters/index.js"; import { + corruptGraphDataShapeBlind, + decodeAtReport, decodeCoverageReport, decodeFindingsReport, decodeIdsReport, decodeImpactReport, + decodeInventoryRecordedDatum, decodeNodeReport, decodeNodeRowsReport, + decodeOccurrencesReport, decodeSessionListReport, decodeSessionStatusReport, + decodeViewFilesReport, isGraphDataKey, } from "../../helpers/adapters/index.js"; import { @@ -363,6 +392,72 @@ async function resolveNoChange( ); } +// --------------------------------------------------------------------------- +// §11.2-surface sweep helpers (SPEC 11.3, 11.5) — the identity-level scope +// this module's sweeps assert (see the header note) +// --------------------------------------------------------------------------- + +/** One sweep probe: a labeled read invocation with its answer assertions. */ +interface SweepProbe { + readonly label: string; + readonly run: () => Promise<void>; +} + +/** + * Identity-level projection of occurrence records: referencing file, edge + * kind, source graph-node identity (or the unavailability marker), resolved + * target identity. Ranges stay unprojected — the decoder validates their + * form and order, and byte-precise span semantics are T11.3-*'s home + * (SPEC 5.7, 11.3). + */ +function occurrenceIdentitySummaries(records: readonly OccurrenceRecord[]): { + file: PathValue; + kind: string; + source: string | { readonly unavailable: true }; + target: string; +}[] { + return records.map((record) => ({ + file: record.file, + kind: record.kind, + source: + "identity" in record.source ? record.source.identity : record.source, + target: record.target, + })); +} + +/** + * Assert an `at` answer at this module's identity level: finding-free, the + * resolution present (only an unparseable file's resolution is unavailable, + * and these fixtures are parseable), resolving to the expected section + * identity with no containing occurrence (SPEC 11.5, 11.2; construct-range + * byte precision is T11.5-*'s home). + */ +function assertAtAnswer( + report: AtReport, + expectedIdentity: string, + context: string, +): void { + if ("unavailable" in report.resolution) { + fail( + `${context}: the resolution must be present — the named file is ` + + `parseable, and only an unparseable file's resolution is reported ` + + `explicitly unavailable (SPEC 11.5, 11.2); got the unavailability ` + + `marker`, + ); + } + assertSameJson( + { + findings: report.findings, + identity: report.resolution.section.identity, + occurrence: report.resolution.occurrence, + }, + { findings: [], identity: expectedIdentity, occurrence: null }, + `${context}: a finding-free answer resolving the offset to the ` + + `innermost enclosing section construct, the offset lying within no ` + + `occurrence (SPEC 11.5, 11.2)`, + ); +} + // --------------------------------------------------------------------------- // T13.3-1 — serving reads // --------------------------------------------------------------------------- @@ -381,7 +476,7 @@ const T13_3_1_A = [ const T13_3_1 = defineProductTest({ id: "T13.3-1", title: - "after `build`, the read commands (check, ids, show, coverage, impact, review, query) answer without error, and graph data lives under .xspec/ (SPEC 13.3, 12.0)", + "after `build`, the read commands (check, ids, show, coverage, impact, review, query, occurrences, view, at) answer without error, and graph data lives under .xspec/ (SPEC 13.3, 12.0)", run: async (product) => { await withWorkspace( { "xspec.config.ts": GRAPH_CONFIG, "specs/A.mdx": T13_3_1_A }, @@ -588,6 +683,73 @@ const T13_3_1 = defineProductTest({ ); } } + + // The 11.2 surfaces are read commands of 13.3 too — JSON-only, + // answering one document when invoked bare (SPEC 11, 11.2). + const occurrencesLabel = "T13.3-1 `occurrences`"; + const occurrences = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences"], + occurrencesLabel, + ), + occurrencesLabel, + ); + assertSameJson( + { + findings: occurrences.findings, + occurrences: occurrenceIdentitySummaries( + occurrences.occurrences, + ), + }, + { + findings: [], + occurrences: [ + { + file: A_ROOT, + kind: "depends", + source: ALPHA, + target: BETA, + }, + ], + }, + `${occurrencesLabel}: the staged d entry is the workspace's ` + + `one reference occurrence — alpha's depends reference to ` + + `beta, finding-free (SPEC 11.3, 5.7, 13.3)`, + ); + + const viewLabel = "T13.3-1 `view`"; + const view = decodeViewFilesReport( + await runJson(product, workspace, ["view"], viewLabel), + viewLabel, + ); + assertSameJson( + view, + { findings: [], files: [A_ROOT] }, + `${viewLabel}: with neither operands nor --file, the request ` + + `covers every discovered spec source — one per-file view, ` + + `finding-free (SPEC 11.4, 12.7)`, + ); + + // Byte 30 lies inside "Alpha depends on beta." — within alpha's + // construct (bytes 0..55), outside beta's (starting at 57) and + // outside the d entry's occurrence span ("beta" at bytes + // 18..24) (SPEC 11.5, 1.7). + const atLabel = "T13.3-1 `at specs/A.mdx 30`"; + assertAtAnswer( + decodeAtReport( + await runJson( + product, + workspace, + ["at", A_ROOT, "30"], + atLabel, + ), + atLabel, + ), + ALPHA, + atLabel, + ); }, "T13.3-1 the read commands serve from the graph data `build` " + "wrote without modifying anything in the workspace (SPEC 13.3, " + @@ -608,22 +770,41 @@ const T13_3_2_A_V0 = [ "</S>", "", ].join("\n"); +// The edit adds a section carrying a same-file d reference: the edited +// sources hold exactly one reference occurrence where the pre-edit sources +// hold none, so `occurrences` (and `coverage`, via the new edge) answer +// values stale graph data cannot produce (SPEC 5.7, 8.2, 13.3). const T13_3_2_A_V1 = [ '<S id="alpha">', "Alpha revised text.", "</S>", "", - '<S id="added">', + '<S id="added" d={["alpha"]}>', "Added section text.", "</S>", "", ].join("\n"); const T13_3_2_B = ['<S id="beta">', "Beta text.", "</S>", ""].join("\n"); +// The record-discipline arm's one source: a d reference makes every +// surface's answer contentful (the one occurrence; beta covered through it), +// and the workspace is otherwise clean — a successful `build` precedes the +// corruption, so nothing but the corrupt record is wrong (SPEC 13.3, 14.23). +const T13_3_2_RECORD_A = [ + '<S id="alpha" d={["beta"]}>', + "Alpha depends on beta.", + "</S>", + "", + '<S id="beta">', + "Beta text.", + "</S>", + "", +].join("\n"); + const T13_3_2 = defineProductTest({ id: "T13.3-2", title: - "deleting the graph data (every path under .xspec/ except the durable journal and reviews/) or editing a source makes each of ids, show, coverage, impact, review status, query answer from current sources and rewrite graph data as `build` would write it — while no TypeScript or Markdown is generated or removed and the recorded derived-file paths stay unchanged (a stale module stays stale, `check` reports 14.10; a later `build` removes the recorded orphan) (SPEC 13.3, 13.4, 12.1)", + "deleting the graph data (every path under .xspec/ except the durable journal and reviews/) or editing a source makes each of ids, show, coverage, impact, review status, query, occurrences, view, at answer from current sources and rewrite graph data as `build` would write it — while no TypeScript or Markdown is generated or removed and the recorded derived-file paths stay unchanged (a stale module stays stale, `check` reports 14.10; a later `build` removes the recorded orphan); with the record corrupted shape-blind instead, each refreshing read answers finding-free at exit 0, leaving the corrupt state neither read, repaired, nor replaced — `inventory` still reports `recorded` unavailable — until a successful `build` replaces the state (SPEC 13.3, 13.4, 12.1, 14.23)", run: async (product) => { await withWorkspace( { @@ -688,15 +869,10 @@ const T13_3_2 = defineProductTest({ assertGraphDataPresent(w0, "T13.3-2 after the staging builds"); const staleGraph = graphDataEntries(w0); - // The six refreshing reads (SPEC 13.3; `review` represented by + // The nine refreshing reads (SPEC 13.3; `review` represented by // `status` per the T13.3-2 command list), with per-arm answer // assertions supplied by each arm below. - type Probe = { - readonly label: string; - readonly run: () => Promise<void>; - }; - - const armAProbes: readonly Probe[] = [ + const armAProbes: readonly SweepProbe[] = [ { label: "`ids --json`", run: async () => { @@ -830,6 +1006,62 @@ const T13_3_2 = defineProductTest({ } }, }, + { + label: "`occurrences`", + run: async () => { + const label = "T13.3-2 (deleted graph data) `occurrences`"; + const report = decodeOccurrencesReport( + await runJson(product, workspace, ["occurrences"], label), + label, + ); + assertSameJson( + report, + { findings: [], occurrences: [] }, + `${label}: no reference spelling exists in these sources — ` + + `the definitive empty, finding-free enumeration, answered ` + + `from the current sources (SPEC 11.3, 5.7, 13.3)`, + ); + }, + }, + { + label: "`view`", + run: async () => { + const label = "T13.3-2 (deleted graph data) `view`"; + const view = decodeViewFilesReport( + await runJson(product, workspace, ["view"], label), + label, + ); + assertSameJson( + view, + { findings: [], files: [A_ROOT, B_ROOT] }, + `${label}: with neither operands nor --file the request ` + + `covers every discovered spec source, finding-free ` + + `(SPEC 11.4, 12.7, 13.3)`, + ); + }, + }, + { + label: "`at`", + run: async () => { + // Byte 20 lies inside "Alpha original text." — within + // alpha's construct (bytes 0..40 of the pre-edit source), + // and the source spells no occurrence (SPEC 11.5, 1.7). + const label = "T13.3-2 (deleted graph data) `at specs/A.mdx 20`"; + assertAtAnswer( + decodeAtReport( + await runJson( + product, + workspace, + ["at", A_ROOT, "20"], + label, + ), + label, + ), + ALPHA, + label, + ); + }, + }, ]; // --- Arm A: deletion trigger. Before each command the graph data is @@ -872,7 +1104,7 @@ const T13_3_2 = defineProductTest({ bytes: Buffer.from(T13_3_2_A_V1, "utf8"), }); - const armBProbes: readonly Probe[] = [ + const armBProbes: readonly SweepProbe[] = [ { label: "`ids --json`", run: async () => { @@ -943,9 +1175,17 @@ const T13_3_2 = defineProductTest({ } assertSameJson( [...profile.uncovered].sort(), - [ADDED, ALPHA, BETA], - `${label}: the added node is required and uncovered — the ` + - `answer reflects the edited sources (SPEC 8.1, 8.2, 13.3)`, + [ADDED, BETA], + `${label}: the added node is required and uncovered while ` + + `alpha is covered through its new d edge — the answer ` + + `reflects the edited sources (SPEC 8.1, 8.2, 13.3)`, + ); + assertSameJson( + profile.covered, + [{ identity: ALPHA, path: [ADDED, ALPHA] }], + `${label}: alpha's covering path exists only in the edited ` + + `sources — the stale graph holds no dependency edge at ` + + `all (SPEC 8.2, 13.3)`, ); }, }, @@ -1035,6 +1275,77 @@ const T13_3_2 = defineProductTest({ } }, }, + { + label: "`occurrences`", + run: async () => { + const label = "T13.3-2 (edited source) `occurrences`"; + const report = decodeOccurrencesReport( + await runJson(product, workspace, ["occurrences"], label), + label, + ); + assertSameJson( + { + findings: report.findings, + occurrences: occurrenceIdentitySummaries(report.occurrences), + }, + { + findings: [], + occurrences: [ + { + file: A_ROOT, + kind: "depends", + source: ADDED, + target: ALPHA, + }, + ], + }, + `${label}: the edited source's d entry is the workspace's ` + + `one reference occurrence — the pre-edit sources spell ` + + `none, so stale graph data cannot produce this answer ` + + `(SPEC 11.3, 5.7, 13.3)`, + ); + }, + }, + { + label: "`view`", + run: async () => { + const label = "T13.3-2 (edited source) `view`"; + const view = decodeViewFilesReport( + await runJson(product, workspace, ["view"], label), + label, + ); + assertSameJson( + view, + { findings: [], files: [A_ROOT, B_ROOT] }, + `${label}: the whole-domain request answers finding-free ` + + `over the edited, valid sources (SPEC 11.4, 12.7, 13.3)`, + ); + }, + }, + { + label: "`at`", + run: async () => { + // Byte 75 lies inside "Added section text." — within the + // added section's construct (bytes 41..94 of the edited + // source) and outside its d entry's occurrence span ("alpha" + // at bytes 59..66); the section exists only in the edited + // source, so a stale answer cannot name it (SPEC 11.5, 13.3). + const label = "T13.3-2 (edited source) `at specs/A.mdx 75`"; + assertAtAnswer( + decodeAtReport( + await runJson( + product, + workspace, + ["at", A_ROOT, "75"], + label, + ), + label, + ), + ADDED, + label, + ); + }, + }, ]; let refreshedGraph: Map<string, SnapshotEntry> | undefined; @@ -1247,6 +1558,341 @@ const T13_3_2 = defineProductTest({ } }, ); + + // --- Record discipline (SPEC 13.3, 14.23): with the record corrupted + // shape-blind (T6.6-6's staging), each refreshing read answers + // finding-free at exit 0 on the otherwise clean workspace, reporting + // nothing for the record and leaving the corrupt state neither read, + // repaired, nor replaced — `inventory` still reports `recorded` + // explicitly unavailable after every read (the record-consulting + // surface; see the module header for why graph-data bytes are not + // pinned here) — until a successful `build` replaces the state. + await withWorkspace( + { "xspec.config.ts": GRAPH_CONFIG, "specs/A.mdx": T13_3_2_RECORD_A }, + async (workspace) => { + const A_ROOT = "specs/A.mdx"; + const ALPHA = "specs/A.mdx#alpha"; + const BETA = "specs/A.mdx#beta"; + + // Staging: a resolvable baseline for `impact --base`, a successful + // `build` (the corruption applies only to record files the product + // itself wrote, H-3), and an audit session for `review status`. + await workspace.gitInit(); + const base = await workspace.gitCommitAll("baseline"); + await buildOk(product, workspace, "T13.3-2 (corrupt record) `build`"); + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", "s"], + 0, + "T13.3-2 (corrupt record) staging `review create --strategy " + + "audit --name s` (SPEC 10.7)", + ); + await corruptGraphDataShapeBlind( + workspace.root, + "T13.3-2 (corrupt record) staging", + ); + const corrupted = await snapshotDirectory(workspace.root); + const outsideGraph = filteredEntries( + corrupted.entries, + (key) => !isGraphDataKey(key), + ); + + const recordProbes: readonly SweepProbe[] = [ + { + label: "`ids --json`", + run: async () => { + const label = "T13.3-2 (corrupt record) `ids --json`"; + const ids = decodeIdsReport( + await runJson(product, workspace, ["ids", "--json"], label), + label, + ); + assertSameJson( + ids.files, + [{ file: A_ROOT, ids: ["alpha", "beta"] }], + `${label}: the current sources' IDs, answered finding-free ` + + `(SPEC 13.3, 12.3)`, + ); + }, + }, + { + label: "`show`", + run: async () => { + const label = `T13.3-2 (corrupt record) \`show ${ALPHA} --json\``; + const node = decodeNodeReport( + await runJson( + product, + workspace, + ["show", ALPHA, "--json"], + label, + ), + label, + ); + assertBytesEqual( + node.subtreeText, + "Alpha depends on beta.\n", + `${label}: subtree text from the current sources (SPEC ` + + `13.3, 12.4)`, + ); + }, + }, + { + label: "`coverage --json`", + run: async () => { + const label = "T13.3-2 (corrupt record) `coverage --json`"; + const coverage = decodeCoverageReport( + await runJson( + product, + workspace, + ["coverage", "--json"], + label, + ), + label, + ); + const profile = coverage.profiles.find((p) => p.name === "p"); + if (profile === undefined) { + fail( + `${label}: the configured profile "p" must be reported ` + + `(SPEC 8.2); got ` + + JSON.stringify(coverage.profiles.map((p) => p.name)), + ); + } + assertSameJson( + { + covered: profile.covered, + uncovered: profile.uncovered, + }, + { + covered: [{ identity: BETA, path: [ALPHA, BETA] }], + uncovered: [ALPHA], + }, + `${label}: beta covered through alpha's d edge, alpha ` + + `uncovered (SPEC 8.2, 13.3)`, + ); + }, + }, + { + label: "`impact --base`", + run: async () => { + const label = `T13.3-2 (corrupt record) \`impact --base ${base} --json\``; + const impact = decodeImpactReport( + await runJson( + product, + workspace, + ["impact", "--base", base, "--json"], + label, + ), + label, + ); + assertSameJson( + { + requirements: impact.requirements, + direct: impact.code.direct, + transitive: impact.code.transitive, + }, + { requirements: [], direct: [], transitive: [] }, + `${label}: current sources equal the baseline — no ` + + `categories, no impacted code (SPEC 5.6, 9.3, 13.3)`, + ); + }, + }, + { + label: "`review status`", + run: async () => { + const status = await sessionStatus( + product, + workspace, + "s", + "T13.3-2 (corrupt record)", + ); + assertStatusRows( + status, + [ + { scope: A_ROOT, status: "unresolved", blocked: true }, + { scope: ALPHA, status: "unresolved", blocked: false }, + { scope: BETA, status: "unresolved", blocked: false }, + ], + "T13.3-2 (corrupt record) `review status s --json` — the " + + "session answers on the passing workspace (SPEC 10.6, " + + "10.7, 13.3)", + ); + }, + }, + { + label: "`query nodes`", + run: async () => { + const label = "T13.3-2 (corrupt record) `query nodes`"; + const rows = decodeNodeRowsReport( + await runJson(product, workspace, ["query", "nodes"], label), + label, + ); + for (const identity of [ALPHA, BETA]) { + if (!rows.some((row) => row.identity === identity)) { + fail( + `${label}: expected ${identity} among the rows (SPEC ` + + `11, 13.3); got ` + + JSON.stringify(rows.map((row) => row.identity).sort()), + ); + } + } + }, + }, + { + label: "`occurrences`", + run: async () => { + const label = "T13.3-2 (corrupt record) `occurrences`"; + const report = decodeOccurrencesReport( + await runJson(product, workspace, ["occurrences"], label), + label, + ); + assertSameJson( + { + findings: report.findings, + occurrences: occurrenceIdentitySummaries(report.occurrences), + }, + { + findings: [], + occurrences: [ + { + file: A_ROOT, + kind: "depends", + source: ALPHA, + target: BETA, + }, + ], + }, + `${label}: the one staged occurrence, finding-free — no ` + + `condition-23 finding accompanies a refreshing read's ` + + `answer (SPEC 11.3, 13.3, 14.23)`, + ); + }, + }, + { + label: "`view`", + run: async () => { + const label = "T13.3-2 (corrupt record) `view`"; + const view = decodeViewFilesReport( + await runJson(product, workspace, ["view"], label), + label, + ); + assertSameJson( + view, + { findings: [], files: [A_ROOT] }, + `${label}: the whole-domain request answers finding-free — ` + + `nothing is reported for the record (SPEC 11.4, 13.3, ` + + `14.23)`, + ); + }, + }, + { + label: "`at`", + run: async () => { + // Byte 30 lies inside "Alpha depends on beta." — within + // alpha's construct (bytes 0..55), outside the d entry's + // occurrence span ("beta" at bytes 18..24) (SPEC 11.5, 1.7). + const label = "T13.3-2 (corrupt record) `at specs/A.mdx 30`"; + assertAtAnswer( + decodeAtReport( + await runJson( + product, + workspace, + ["at", A_ROOT, "30"], + label, + ), + label, + ), + ALPHA, + label, + ); + }, + }, + ]; + + for (const probe of recordProbes) { + await probe.run(); + + // The corrupt state persists — neither read, repaired, nor + // replaced (SPEC 13.3): the record-consulting surface still + // reports the record-supplied datum explicitly unavailable, with + // the 14.23 outcome's exit 1 (the finding's full form is + // T11.6-4's home). + const invLabel = `T13.3-2 (corrupt record) \`inventory\` after ${probe.label}`; + const invResult = await runCli(product, workspace, ["inventory"]); + assertExitCode( + invResult, + 1, + `${invLabel} — an inventory answer carrying the condition-23 ` + + `finding exits 1 (SPEC 14.23, 11.6, 12.0)`, + ); + const recorded = decodeInventoryRecordedDatum( + parseJsonStdout(invResult, invLabel), + invLabel, + ); + if (recorded.state !== "unavailable") { + fail( + `${invLabel}: the record-supplied datum must still be ` + + `explicitly unavailable — a refreshing read leaves the ` + + `corrupt record state neither read, repaired, nor ` + + `replaced, and it is never read as an empty record (SPEC ` + + `13.3, 14.23, 11.6); got state ` + + JSON.stringify(recorded.state), + ); + } + + // Outside the graph data, nothing changed: no TypeScript or + // Markdown generated or removed, sources and durable files + // untouched (SPEC 13.3, 13.4; graph-data bytes stay unpinned — + // module header). + const after = await snapshotDirectory(workspace.root); + assertSnapshotsEqual( + asSnapshot(workspace.root, outsideGraph), + asSnapshot( + workspace.root, + filteredEntries(after.entries, (key) => !isGraphDataKey(key)), + ), + `T13.3-2 (corrupt record) after ${probe.label} and its ` + + `inventory probe: outside the graph data the workspace must ` + + `be byte-identical — no TypeScript or Markdown generated or ` + + `removed, journal, session, and source files untouched ` + + `(SPEC 13.3, 13.4)`, + ); + } + + // Until a successful `build` replaces the state (SPEC 13.3, 14.10, + // 12.1): afterwards the record-supplied datum is the plain recorded + // derived-file paths again, at exit 0 on the clean workspace. + await buildOk( + product, + workspace, + "T13.3-2 (corrupt record) `build` over the corrupt-record state " + + "— a successful build replaces the record (SPEC 12.1, 13.4, " + + "14.10)", + ); + const recoveredLabel = + "T13.3-2 (corrupt record) `inventory` after the rebuild"; + const recovered = decodeInventoryRecordedDatum( + await runJson(product, workspace, ["inventory"], recoveredLabel), + recoveredLabel, + ); + if (recovered.state !== "value") { + fail( + `${recoveredLabel}: after a successful \`build\` replaces the ` + + `corrupt record, the record-supplied datum is the plain ` + + `recorded derived-file paths again — never unavailability, ` + + `never null (SPEC 13.3, 14.23, 11.6, 12.7); got state ` + + JSON.stringify(recovered.state), + ); + } + if (!recovered.value.includes("specs/A.xspec.ts")) { + fail( + `${recoveredLabel}: the recorded derived-file paths — the ` + + `paths as last generated, companions included — must name ` + + `the generated module specs/A.xspec.ts (SPEC 11.6, 13.1, ` + + `13.3); got ${JSON.stringify(recovered.value)}`, + ); + } + }, + ); }, }); diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index b305119..b41818f 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -323,7 +323,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T13.1-2": ["13.1"], "T13.2-1": ["13.2"], "T13.3-1": ["13.3"], - "T13.3-2": ["13.3"], + "T13.3-2": ["13.3", "14"], "T13.3-3": ["13.3"], "T13.3-4": ["13.3"], "T13.4-1": ["13.4"], From f3f455ad71203bc01dcb218ffab5cfb9762ce7f5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 20:17:30 +0000 Subject: [PATCH 090/226] sdg(phase-9): add T13.3-3's whole-gate arms and never-gated contrast (FP-045) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 52 ++- test/suite/registry/section-13.3.ts | 514 +++++++++++++++++++++++++++- 2 files changed, 564 insertions(+), 2 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 48a3673..8d0fdda 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1873,12 +1873,62 @@ A "new test T<x>" task always means, in one change: CONF-DISC 3/3; violators failing exactly as certified). No new decoder (S-5 unchanged); T13.3-1/-2 in no certification scope.] -- [ ] FP-045 — T13.3-3: add the whole-gate arms and the never-gated +- [x] FP-045 — T13.3-3: add the whole-gate arms and the never-gated contrast. [R2 #32; TEST-SPEC §13.3] Whole-gate: garbage journal line (14.13) and obstructed write path (14.22) — each gated read reports it, exits 1, answers nothing, modifies nothing. Never-gated contrast: `occurrences`/`view`/`at` answering per SPEC 11.2 and `inventory` answering, on the same workspaces. + [Done 2026-08-13: two new workspaces in T13.3-3 (section-13.3.ts). + Journal arm: one legitimate journaled rename puts the garbage on line 2 + (T6.1-3's staging, so line naming has teeth; the 14.13 line-naming check + mirrors T6.1-3's H-4 operationalization), and the baseline commit for + `impact --base` is taken WITH the garbage line in place: 12.0 orders + baseline resolution before the gate, and per 6.3 it succeeds there — the + baseline journal is byte-identical to the current journal (append-only + prefix invariant), zero entries replay (T6.3-4's exit-2 replay-failure + arm is the garbage appended AFTER the baseline commit — TEST-SPEC's + deliberate contrast), and baseline-content validation is + source/configuration validity per T6.3-4's own "sources fail + parse/validation" arm; SPEC 13.3's naming of `impact` among the + journal-error-reporting gated reads is reachable only under this + staging (module header documents the interpretation). Obstruction arm: + after a successful build (emission under markdown.outDir) and session + create, the mdout directory is replaced by a plain file — the emit + write path mdout/specs/A.md's one offending component. Each of the six + gated reads (ids, show alpha, coverage, impact --base, review status s, + query nodes) asserts exit 1 + the form-exact findings report with + exactly {14.13: 1} (concerned path .xspec/journal, line named) / + {14.22: 1} (concerned path mdout), each inside a whole-root + assertLeavesUnchanged. Never-gated contrast on both workspaces, each + probe in its own whole-root compare: `occurrences` (exact one-record + alpha→beta identity summary, findings []), `view` (whole domain, + findings []), `at specs/A.mdx 30` (alpha, occurrence null), all exit 0 + whatever the gate state (11.2: a gate condition is no domain file's + finding), and `inventory` exit 0 with `recorded` a plain list naming + specs/A.xspec.ts (11.6). Verified: typecheck/format clean; + section-13.3 unchanged 3 failed / 1 passed (T13.3-3 red at the same + first FP-001-class decode; new arms suite-unreached); guarded solo runs + of each new arm against the built product fail exactly as diagnosed — + journal arm at the first gated probe's form-exact decode (the product + already gates the five non-impact reads with old-shape 14.13 naming + line 2, while `impact --base` exits 2 treating the baseline's own + journal state as a 6.3 failure: the diagnosed precedence gap), and + obstruction arm at the first probe's exit assertion (the product + answers all six reads exit 0 from the obstructed workspace — the + gates-on-source-validity-alone product the arm discriminates; its + `build`/`check` crash exit 70 ENOTDIR on the same staging); the + never-gated surfaces exit 2 "unknown command" (the patch-new §11 + dispatch gap). Staging premises probe-verified (rename → one-line + journal; build emits mdout/specs/A.md preserving workspace-relative + paths; both `review create`s succeed). `npm run test:self`: unchanged 4 + planned mid-loop reds (certification-document ×3 → FP-091; S-1's 7 + unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification + green. Traceability unchanged (["13.3"] — T13.3-3 sits in TEST-SPEC + 14's per-condition record for neither 14.13 nor 14.22 (T6.1-3/T13.4-6 + are its homes), matching the landed convention that "14" rides that + record); no new decoder (S-5 unchanged); T13.3-3 in no certification + scope (Exclusions-shared machinery only).] - [ ] FP-046 — T13.4-6: add plain-file occupant and finding-cardinality arms. [R2 #33; TEST-SPEC §13.4] diff --git a/test/suite/registry/section-13.3.ts b/test/suite/registry/section-13.3.ts index f34a0ae..2c06baf 100644 --- a/test/suite/registry/section-13.3.ts +++ b/test/suite/registry/section-13.3.ts @@ -57,6 +57,40 @@ // view document — because byte-precise span and per-file view semantics // are T11.3-*/T11.4-*/T11.5-*'s home; this section owns the // serving/refresh behaviors those answers demonstrate. +// - T13.3-3's whole-gate arms (SPEC 13.3: the gate is over every finding a +// `build` would report — source validation errors, journal errors, and +// refused writes alike): the garbage journal line rides line 2 behind one +// legitimate journaled entry (T6.1-3's staging, so "naming the line" has +// teeth and refresh really must consume the journal for canonical +// identities, SPEC 5.4), and the baseline commit for `impact --base` is +// taken WITH the garbage line in place: 12.0 orders baseline resolution +// before the gate, and per 6.3 it succeeds here — the baseline journal is +// byte-identical to the current journal (the append-only prefix invariant +// holds), zero entries replay (T6.3-4's replay-failure arm is the garbage +// line appended AFTER the baseline commit, exit 2 — TEST-SPEC's +// deliberate contrast), and the baseline's sources and configuration +// parse and validate (6.3's baseline-content validation, operationalized +// as source/configuration validity per T6.3-4's "a baseline whose sources +// fail parse/validation" arm) — so the whole gate is the operative error, +// exit 1 with the journal finding. The obstructed write path stages a +// plain file over the emptied `markdown.outDir` directory after a +// successful build: the emit write path's workspace-relative component +// `mdout` is then occupied by a non-directory (13.4) — the workspace's +// one offending component, nonexistent deeper components never being the +// condition — so `build` would report exactly the one condition-22 +// finding (14.22: one finding per distinct offending component). 14.13 +// line naming follows T6.1-3's H-4 operationalization: the message +// echoing the garbage text or citing line/entry 2, or (tolerated) a +// location within the garbage line's byte window — a journal condition +// carries the concerned journal path, no in-source location (SPEC 14, +// 12.7). The never-gated contrast (`occurrences`, `view`, `at` answering +// per 11.2, `inventory` answering whatever the sources' validity, SPEC +// 11.6) is asserted at this module's identity/membership altitude, each +// probe inside its own whole-root compare: a gate condition is a finding +// of no domain file — the journal and a write-path component are never +// domain files — so those answers are complete and finding-free at exit +// 0, whatever journal or write-path state the workspace holds (SPEC +// 11.2), and nothing is modified. // - T13.3-2's record-discipline arm (record corrupted shape-blind, T6.6-6's // staging via the H-3 record-staging adapter): SPEC 13.3 pins the record — // the recorded derived-file paths — as neither read, repaired, nor @@ -123,6 +157,7 @@ import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { assertConditionCounts, + assertFindingConcernsPath, assertFindingLocated, assertSameJson, buildOk, @@ -1962,10 +1997,274 @@ const T13_3_3_B_VALID = ['<S id="beta">', "Beta text.", "</S>", ""].join("\n"); // A non-root section without `id` — build validation condition 14.1. const T13_3_3_B_INVALID = ["<S>", "Beta text.", "</S>", ""].join("\n"); +// --- Whole-gate arm fixtures (SPEC 13.3; see the module header) --- + +const JOURNAL_PATH = ".xspec/journal"; +const LF = 0x0a; + +// Deliberately structureless bytes no conforming entry format accepts — the +// TEST-SPEC-sanctioned malformed-journal staging (T6.1-3's shape, H-4). +const GATE_GARBAGE_LINE = "?? harness-injected garbage: not a journal entry ??"; + +// One reference occurrence (alpha's d entry to beta) keeps every never-gated +// answer contentful; the sources are otherwise finding-free, so the staged +// journal/write-path state is the workspace's only build-failing condition. +const T13_3_3_GATE_A = [ + '<S id="alpha" d={["beta"]}>', + "Alpha depends on beta.", + "</S>", + "", + '<S id="beta">', + "Beta text.", + "</S>", + "", +].join("\n"); +// An unreferenced section for the legitimate journaled rename (line 1). +const T13_3_3_GATE_T = ['<S id="tmp">', "Tmp text.", "</S>", ""].join("\n"); + +// The obstructed-write-path workspace: emission redirected under +// `markdown.outDir`, so a plain file at `mdout` obstructs the emit write +// path `mdout/specs/A.md` at its first component (SPEC 7.3, 13.2, 13.4). +const T13_3_3_OUTDIR_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + markdown: { emit: true, outDir: "mdout" }, + coverage: [ + { + name: "p", + target: "main", + targets: "all", + boundary: "main", + mode: "direct" + } + ] +}) +`; + +/** Lines in a line-oriented file, either final-line convention (T6.1-3). */ +function journalLineCount(bytes: Uint8Array): number { + if (bytes.length === 0) return 0; + let count = 0; + for (const byte of bytes) { + if (byte === LF) count += 1; + } + if (bytes[bytes.length - 1] !== LF) count += 1; + return count; +} + +/** + * Does a 14.13 finding name the garbage line (line 2)? T6.1-3's H-4 + * operationalization: the message echoing the garbage line's text or citing + * line/entry 2 — a journal condition carries the journal path it concerns + * and no in-source location (SPEC 14, 12.7), so the lines are named in the + * message — or, tolerated, a location within the garbage line's byte window + * in `.xspec/journal`. + */ +function findingNamesGarbageLine( + finding: Finding, + window: { readonly start: number; readonly end: number }, +): boolean { + if (finding.message.includes(GATE_GARBAGE_LINE)) return true; + if (/\b(?:line|entry)\s*#?\s*2\b/i.test(finding.message)) return true; + if (finding.message.includes("journal:2")) return true; + return finding.locations.some( + (location) => + location.file === JOURNAL_PATH && + location.range.start >= window.start && + location.range.end <= window.end + 1, + ); +} + +/** + * The six gated reads' invocations (SPEC 13.3; `review` represented by + * `status`, the read subcommand — the mutating subcommands are the + * invalid-sources workspace's subject). + */ +function gatedReadInvocations( + base: string, + alpha: string, +): readonly { readonly argv: readonly string[]; readonly what: string }[] { + return [ + { argv: ["ids", "--json"], what: "`ids --json`" }, + { argv: ["show", alpha, "--json"], what: `\`show ${alpha} --json\`` }, + { argv: ["coverage", "--json"], what: "`coverage --json`" }, + { + argv: ["impact", "--base", base, "--json"], + what: "`impact --base <ref> --json`", + }, + { + argv: ["review", "status", "s", "--json"], + what: "`review status s --json`", + }, + { argv: ["query", "nodes"], what: "`query nodes`" }, + ]; +} + +/** + * One whole-gate probe (SPEC 13.3): the gated read reports exactly the + * staged gate finding, exits 1, answers nothing (stdout is the findings + * report, like a failed build — the module's T13.3-3 operationalization), + * and modifies nothing: journal, sessions, derived files, graph data, and + * `.git/` byte-identical around the invocation. + */ +async function probeWholeGate( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + counts: Readonly<Record<string, number>>, + verifyFinding: (finding: Finding, context: string) => void, + context: string, +): Promise<void> { + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await runCli(product, workspace, argv); + assertExitCode( + result, + 1, + `${context} — the gate is over every finding a \`build\` would ` + + `report, source validity or not: the gated read reports it and ` + + `exits 1 without answering (SPEC 13.3, 12.0)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, context), + context, + ).findings; + assertConditionCounts( + findings, + counts, + `${context} — exactly the staged gate finding is reported, like a ` + + `failed build (SPEC 13.3, 14)`, + ); + verifyFinding(findings[0] as Finding, context); + }, + `${context} — journal, sessions, derived files, and graph data must be ` + + `byte-identical around the gated read (SPEC 13.3)`, + ); +} + +/** + * The never-gated contrast (SPEC 13.3, 11.2, 11.6) on a whole-gate + * workspace staged with `T13_3_3_GATE_A` as its one occurrence-bearing spec + * source: `occurrences`, `view`, and `at` answer per file — complete and + * finding-free at exit 0, the gate condition being no domain file's finding + * — and `inventory` answers whatever the sources' validity, none of them + * modifying anything (whole-root byte compare per probe). + */ +async function assertNeverGatedAnswers( + product: ProductBinding, + workspace: TestWorkspace, + viewFiles: readonly string[], + context: string, +): Promise<void> { + const A_ROOT = "specs/A.mdx"; + const ALPHA = "specs/A.mdx#alpha"; + const BETA = "specs/A.mdx#beta"; + + const occurrencesLabel = `${context} \`occurrences\``; + await assertLeavesUnchanged( + workspace.root, + async () => { + const report = decodeOccurrencesReport( + await runJson(product, workspace, ["occurrences"], occurrencesLabel), + occurrencesLabel, + ); + assertSameJson( + { + findings: report.findings, + occurrences: occurrenceIdentitySummaries(report.occurrences), + }, + { + findings: [], + occurrences: [ + { file: A_ROOT, kind: "depends", source: ALPHA, target: BETA }, + ], + }, + `${occurrencesLabel}: the staged occurrence, finding-free at exit 0 ` + + `— the gate condition is no domain file's finding, so it ` + + `accompanies no answer of this surface (SPEC 11.2, 11.3, 13.3)`, + ); + }, + `${occurrencesLabel} answers from the current sources and modifies ` + + `nothing — no graph data, no derived files (SPEC 11.2, 13.3)`, + ); + + const viewLabel = `${context} \`view\``; + await assertLeavesUnchanged( + workspace.root, + async () => { + const view = decodeViewFilesReport( + await runJson(product, workspace, ["view"], viewLabel), + viewLabel, + ); + assertSameJson( + view, + { findings: [], files: viewFiles }, + `${viewLabel}: the whole-domain request answers every discovered ` + + `spec source, finding-free at exit 0, whatever journal or ` + + `write-path state the workspace holds (SPEC 11.2, 11.4, 13.3)`, + ); + }, + `${viewLabel} answers from the current sources and modifies nothing ` + + `(SPEC 11.2, 13.3)`, + ); + + // Byte 30 lies inside "Alpha depends on beta." — within alpha's construct + // (bytes 0..55), outside the d entry's occurrence span ("beta" at bytes + // 18..24) (SPEC 11.5, 1.7). + const atLabel = `${context} \`at specs/A.mdx 30\``; + await assertLeavesUnchanged( + workspace.root, + async () => { + assertAtAnswer( + decodeAtReport( + await runJson(product, workspace, ["at", A_ROOT, "30"], atLabel), + atLabel, + ), + ALPHA, + atLabel, + ); + }, + `${atLabel} answers from the current sources and modifies nothing ` + + `(SPEC 11.2, 11.5, 13.3)`, + ); + + const inventoryLabel = `${context} \`inventory\``; + await assertLeavesUnchanged( + workspace.root, + async () => { + const recorded = decodeInventoryRecordedDatum( + await runJson(product, workspace, ["inventory"], inventoryLabel), + inventoryLabel, + ); + if (recorded.state !== "value") { + fail( + `${inventoryLabel}: the inventory parses no sources and reads no ` + + `journal content — it answers whatever the workspace's gate ` + + `state, and with the record intact the record-supplied datum ` + + `is the plain recorded derived-file paths (SPEC 11.6, 13.3, ` + + `14.23); got state ${JSON.stringify(recorded.state)}`, + ); + } + if (!recorded.value.includes("specs/A.xspec.ts")) { + fail( + `${inventoryLabel}: the recorded derived-file paths must name ` + + `the generated module specs/A.xspec.ts (SPEC 11.6, 13.1); got ` + + JSON.stringify(recorded.value), + ); + } + }, + `${inventoryLabel} neither refreshes nor writes anything (SPEC 11.6)`, + ); +} + const T13_3_3 = defineProductTest({ id: "T13.3-3", title: - "with invalid sources, each read command and each mutating review subcommand (create under --base/--strategy audit/--coverage, resolve, split) reports the validation errors, exits 1, answers nothing, and modifies nothing — no session created, and session file, journal, derived files, and graph data byte-identical (SPEC 13.3, 12.0, 14)", + "with invalid sources, each read command and each mutating review subcommand (create under --base/--strategy audit/--coverage, resolve, split) reports the validation errors, exits 1, answers nothing, and modifies nothing — no session created, and session file, journal, derived files, and graph data byte-identical; the gate is over every finding a `build` would report: with a garbage journal line staged (the baseline commit including it) and separately an obstructed write path, ids, show, coverage, impact, review status, and query each report exactly that finding — the journal error (14.13) naming the line, the refused write (14.22) its offending component — exit 1, answer nothing, and modify nothing, while on the same workspaces occurrences, view, and at answer per file finding-free at exit 0 and inventory answers, none of them modifying anything (SPEC 13.3, 11.2, 11.6, 12.0, 14)", run: async (product) => { await withWorkspace( { @@ -2190,6 +2489,219 @@ const T13_3_3 = defineProductTest({ ); }, ); + + // --- Whole-gate arm 1: garbage journal line (14.13). The gate is over + // every finding a `build` would report, source validity or not (SPEC + // 13.3); the staging and the baseline-commit placement are explained in + // the module header. Discriminates a product that gates on source + // validity alone and answers `query` from a broken journal with exit 0 + // (refresh consumes the journal for canonical identities, SPEC 5.4). + await withWorkspace( + { + "xspec.config.ts": GRAPH_CONFIG, + "specs/A.mdx": T13_3_3_GATE_A, + "specs/T.mdx": T13_3_3_GATE_T, + }, + async (workspace) => { + const context = "T13.3-3 (garbage journal)"; + const ALPHA = "specs/A.mdx#alpha"; + + await workspace.gitInit(); + await buildOk(product, workspace, `${context} staging \`build\``); + // Journal line 1: one legitimate journaled operation — the rename + // of the unreferenced tmp section — so the garbage lands on line 2 + // ("naming the line" has teeth, T6.1-3) and the journal really + // participates in canonical identities (SPEC 6.1, 6.4, 5.4). + await expectExit( + product, + workspace, + ["rename", "specs/T.mdx", "tmp", "tmp2"], + 0, + `${context} staging \`rename specs/T.mdx tmp tmp2\` — the ` + + `legitimate journal entry (SPEC 6.4, 6.1)`, + ); + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", "s"], + 0, + `${context} staging \`review create --strategy audit --name s\` ` + + `(SPEC 10.7)`, + ); + + // Append the garbage as its own line 2 (whole-line append under + // either final-line convention; shape-independent, H-4). + const journalKind = await workspace.kind(JOURNAL_PATH); + if (journalKind !== "file") { + fail( + `${context}: staging premise — the journaled rename brings the ` + + `journal into existence as a plain file at ${JOURNAL_PATH} ` + + `(SPEC 6.1, 13.4); found ${journalKind}`, + ); + } + const legitimate = await workspace.readBytes(JOURNAL_PATH); + if (journalLineCount(legitimate) !== 1) { + fail( + `${context}: staging premise — one journaled operation yields ` + + `a one-line journal (SPEC 6.1), so the garbage lands on line ` + + `2; found ${String(journalLineCount(legitimate))} line(s)`, + ); + } + const needsTerminator = + legitimate.length > 0 && legitimate[legitimate.length - 1] !== LF; + const garbageStart = legitimate.length + (needsTerminator ? 1 : 0); + await workspace.file( + JOURNAL_PATH, + Buffer.concat([ + legitimate, + Buffer.from( + (needsTerminator ? "\n" : "") + GATE_GARBAGE_LINE + "\n", + "utf8", + ), + ]), + ); + const window = { + start: garbageStart, + end: garbageStart + Buffer.byteLength(GATE_GARBAGE_LINE, "utf8"), + }; + + // The baseline commit INCLUDES the garbage line, so baseline + // resolution — which precedes the gate (SPEC 12.0) — succeeds and + // the gate is `impact --base`'s operative error (module header; + // the post-baseline garbage position is T6.3-4's exit-2 arm). + const base = await workspace.gitCommitAll( + "gate baseline (garbage journal line included)", + ); + + for (const probe of gatedReadInvocations(base, ALPHA)) { + await probeWholeGate( + product, + workspace, + probe.argv, + { "14.13": 1 }, + (finding, findingContext) => { + assertFindingConcernsPath( + finding, + JOURNAL_PATH, + `${findingContext} — a journal condition carries the ` + + `journal path it concerns (SPEC 14, 12.7)`, + ); + if (!findingNamesGarbageLine(finding, window)) { + fail( + `${findingContext}: the 14.13 finding must name the ` + + `malformed line — the garbage on line 2 (SPEC 14.13 ` + + `"naming the lines"): the garbage line's text, a ` + + `line/entry-2 citation, or a location within bytes ` + + `[${String(window.start)}, ${String(window.end)}] of ` + + `${JOURNAL_PATH}; got ${JSON.stringify(finding)}`, + ); + } + }, + `${context} ${probe.what}`, + ); + } + + // Never-gated contrast on the same workspace (SPEC 11.2, 11.6). + await assertNeverGatedAnswers( + product, + workspace, + ["specs/A.mdx", "specs/T.mdx"], + context, + ); + }, + ); + + // --- Whole-gate arm 2: obstructed write path (14.22). After a + // successful build (and a session for `review status`), the + // `markdown.outDir` directory is replaced by a plain file: the emit + // write path `mdout/specs/A.md` then has its workspace-relative + // component `mdout` occupied by a non-directory — the one offending + // component, so `build` would report exactly the one condition-22 + // finding (SPEC 13.4, 14.22; module header). + await withWorkspace( + { + "xspec.config.ts": T13_3_3_OUTDIR_CONFIG, + "specs/A.mdx": T13_3_3_GATE_A, + }, + async (workspace) => { + const context = "T13.3-3 (obstructed write path)"; + const ALPHA = "specs/A.mdx#alpha"; + + await workspace.gitInit(); + // Pristine valid sources at the baseline; the journal is absent on + // both sides (an empty journal is a prefix of every journal), so + // baseline resolution succeeds and the gate is `impact --base`'s + // operative error (SPEC 6.3, 12.0). + const base = await workspace.gitCommitAll("baseline (valid sources)"); + await buildOk( + product, + workspace, + `${context} staging \`build\` — emits under markdown.outDir ` + + `(SPEC 7.3, 13.2, 12.1)`, + ); + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", "s"], + 0, + `${context} staging \`review create --strategy audit --name s\` ` + + `(SPEC 10.7)`, + ); + + // Staging premises: emission landed under mdout/ preserving + // workspace-relative paths (SPEC 7.3, 13.2), so mdout is a + // component of a path `build` writes. + const mdoutKind = await workspace.kind("mdout"); + if (mdoutKind !== "dir") { + fail( + `${context}: staging premise — \`build\` with emission enabled ` + + `under markdown.outDir creates the mdout/ directory (SPEC ` + + `7.3, 13.2, 13.4); found ${mdoutKind}`, + ); + } + const emittedKind = await workspace.kind("mdout/specs/A.md"); + if (emittedKind !== "file") { + fail( + `${context}: staging premise — emission under outDir preserves ` + + `workspace-relative paths, so specs/A.mdx emits ` + + `mdout/specs/A.md (SPEC 7.3, 13.2); found ${emittedKind}`, + ); + } + + // Obstruct: replace the directory with a plain file (the emitted + // Markdown goes with it — staleness is invisible here: 14.10 is + // `check`-only, and `build` would refuse at the obstruction). + await fsp.rm(workspace.path("mdout"), { recursive: true, force: true }); + await workspace.file("mdout", "not a directory\n"); + + for (const probe of gatedReadInvocations(base, ALPHA)) { + await probeWholeGate( + product, + workspace, + probe.argv, + { "14.22": 1 }, + (finding, findingContext) => { + assertFindingConcernsPath( + finding, + "mdout", + `${findingContext} — the refused write's concerned path is ` + + `the offending component's workspace-relative path ` + + `(SPEC 14.22, 13.4)`, + ); + }, + `${context} ${probe.what}`, + ); + } + + // Never-gated contrast on the same workspace (SPEC 11.2, 11.6). + await assertNeverGatedAnswers( + product, + workspace, + ["specs/A.mdx"], + context, + ); + }, + ); }, }); From 7e2a9cf913831c6f6a02b60ce22e49b98c4107c8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 20:40:39 +0000 Subject: [PATCH 091/226] sdg(phase-9): add T13.4-6's plain-file occupant and 14.22 cardinality arms (FP-046) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 40 +++- test/suite/registry/section-13.4.ts | 334 +++++++++++++++++++++------- 2 files changed, 295 insertions(+), 79 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 8d0fdda..aa9d298 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1930,12 +1930,50 @@ A "new test T<x>" task always means, in one change: record); no new decoder (S-5 unchanged); T13.3-3 in no certification scope (Exclusions-shared machinery only).] -- [ ] FP-046 — T13.4-6: add plain-file occupant and finding-cardinality +- [x] FP-046 — T13.4-6: add plain-file occupant and finding-cardinality arms. [R2 #33; TEST-SPEC §13.4] `test/suite/registry/section-13.4.ts`: occupants — a `build` write-path directory component; a first-emission `outDir` component. Cardinality — one component refusing two writes → one finding; two components → two findings, via `check`. + [Done 2026-08-13: four first-emission workspaces under the existing + OUT_CONFIG (outDir "out"; occupant staged in the declaration, so no move + operand is involved — the destination-side contrast reporting + `refused-invalid-destination` stays T6.5-4's, per TEST-SPEC's + parenthetical): plain file at the outDir component `out` and, separately, + at the deeper emit-path component `out/specs` (out a real directory — + discriminates vet-only-the-outDir products), each asserting `build` and + `check`; cardinality via `check` where TEST-SPEC pins it — A+B both + emitting under occupied `out` → exactly one 14.22, and nested + specs/one/A.mdx + specs/two/B.mdx with plain files at `out/specs/one` + + `out/specs/two` → exactly two. All probes ride new module helpers + `assertObstructionFindings`/`expectObstructionReport` (exit 1, form-exact + 12.7 findings report, exactly one condition-22 finding per staged + offending component with the component as its concerned path per SPEC + 14.22's cardinality rule — per-index compare, sound because the pinned + 12.7 order among equal-code empty-location findings is concerned-path + byte order; build-side sets exact, check-side tolerating only 14.10 + beside; every probe in a whole-root assertLeavesUnchanged); the existing + write-path-symlink arm was refactored onto the same helpers, gaining the + previously missing concerned-path assertion. No new adapter (S-5 + unchanged); traceability already ["13.4","14"] (T13.4-6 is 14.22's + primary); no certification scope. Verified: typecheck/format clean; + suite section-13.4 unchanged 1 failed / 5 passed — T13.4-6 red at the + symlink arm's first FP-001-class form-exact decode, new arms + suite-unreached; peeled solo runs against the built product fail each + new arm exactly as diagnosed at its first probe's exit-code assertion + (the product vets symlink components only and crashes exit 70 ENOTDIR on + plain-file occupants in all four stagings — build and check alike; the + deeper-component build even writes modules before crashing, the + modify-while-refusing gap); occupant-free control twins build exit 0 + writing exactly the obstructed emit paths (staging premise); a + fake-product control matrix on the two-components arm proved the helper + green path and teeth (conforming report passes; a 14.10 beside passes; + one-finding, per-write-duplicate, out-of-order, and extra-14.20 reports + each fail at the intended assertion). `npm run test:self`: unchanged 4 + planned mid-loop reds (certification-document ×3 → FP-091; S-1's 7 + unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification + green.] - [ ] FP-047 — T13.5-1: add the seam-neutrality arm. [R2 #34; TEST-SPEC §13.5] diff --git a/test/suite/registry/section-13.4.ts b/test/suite/registry/section-13.4.ts index 69e79e1..52aeb93 100644 --- a/test/suite/registry/section-13.4.ts +++ b/test/suite/registry/section-13.4.ts @@ -51,6 +51,27 @@ // same 14.22 without writing; 14.10 staleness findings are tolerated // beside it (no build has ever succeeded, so every derived file is // missing); any other condition fails. +// - T13.4-6 plain-file occupant and cardinality arms: every staging is a +// first emission — no build has ever run and the occupant is staged in the +// workspace declaration — and no move operand is involved (a plain-file +// component under a move's destination or its derived paths is the move's +// `refused-invalid-destination` instead, SPEC 6.5, 14.22; T6.5-4). Under +// OUT_CONFIG emission preserves workspace-relative paths (SPEC 7.3), so +// each staged component is a workspace-relative directory component of a +// `build` write path and the staged occupants are exactly the offending +// components: the arms assert the complete condition-22 finding set with +// each finding's concerned path equal to its component (SPEC 14.22 — one +// finding per distinct offending component, whatever write paths it +// refuses; a product refusing at a different component, once per refused +// write, or per occupant kind rather than per component fails the count +// or the path equality). The `build`-side finding set is exact (sources +// valid; `build` cannot observe 14.10, 12.1); the `check` side counts +// the condition-22 findings exactly and tolerates 14.10 beside them (as +// above). The cardinality arms — one occupant under which two derived +// files would be written yields one finding; two distinct offending +// components yield two — are asserted via `check`, where TEST-SPEC pins +// them; among equal-code findings with empty locations the pinned 12.7 +// order is concerned-path byte order, fixing the per-index comparison. // - T13.4-6 durable arms: the journal occupant's link target is an empty // plain file — a valid empty journal — and the session occupant's link // target is the product's own healthy session file beside it, so a product @@ -105,7 +126,7 @@ import { runProduct } from "../../helpers/subprocess.js"; import type { WorkspaceDecl } from "../../helpers/workspace.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { - assertConditionCounts, + assertFindingConcernsPath, buildOk, expectExit, runCli, @@ -1136,6 +1157,15 @@ export default defineConfig({ }) `; +// A second minimal source (the cardinality arms): under OUT_CONFIG it adds +// the emit write path `out/specs/B.md` — or, staged nested, another emit +// path under its own `out/…` directory chain (SPEC 7.3, 13.2). +const B_MDX = ['<S id="b">', "Beta text.", "</S>", ""].join("\n"); + +// The non-directory occupant staged at write-path components (SPEC 14.22's +// plain-file kind; content arbitrary — the occupant is never read). +const OCCUPANT = "not a directory\n"; + /** * Decode a findings report from an exit-1 `--json` run and assert at least * one finding carries the given condition; every finding is returned. @@ -1154,10 +1184,104 @@ function requireCondition( } } +/** + * Assert a findings report carries exactly one condition-22 finding per + * staged offending component, each finding's concerned path that component's + * workspace-relative path (SPEC 14.22: one finding per distinct offending + * component, whatever write paths it refuses). `components` is given in + * concerned-path byte order — the pinned 12.7 findings order among + * equal-code findings whose locations are empty (module header) — so the + * comparison is per index. With `besideStaleness` (the `check` side), 14.10 + * findings are tolerated beside the counted set; any other condition fails + * either way (module header). + */ +function assertObstructionFindings( + findings: readonly Finding[], + components: readonly string[], + besideStaleness: boolean, + context: string, +): void { + const obstructions = findings.filter( + (finding) => finding.condition === "14.22", + ); + if (obstructions.length !== components.length) { + fail( + `${context}: exactly ${String(components.length)} condition-22 ` + + `finding(s) — one per distinct offending component, whatever write ` + + `paths it refuses (SPEC 14.22); reported conditions: ` + + JSON.stringify(findings.map((finding) => finding.condition)), + ); + } + components.forEach((component, index) => { + assertFindingConcernsPath( + obstructions[index]!, + component, + `${context}: the concerned path is the offending component's ` + + `workspace-relative path (SPEC 14.22, 13.4)`, + ); + }); + for (const finding of findings) { + if (finding.condition === "14.22") continue; + if (besideStaleness && finding.condition === "14.10") continue; + fail( + `${context}: beside the staged condition-22 finding(s), ` + + (besideStaleness + ? `only 14.10 staleness is stageable here (no build has ever ` + + `succeeded, so every derived file is missing; SPEC 14.10, 12.2)` + : `nothing else is stageable (the sources are valid, and ` + + `\`build\` cannot observe 14.10; SPEC 14.22, 12.1)`) + + `; got ${JSON.stringify(finding.condition)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } +} + +/** + * Run `build --json` or `check --json` on a workspace staging non-directory + * occupants at write-path directory components and assert the SPEC 14.22 + * contract: exit 1; the form-exact findings report carrying exactly the + * staged obstructions per {@link assertObstructionFindings}; and nothing + * modified — `build` refuses before anything is modified, `check` reports + * without writing (SPEC 14.22, 13.4, 12.1, 12.2). + */ +async function expectObstructionReport( + product: ProductBinding, + workspace: TestWorkspace, + command: "build" | "check", + components: readonly string[], + what: string, +): Promise<void> { + const context = `${what} \`${command} --json\``; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await runCli(product, workspace, [command, "--json"]); + assertExitCode( + result, + 1, + `${context}: the obstructed write is a condition-22 finding, never ` + + `a crash or a success (SPEC 14.22, 12.0)`, + ); + assertObstructionFindings( + decodeFindingsReport(parseJsonStdout(result, context), context) + .findings, + components, + command === "check", + context, + ); + }, + command === "build" + ? `${context}: \`build\` refuses before anything is modified — no ` + + `module, Markdown, or graph data appears and the occupants are ` + + `untouched (SPEC 14.22, 13.4, 12.1)` + : `${context}: \`check\` reports without writing (SPEC 14.22, 12.2)`, + ); +} + const T13_4_6 = defineProductTest({ id: "T13.4-6", title: - "a write path with a symbolic link at a workspace-relative directory component is refused before anything is modified (14.22, exit 1, workspace byte-identical; `check` reports it without writing); a durable path occupied by a symlink or non-plain file is a journal error (14.13) / corrupt session (14.21), never read, appended, or replaced; path components above the workspace root are unrestricted — a root reached through a symlink builds, mutates, and `check`s normally (SPEC 13.4, 14.13, 14.21, 14.22)", + "a write path with a symbolic link at a workspace-relative directory component is refused before anything is modified (14.22, exit 1, workspace byte-identical; `check` reports it without writing); a plain file occupying a directory component of a `build` write path — a first emission's `outDir` component, and a deeper component below it, no move operand involved — is refused identically, concerned path that component; one occupant under which two derived files would be written is one finding and two distinct offending components are two, via `check`; a durable path occupied by a symlink or non-plain file is a journal error (14.13) / corrupt session (14.21), never read, appended, or replaced; path components above the workspace root are unrestricted — a root reached through a symlink builds, mutates, and `check`s normally (SPEC 13.4, 14.13, 14.21, 14.22)", run: async (product) => { // --- Refusal arm: the Markdown emit destination's directory component // is a symbolic link (module header: exactly one write path traverses @@ -1169,84 +1293,138 @@ const T13_4_6 = defineProductTest({ symlinks: { out: "real-out" }, }, async (workspace) => { - await assertLeavesUnchanged( - workspace.root, - async () => { - const context = - "T13.4-6 (write-path symlink) `build --json` — the write to " + - "out/specs/A.md traverses the symlink at `out`"; - const result = await runCli(product, workspace, [ - "build", - "--json", - ]); - assertExitCode( - result, - 1, - `${context}: the write is refused with the report (SPEC ` + - `14.22, 12.0)`, - ); - const findings = decodeFindingsReport( - parseJsonStdout(result, context), - context, - ).findings; - assertConditionCounts( - findings, - { "14.22": 1 }, - `${context}: exactly the one staged condition — one write ` + - `path traverses the link, the sources are valid, and ` + - `\`build\` cannot observe 14.10 (SPEC 14.22, 12.1)`, - ); - }, - "T13.4-6 (write-path symlink) `build` refuses before anything is " + - "modified — no module, Markdown, or graph data appears and the " + - "link and its target are untouched (SPEC 14.22, 13.4, 12.1)", + // One write path (out/specs/A.md) traverses the link at `out` — the + // one offending component, so the finding set is exactly one 14.22 + // concerning `out` on both sides (module header). + await expectObstructionReport( + product, + workspace, + "build", + ["out"], + "T13.4-6 (write-path symlink)", ); + await expectObstructionReport( + product, + workspace, + "check", + ["out"], + "T13.4-6 (write-path symlink)", + ); + }, + ); - await assertLeavesUnchanged( - workspace.root, - async () => { - const context = "T13.4-6 (write-path symlink) `check --json`"; - const result = await runCli(product, workspace, [ - "check", - "--json", - ]); - assertExitCode( - result, - 1, - `${context}: \`check\` reports the same finding (SPEC 14.22, ` + - `12.2)`, - ); - const findings = decodeFindingsReport( - parseJsonStdout(result, context), - context, - ).findings; - const symlinkFindings = findings.filter( - (finding) => finding.condition === "14.22", - ); - if (symlinkFindings.length !== 1) { - fail( - `${context}: exactly one 14.22 finding — one write path ` + - `traverses the link (SPEC 14.22); reported conditions: ` + - JSON.stringify(findings.map((finding) => finding.condition)), - ); - } - for (const finding of findings) { - if ( - finding.condition !== "14.22" && - finding.condition !== "14.10" - ) { - fail( - `${context}: beside the 14.22, only 14.10 staleness is ` + - `stageable here (no build has ever succeeded, so ` + - `derived files are missing; SPEC 14.10, 12.2); got ` + - `${JSON.stringify(finding.condition)} (message: ` + - `${JSON.stringify(finding.message)})`, - ); - } - } - }, - "T13.4-6 (write-path symlink) `check` reports without writing " + - "(SPEC 14.22, 12.2)", + // --- Occupant kinds, plain file at a first emission's `outDir` + // component: no build has ever run, no move operand is involved (a + // plain-file component under a move's destination or its derived paths + // is the move's `refused-invalid-destination` instead, SPEC 6.5, 14.22; + // T6.5-4) — refused identically to the symlink kind: `build` exits 1 + // with the condition-22 finding, concerned path that component, + // modifying nothing, and `check` reports it without writing --- + await withWorkspace( + { + files: { + "xspec.config.ts": OUT_CONFIG, + "specs/A.mdx": A_MDX, + out: OCCUPANT, + }, + }, + async (workspace) => { + await expectObstructionReport( + product, + workspace, + "build", + ["out"], + "T13.4-6 (outDir plain-file occupant)", + ); + await expectObstructionReport( + product, + workspace, + "check", + ["out"], + "T13.4-6 (outDir plain-file occupant)", + ); + }, + ); + + // --- Occupant kinds, plain file at a deeper directory component of the + // `build` write path: `out` is a real directory and the occupant sits at + // `out/specs` — the emit path out/specs/A.md's other workspace-relative + // component (SPEC 7.3 path preservation) — discriminating a product + // that vets only the `outDir` component itself (SPEC 14.22, 13.4) --- + await withWorkspace( + { + files: { + "xspec.config.ts": OUT_CONFIG, + "specs/A.mdx": A_MDX, + "out/specs": OCCUPANT, + }, + }, + async (workspace) => { + await expectObstructionReport( + product, + workspace, + "build", + ["out/specs"], + "T13.4-6 (deeper-component plain-file occupant)", + ); + await expectObstructionReport( + product, + workspace, + "check", + ["out/specs"], + "T13.4-6 (deeper-component plain-file occupant)", + ); + }, + ); + + // --- Finding cardinality, one component refusing two writes: with two + // sources both emitting under the occupied `out` (out/specs/A.md and + // out/specs/B.md), the one non-directory occupant yields ONE finding, + // concerned path that component — never one per refused write (SPEC + // 14.22); asserted via `check` per TEST-SPEC (module header) --- + await withWorkspace( + { + files: { + "xspec.config.ts": OUT_CONFIG, + "specs/A.mdx": A_MDX, + "specs/B.mdx": B_MDX, + out: OCCUPANT, + }, + }, + async (workspace) => { + await expectObstructionReport( + product, + workspace, + "check", + ["out"], + "T13.4-6 (one component, two refused writes)", + ); + }, + ); + + // --- Finding cardinality, two distinct offending components: nested + // sources emit at out/specs/one/A.md and out/specs/two/B.md (SPEC 7.3); + // with `out` and `out/specs` real directories and plain files at + // `out/specs/one` and `out/specs/two`, each refused write has its own + // offending component — TWO findings, each concerning its component, in + // concerned-path byte order (SPEC 14.22, 12.7); via `check` --- + await withWorkspace( + { + files: { + "xspec.config.ts": OUT_CONFIG, + "specs/one/A.mdx": A_MDX, + "specs/two/B.mdx": B_MDX, + "out/specs/one": OCCUPANT, + "out/specs/two": OCCUPANT, + }, + }, + async (workspace) => { + await expectObstructionReport( + product, + workspace, + "check", + ["out/specs/one", "out/specs/two"], + "T13.4-6 (two offending components)", ); }, ); From 6a7787320ffd4ca5cb5dfd6f3fe6841918acea55 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 20:57:42 +0000 Subject: [PATCH 092/226] sdg(phase-9): add T13.5-1's seam-neutrality twin compare (FP-047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An identical twin workspace replays the held workspace's exact command sequence (staging build and the review-status item lookup included) with the seam flag alone removed; after each held-then-released arm the two whole trees — sources, journal, sessions, derived files, graph data — are compared byte-identically (H-4 product-to-itself, H-6 across directories, no exclusions). Arms 4/5 pass each side its own workspace's reported item ID. The sequence equality is the staging VIOL-CORE-CHATTYREADS's passing analysis leans on, recorded as a header constraint. CONF-CORE conformer 9/9; violator expected-failure sets exact; suite 13.5 7/7 (product already seam-neutral; teeth proven by a diverging-twin probe); test:self unchanged 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 32 ++- test/suite/registry/section-13.5.ts | 342 +++++++++++++++++----------- 2 files changed, 240 insertions(+), 134 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index aa9d298..0a20794 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1975,11 +1975,41 @@ A "new test T<x>" task always means, in one change: unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-047 — T13.5-1: add the seam-neutrality arm. [R2 #34; TEST-SPEC +- [x] FP-047 — T13.5-1: add the seam-neutrality arm. [R2 #34; TEST-SPEC §13.5] `test/suite/registry/section-13.5.ts`: held-then-released final workspace state byte-identical to the same operation without `--test-hold` on an identical twin. + [Done 2026-08-13: one identical twin workspace (CORE_DECL), scoped around + the five held arms alone, replays the held workspace's exact command + sequence — staging `build` and the `review status` item lookup included — + with the seam flag alone removed, and after each held-then-released arm + the two whole trees are compared byte-identically via + `assertDirectoriesEqual` with no exclusions (sources, journal, sessions, + derived files, graph data; H-4 product-to-itself, H-6 across directories; + hold paths already live outside the root). Per-arm compares make the twin + byte-identical at each next arm's start, so every arm runs "the same + operation on an identical twin workspace"; the sequence equality — reads + included — is exactly what §VIOL-CORE-CHATTYREADS's passing analysis + leans on (its appends land byte-identically on both sides), recorded as a + header staging constraint. Arms 4/5 pass each side its own workspace's + reported item ID (same operation by item scope, never an assumed + cross-directory ID equality); `heldArm` gained an optional `twinArgv`. + Title extended; traceability unchanged (["13.5"]; no numbered condition); + no new adapter (S-5 unchanged); no certification-scope change. NOT red + against this repo's product: suite section-13.5 7/7 — the product is + already seam-neutral, consistent with T13.5-6's passing cross-directory + tree compare; the pass is proven genuine by a teeth probe (twin's arm-2 + rename diverted a→a3 fails exactly at the new compare, diagnosing all 6 + divergent files across sources, journal, graph data, and generated + modules; probe reverted). Certification: CONF-CORE conformer 9/9 with the + new arm; violators exact — CHATTYREADS fails exactly T6.1-1+T13.4-5 while + passing T13.5-1 (the sequence staging's proof), EARLYWRITE fails exactly + T13.5-1 (still at the earlier while-held compare) +T13.5-4, NOLOCK/ + STALELOCK/PARTIALWRITE/PERSISTREADS exact. Typecheck/format clean; + `npm run test:self` unchanged 4 planned mid-loop reds + (certification-document ×3 → FP-091; S-1's 7 unmapped keys {11.2–11.6, + 12.6, 12.7} → stage G).] - [ ] FP-048 — T14-4: extend the reporter matrix. [R2 #35; TEST-SPEC §14] `test/suite/registry/section-14.ts`: 14.21 by `check` alone beside gate diff --git a/test/suite/registry/section-13.5.ts b/test/suite/registry/section-13.5.ts index e8c9129..dc3b3ae 100644 --- a/test/suite/registry/section-13.5.ts +++ b/test/suite/registry/section-13.5.ts @@ -1,5 +1,6 @@ // TEST-SPEC §13.5 (concurrency and isolation) — SUITE-48: T13.5-1 (hold-seam -// basics: five held mutating-command arms, the occupied-hold-path exit-2 +// basics: five held mutating-command arms each compared byte-identically +// against its no-seam twin (seam neutrality), the occupied-hold-path exit-2 // arms, and the non-mutating unknown-flag arm), T13.5-2 (mutual exclusion), // T13.5-3 (exclusivity ends with the process), T13.5-4 (readers during // mutation + build/query storm), T13.5-5 (atomic visibility via a polling @@ -22,6 +23,11 @@ // fixtures stay in CONF-CORE's workspace shape: one spec group of // importless, tagless `.mdx` sources; no `code`, `markdown`, `coverage`, // or `policy` keys; no git. +// - T13.5-1's seam-neutrality twin drives the exact command sequence of the +// held workspace — the staging `build` and the `review status` item +// lookup included — with the seam flag alone removed, and its whole-tree +// compare includes the journal (§VIOL-CORE-CHATTYREADS's passing analysis +// leans on exactly that sequence equality). // - T13.5-2's excluded commands carry no `--test-hold` (§VIOL-CORE-NOLOCK), // and its modifies-nothing compare brackets each excluded command alone, // with the baseline snapshot taken while command 1 is already held @@ -46,6 +52,14 @@ // certified via VIOL-CORE-EARLYWRITE — plus: the process is still running // after that snapshot's full-tree read completes, and exits 0 only after // the harness deletes the hold file. +// - Seam neutrality (T13.5-1): one identical twin workspace replays each +// held arm's operation without `--test-hold` and the two whole trees — +// sources, journal, sessions, derived files, graph data — are compared +// after each arm (H-4 product-to-itself, H-6 across directories). The +// per-arm compare makes the twin byte-identical at each next arm's start, +// so every arm runs "the same operation on an identical twin workspace"; +// arms 4/5 pass each side its own workspace's reported item ID — the same +// operation by item scope, never an assumed cross-directory ID equality. // - "Fails promptly" (T13.5-1 occupied path, T13.5-2): a bounded foreground // run — a product that blocks instead of failing is killed at the bound // and fails diagnosed (H-8; the bound is a hang guard, never an assertion @@ -317,152 +331,214 @@ function requireRowByScope( const T13_5_1 = defineProductTest({ id: "T13.5-1", title: - "each mutating command (`rename`, file-form `move`, `review create/resolve/split`) with `--test-hold` creates an empty file at the path after acquiring exclusivity and before modifying anything (workspace byte-identical while held), proceeds only once the file is deleted, and completes normally; anything at the hold path — file, directory, or symlink — fails the command exit 2 without modifying anything; `build` and `query` given `--test-hold` fail exit 2 as an unknown flag (SPEC 13.5, 12.0)", + "each mutating command (`rename`, file-form `move`, `review create/resolve/split`) with `--test-hold` creates an empty file at the path after acquiring exclusivity and before modifying anything (workspace byte-identical while held), proceeds only once the file is deleted, and completes normally, the held-then-released run's final workspace state — sources, journal, sessions, derived files, and graph data — byte-identical to the same operation run without `--test-hold` on an identical twin workspace (seam neutrality: the seam changes no other behavior; H-4/H-6); anything at the hold path — file, directory, or symlink — fails the command exit 2 without modifying anything; `build` and `query` given `--test-hold` fail exit 2 as an unknown flag (SPEC 13.5, 12.0)", run: async (product) => { await withWorkspace(CORE_DECL, async (workspace) => { - await buildOk(product, workspace, "T13.5-1 staging `build`"); - - let armIndex = 0; - const heldArm = async ( - argv: readonly string[], - what: string, - onCompleted: () => Promise<void>, - ): Promise<void> => { - armIndex += 1; - const hold = holdPathFor(workspace, `hold-${String(armIndex)}.tmp`); - const context = `T13.5-1 (held ${what})`; - const before = await snapshotDirectory(workspace.root); - const running = await startProduct(product, { - cwd: workspace.root, - argv: [...argv, "--test-hold", hold], - }); - try { - await awaitHoldFile(running, hold, context); - await assertEmptyHoldFile(hold, context); - const whileHeld = await snapshotDirectory(workspace.root); - assertSnapshotsEqual( - before, - whileHeld, - `${context}: the workspace while held vs before the command ` + - `started — the hold file is created after acquiring ` + - `exclusivity and before modifying anything, so the workspace ` + - `is byte-identical while held (SPEC 13.5)`, - ); - if (running.hasExited()) { - fail( - `${context}: the command must proceed only once the hold file ` + - `is deleted, but it exited while the hold file still ` + - `existed (SPEC 13.5) — ${await describeExit(running)}`, - ); - } - await releaseHoldFile(hold); - let result: RunResult; + // Seam neutrality (SPEC 13.5: the seam changes no other behavior): an + // identical twin workspace is driven through the exact same command + // sequence — the staging `build` and the `review status` item lookup + // included — with the seam flag alone removed, and after each + // held-then-released arm the two whole trees (sources, journal, + // sessions, derived files, graph data) are compared byte-identically: + // a product-to-itself comparison under H-4, well-defined across + // directories per H-6, the hold path outside the workspace. The + // per-arm compare makes the twin byte-identical at each next arm's + // start, so every arm runs "the same operation on an identical twin + // workspace"; the two sides' sequences matching exactly — reads + // included — is the staging §VIOL-CORE-CHATTYREADS's passing analysis + // leans on (CERTIFICATIONS.md). + await withWorkspace(CORE_DECL, async (twin) => { + await buildOk(product, workspace, "T13.5-1 staging `build`"); + await buildOk(product, twin, "T13.5-1 twin staging `build`"); + + let armIndex = 0; + const heldArm = async ( + argv: readonly string[], + what: string, + onCompleted: () => Promise<void>, + twinArgv: readonly string[] = argv, + ): Promise<void> => { + armIndex += 1; + const hold = holdPathFor(workspace, `hold-${String(armIndex)}.tmp`); + const context = `T13.5-1 (held ${what})`; + const before = await snapshotDirectory(workspace.root); + const running = await startProduct(product, { + cwd: workspace.root, + argv: [...argv, "--test-hold", hold], + }); try { - result = await running.waitForExit(); - } catch (error) { - return fail( - `${context}: once the hold file is deleted the command must ` + - `proceed and complete normally (SPEC 13.5) — ` + - `${error instanceof Error ? error.message : String(error)}`, + await awaitHoldFile(running, hold, context); + await assertEmptyHoldFile(hold, context); + const whileHeld = await snapshotDirectory(workspace.root); + assertSnapshotsEqual( + before, + whileHeld, + `${context}: the workspace while held vs before the command ` + + `started — the hold file is created after acquiring ` + + `exclusivity and before modifying anything, so the workspace ` + + `is byte-identical while held (SPEC 13.5)`, ); + if (running.hasExited()) { + fail( + `${context}: the command must proceed only once the hold ` + + `file is deleted, but it exited while the hold file still ` + + `existed (SPEC 13.5) — ${await describeExit(running)}`, + ); + } + await releaseHoldFile(hold); + let result: RunResult; + try { + result = await running.waitForExit(); + } catch (error) { + return fail( + `${context}: once the hold file is deleted the command must ` + + `proceed and complete normally (SPEC 13.5) — ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + assertExitCode( + result, + 0, + `${context}: completes normally once the hold file is deleted ` + + `(SPEC 13.5)`, + ); + await onCompleted(); + } finally { + running.kill(); + await releaseHoldFile(hold); } - assertExitCode( - result, + + // Seam neutrality: the twin runs the same operation without + // `--test-hold`, and the final workspace states are compared + // whole — no exclusions, the journal included. + await expectExit( + product, + twin, + twinArgv, 0, - `${context}: completes normally once the hold file is deleted ` + - `(SPEC 13.5)`, + `T13.5-1 (twin ${what}) run without --test-hold on the ` + + `identical twin workspace (SPEC 13.5)`, ); - await onCompleted(); - } finally { - running.kill(); - await releaseHoldFile(hold); - } - }; + await assertDirectoriesEqual( + workspace.root, + twin.root, + `${context} vs its no-seam twin: the final workspace state of ` + + `the held-then-released run — sources, journal, sessions, ` + + `derived files, and graph data — is byte-identical to the ` + + `same operation run without --test-hold on an identical twin ` + + `workspace (SPEC 13.5 seam neutrality: the seam changes no ` + + `other behavior; a product-to-itself comparison under H-4, ` + + `well-defined across directories per H-6)`, + ); + }; - // Arm 1 — `review create` (audit strategy per §CONF-CORE). - await heldArm( - ["review", "create", "--strategy", "audit", "--name", "s"], - "`review create --strategy audit --name s`", - async () => { - const kind = await workspace.kind(sessionRel("s")); - if (kind !== "file") { - fail( - "T13.5-1 (held `review create`): after completing normally, " + - `the session file exists as a plain file at ` + - `${sessionRel("s")} (SPEC 10.1); found ${kind}`, - ); - } - }, - ); + // Arm 1 — `review create` (audit strategy per §CONF-CORE). + await heldArm( + ["review", "create", "--strategy", "audit", "--name", "s"], + "`review create --strategy audit --name s`", + async () => { + const kind = await workspace.kind(sessionRel("s")); + if (kind !== "file") { + fail( + "T13.5-1 (held `review create`): after completing normally, " + + `the session file exists as a plain file at ` + + `${sessionRel("s")} (SPEC 10.1); found ${kind}`, + ); + } + }, + ); - // Arm 2 — `rename`. - await heldArm( - ["rename", "specs/A.mdx", "a", "a2"], - "`rename specs/A.mdx a a2`", - async () => { - const text = new TextDecoder("utf-8", { fatal: false }).decode( - await workspace.readBytes("specs/A.mdx"), - ); - if (!text.includes('id="a2"')) { - fail( - "T13.5-1 (held `rename`): after completing normally, " + - 'specs/A.mdx carries the renamed id="a2" (SPEC 6.4)', + // Arm 2 — `rename`. + await heldArm( + ["rename", "specs/A.mdx", "a", "a2"], + "`rename specs/A.mdx a a2`", + async () => { + const text = new TextDecoder("utf-8", { fatal: false }).decode( + await workspace.readBytes("specs/A.mdx"), ); - } - }, - ); + if (!text.includes('id="a2"')) { + fail( + "T13.5-1 (held `rename`): after completing normally, " + + 'specs/A.mdx carries the renamed id="a2" (SPEC 6.4)', + ); + } + }, + ); - // Arm 3 — file-form `move` (never the section form, §CONF-CORE). - await heldArm( - ["move", "specs/A.mdx", "specs/Moved.mdx"], - "`move specs/A.mdx specs/Moved.mdx`", - async () => { - const moved = await workspace.kind("specs/Moved.mdx"); - const original = await workspace.kind("specs/A.mdx"); - if (moved !== "file" || original !== "absent") { - fail( - "T13.5-1 (held `move`): after completing normally, the file " + - `moved — specs/Moved.mdx is a plain file (found ${moved}) ` + - `and specs/A.mdx is absent (found ${original}) (SPEC 6.5)`, - ); - } - }, - ); + // Arm 3 — file-form `move` (never the section form, §CONF-CORE). + await heldArm( + ["move", "specs/A.mdx", "specs/Moved.mdx"], + "`move specs/A.mdx specs/Moved.mdx`", + async () => { + const moved = await workspace.kind("specs/Moved.mdx"); + const original = await workspace.kind("specs/A.mdx"); + if (moved !== "file" || original !== "absent") { + fail( + "T13.5-1 (held `move`): after completing normally, the " + + `file moved — specs/Moved.mdx is a plain file (found ` + + `${moved}) and specs/A.mdx is absent (found ${original}) ` + + `(SPEC 6.5)`, + ); + } + }, + ); - // Arms 4 and 5 need item IDs: read them once — identities are - // presented under the current (post-rename, post-move) identity - // (SPEC 10.4). - const status = await sessionStatus( - product, - workspace, - "s", - "T13.5-1 item lookup", - ); - const gItem = requireRowByScope( - status, - "specs/Moved.mdx#g", - "T13.5-1 item lookup (leaf item)", - ); - const aItem = requireRowByScope( - status, - "specs/Moved.mdx#a2", - "T13.5-1 item lookup (parent item)", - ); + // Arms 4 and 5 need item IDs: read them once — identities are + // presented under the current (post-rename, post-move) identity + // (SPEC 10.4). The twin replays the same read at the same sequence + // position, and each arm passes each side its own workspace's + // reported item ID — the same operation by item scope, never an + // assumed cross-directory ID equality (H-4 product-to-itself). + const status = await sessionStatus( + product, + workspace, + "s", + "T13.5-1 item lookup", + ); + const gItem = requireRowByScope( + status, + "specs/Moved.mdx#g", + "T13.5-1 item lookup (leaf item)", + ); + const aItem = requireRowByScope( + status, + "specs/Moved.mdx#a2", + "T13.5-1 item lookup (parent item)", + ); + const twinStatus = await sessionStatus( + product, + twin, + "s", + "T13.5-1 twin item lookup", + ); + const twinGItem = requireRowByScope( + twinStatus, + "specs/Moved.mdx#g", + "T13.5-1 twin item lookup (leaf item)", + ); + const twinAItem = requireRowByScope( + twinStatus, + "specs/Moved.mdx#a2", + "T13.5-1 twin item lookup (parent item)", + ); - // Arm 4 — `review resolve` (the unblocked leaf item, SPEC 10.6). - await heldArm( - ["review", "resolve", "s", gItem.id, "--status", "no-change"], - "`review resolve s <leaf item> --status no-change`", - async () => Promise.resolve(), - ); + // Arm 4 — `review resolve` (the unblocked leaf item, SPEC 10.6). + await heldArm( + ["review", "resolve", "s", gItem.id, "--status", "no-change"], + "`review resolve s <leaf item> --status no-change`", + async () => Promise.resolve(), + ["review", "resolve", "s", twinGItem.id, "--status", "no-change"], + ); - // Arm 5 — `review split` (the parent item's scope root has a child, - // SPEC 10.7). - await heldArm( - ["review", "split", "s", aItem.id], - "`review split s <parent item>`", - async () => Promise.resolve(), - ); + // Arm 5 — `review split` (the parent item's scope root has a child, + // SPEC 10.7). + await heldArm( + ["review", "split", "s", aItem.id], + "`review split s <parent item>`", + async () => Promise.resolve(), + ["review", "split", "s", twinAItem.id], + ); + }); // Occupied hold path: anything at the path — a file, directory, or // symbolic link (staged dangling: a create that follows the link From 5fa521b83b6d62096a7aa01fa5115b41bf4d09f7 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 21:20:37 +0000 Subject: [PATCH 093/226] sdg(phase-9): extend T14-4's reporter matrix to the new-surface rows (FP-048) - 14.21 failing-workspace half: check alone reports it beside the gate's findings; build and the gated review reads report exactly the gate's. - 14.23 row: inventory (new scoped form-exact decodeInventoryFindings, S-5-guarded) and rename --preview report it; check reports exactly the 14.10 unit form; query nodes, build, and check-after prove the reads leave the record unconsulted and the rebuild replaces it. - 14.14 row: version answers exit 0 with one JSON document under the same invalid configuration that makes build/check exit 2. - Availability rows: every sweep condition's finding accompanies the occurrences/view/at answers its staging's kind admits (all three for spec sources, occurrences alone for code sources) at exit 1 via the form-exact 12.7 decoders; 14.13/14.22 ride query nodes' gate report instead while all three surfaces answer finding-free at exit 0. Suite section-14 unchanged (5 failed, first-arm FP-001-class decode; new arms probed directly against the built product, red-as-diagnosed); S-5 82/82; test:self unchanged 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 52 +++- test/helpers/adapters/forms.ts | 25 ++ test/self/s5-output-adapters.test.ts | 65 +++++ test/suite/registry/section-14.ts | 410 ++++++++++++++++++++++++++- 4 files changed, 546 insertions(+), 6 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 0a20794..2171f4d 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2011,7 +2011,7 @@ A "new test T<x>" task always means, in one change: (certification-document ×3 → FP-091; S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G).] -- [ ] FP-048 — T14-4: extend the reporter matrix. [R2 #35; TEST-SPEC §14] +- [x] FP-048 — T14-4: extend the reporter matrix. [R2 #35; TEST-SPEC §14] `test/suite/registry/section-14.ts`: 14.21 by `check` alone beside gate findings; the 14.23 row (`inventory` + previews only, `check` as 14.10 unit form, `build`/refreshing reads never); 14.14 never `version`; @@ -2019,6 +2019,56 @@ A "new test T<x>" task always means, in one change: answer; every other condition accompanying `occurrences`/`view`/`at` answers per domain (all three for spec-source stagings; `occurrences` alone for 14.7/14.11/14.18). + [Done 2026-08-13: all five rows landed, membership-only per the module + header's matrix discipline (depth stays with T10.1-5, T6.6-6, T11.6-4, + T12.2-2, T13.3-2/-3, T11.2-5/-6, T12.6-1/-2). (1) The 14.21 block gains + the failing-workspace half — source edited to 14.1 after the corrupt + session: `build` exactly {14.1}, `check` non-14.10 exactly {14.1, 14.21}, + `review status bad --json` and `review list --json` each exit 1 with the + gate report exactly {14.1}, no 14.21 beside. (2) New 14.23 block: valid + build, then `corruptGraphDataShapeBlind` (T6.6-6's shared staging); + `inventory` exit 1 exactly {14.23} via a new scoped form-exact adapter + `decodeInventoryFindings` (forms.ts, the decodeInventoryRecordedDatum + pattern — pinned `findings` member literally decoded, every other member + unread; S-5 DECODERS entry with conforming/old-shape/absent/null cases); + `rename specs/a.mdx a1 a2 --preview --json` exit 1 exactly {14.23} via + decodePreviewReport; `check` exactly {14.10: 1} (the unit form alone — + never 14.23, no per-file finding beside on the clean workspace, no + nonStale set-aside); `query nodes` exit 0 (reads leave the record + unconsulted); `build` exit 0 then `check` exit 0 (the rebuild replaces + the record). (3) `version` under the 14.14 workspace: exit 0 with a + single JSON document (12.6 JSON-only) — never the configuration error. + (4)+(5) Every sweep entry now carries an `answers` classification: + spec-source stagings run `occurrences`/`view`/`at <file> 0` (offset 0 — + resolution is total, 11.5), code-source stagings (14.7/14.11/14.18) + `occurrences` alone, each answer decoded through the form-exact 12.7 + document decoders at exit 1 with the staged condition counted exactly + like the build side (these surfaces never report 14.10); the + no-domain-file entries (14.13/14.22) instead probe `query nodes` — exit 1, + findings-only report, exactly the staged finding — while all three + surfaces answer finding-free at exit 0 over the staged valid spec source. + Title extended to the full entry; traceability unchanged (["14"] — 10.1/ + 11.x/12.6/13.3/6.6 are carriage context with home coverage at their own + tests, the FP-015/FP-018 precedent); no certification scope (T14-4 sits + in CERTIFICATIONS.md's not-to-certify residue). Verified: typecheck/ + format clean; S-5 82/82 green incl. the new decoder's guards; suite + section-14 unchanged 5 failed / 0 passed — every test red at its first + FP-001-class form-exact decode, new arms suite-unreached; direct probes + against the built product prove every new staging sound and every new + arm red-as-diagnosed: the §11/12.6 surfaces and `--preview` are unknown + commands/flags on this pre-patch product (exit 2 → diagnosed exit + failures: inventory/preview/version/occurrences/view/at), the + failing-side 14.21 commands report old-shape findings with conforming + membership except `review status bad` which reports the corrupt session + instead of the gate findings (a real behavioral gap the arm + discriminates), the 14.22 staging's `query nodes` answers exit 0 instead + of gating (the T13.3-3-diagnosed gate gap), the 14.13 gated read exits 1 + with the old-shape 14.13 naming the line, and the 14.23 staging's + `check` reports exactly one old-shape 14.10 while `query nodes`/`build`/ + `check`-after exit 0 as the arms demand. `npm run test:self`: unchanged + 4 planned mid-loop reds (certification-document ×3 → FP-091; S-1's 7 + unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification + green.] ## Stage G — §§10–14 missing tests (new-test convention applies) diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts index 9e0c22d..7b8751c 100644 --- a/test/helpers/adapters/forms.ts +++ b/test/helpers/adapters/forms.ts @@ -543,6 +543,31 @@ export function decodeInventoryRecordedDatum( ); } +/** + * Scoped decode of the inventory document's `findings` member (SPEC 11.6, + * 12.7): the pinned `"findings"` array in the literal finding form and the + * pinned findings order. Deliberately scoped exactly as + * `decodeInventoryRecordedDatum` is: SPEC 12.7 fixes the whole inventory + * form and the T11.6-* tests pin it entirely; this decoder reads the one + * member the reporter matrix needs (T14-4's 14.23 row: the condition-23 + * finding accompanies the inventory answer) — the top level must be an + * object and the member present (`[]` is never `null`, and wherever a + * document carries findings they form this member, 12.7) while every other + * member stays unread. Form-exact (H-3): never adjustable to a product's + * shape. + */ +export function decodeInventoryFindings( + doc: unknown, + context?: string, +): Finding[] { + const site = rootSite("11.6 inventory (findings)", context); + const obj = expectObject(doc, site); + return decodeFindingsArray( + requiredKey(obj, "findings", site), + at(site, "findings"), + ); +} + // --- the occurrences document (5.7, 11.3, 12.7) ------------------------------- const OCCURRENCE_RECORD_MEMBERS = [ diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index 1923ccd..ce86f9d 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -45,6 +45,7 @@ import { decodeIdsReport, decodeIdsTreeReport, decodeImpactReport, + decodeInventoryFindings, decodeInventoryRecordedDatum, decodeItemReport, decodeNextReport, @@ -2176,6 +2177,70 @@ const DECODERS: readonly DecoderSpec[] = [ }, ], }, + { + // The scoped inventory findings decode (SPEC 11.6, 12.7): exactly the + // pinned `findings` member — the literal finding form in the pinned + // findings order — with every other member unread (the full inventory + // form is T11.6-*'s subject; T14-4's 14.23 row reads the condition-23 + // finding through this decode). + name: "11.6 inventory (findings)", + decode: decodeInventoryFindings, + good: { + findings: [ + { + code: "unreadable-record", + message: "recorded generation state cannot be read as a record", + locations: [], + path: ".xspec", + identities: [], + }, + ], + recorded: { unavailable: true }, + graphData: ".xspec", + }, + verify: (decoded: ReturnType<typeof decodeInventoryFindings>) => { + expect(decoded).toHaveLength(1); + expect(decoded[0]!.code).toBe("unreadable-record"); + expect(decoded[0]!.condition).toBe("14.23"); + expect(decoded[0]!.path).toBe(".xspec"); + }, + alsoGood: [ + { + label: + "a finding-free inventory answer carries findings [] — the empty " + + "array, never null (SPEC 12.7)", + doc: { findings: [], recorded: [] }, + verify: (decoded: ReturnType<typeof decodeInventoryFindings>): void => { + expect(decoded).toEqual([]); + }, + }, + ], + bad: [ + { + label: + "absent findings member (SPEC 12.7: wherever a document carries " + + 'findings they form the array member "findings")', + doc: { recorded: [], graphData: ".xspec" }, + }, + { + label: + "null findings (SPEC 12.7: a list-valued member with no elements " + + "is the empty array, never null)", + doc: { findings: null, recorded: [] }, + }, + { + label: + "an old-shape finding element (condition/file members instead of " + + "the literal 12.7 finding form)", + doc: { + findings: [ + { condition: "14.23", file: ".xspec", message: "corrupt" }, + ], + recorded: { unavailable: true }, + }, + }, + ], + }, { name: "coverage", decode: decodeCoverageReport, diff --git a/test/suite/registry/section-14.ts b/test/suite/registry/section-14.ts index 7b73c50..fd578a8 100644 --- a/test/suite/registry/section-14.ts +++ b/test/suite/registry/section-14.ts @@ -6,8 +6,9 @@ // file/location/correction information (T14-1), the unresolved-reference // conditions 14.5/14.6/14.7 plus the consumer-side type error (T14-2), // masking by unparseable files and by configuration errors (T14-3), the -// reporter matrix — which of `build`/`check`/`review` reports which -// condition (T14-4) — and grammar selection by file name (T14-5). +// reporter matrix — which of `build`/`check`/`review`/the machine-interface +// surfaces reports which condition (T14-4) — and grammar selection by file +// name (T14-5). // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -51,14 +52,55 @@ // - T14-4's 14.21 arm asserts matrix membership — exit 1 with /corrupt/i on // stdout, the T10.1-4 operationalization — for one subcommand naming the // session (`review status`) and for `review list`; the all-subcommands -// breadth and the fields-level list contract are T10.1-4's subject. +// breadth and the fields-level list contract are T10.1-4's subject. The +// failing-workspace half likewise asserts membership alone — `check` +// reports 14.21 beside the gate's findings while `build`, `review status`, +// and `review list` report exactly the gate's findings (the `--json` +// findings report of the refusing reads, 12.7/13.3) — the every-subcommand +// breadth, modifies-nothing compares, and bytes-untouched assertions being +// T10.1-5's subject. +// - T14-4's 14.23 arm asserts reporter membership by exact condition counts: +// `inventory` (the scoped `decodeInventoryFindings` decode) and a +// `rename --preview` each carry exactly the one condition-23 finding; +// `check` reports exactly one condition-10 finding (the unit form — so +// never 14.23, never a per-file finding beside it on the freshly built, +// otherwise clean workspace); a refreshing read (`query nodes`) and +// `build` exit 0. Depth — `recorded`/`delta` unavailability, concerned +// paths, record discipline, replacement — is T11.6-4's, T6.6-6's, +// T12.2-2's, and T13.3-2's subject. +// - T14-4's 14.14 row includes `version`: exit 0 with a single JSON document +// as its entire stdout (12.6 is JSON-only) on the same invalid +// configuration that makes `build`/`check` exit 2 — the never-`version` +// membership; the byte-identity and document-form depth is T12.6-1/2's. +// - T14-4's availability rows (SPEC 11.2): each sweep condition's finding +// accompanies the answers of the surfaces whose domain can hold its staged +// file — `occurrences`, `view`, and `at <file> 0` for a spec-source +// staging (offset 0 is always a within-file offset of the non-empty staged +// files; resolution is total, 11.5), `occurrences` alone for a code-source +// one (14.7/14.11/14.18 locate in code sources alone; `view`'s and `at`'s +// domains hold spec sources only) — each answer decoded through the +// form-exact 12.7 document decoders (so the full answer member is emitted +// beside the findings) at exit 1, its findings counted exactly like the +// `build` side (these surfaces never report 14.10, which is `check`'s +// alone, so no set-aside applies). 14.13 and 14.22 are instead the +// findings of no domain file: one gated read (`query nodes`) reports +// exactly the staged finding at exit 1 (the 13.3 gate; the six-read +// breadth and modifies-nothing compares are T13.3-3's), while the three +// surfaces answer finding-free at exit 0 over the staged valid spec +// source. Per-surface semantics depth is T11.2-*..T11.5-*'s subject. import { Buffer } from "node:buffer"; import type { Finding, GraphEdge } from "../../helpers/adapters/index.js"; import { assertReportMentions, + corruptGraphDataShapeBlind, + decodeAtReport, decodeEdgesReport, decodeFindingsReport, + decodeInventoryFindings, + decodeOccurrencesReport, + decodePreviewReport, + decodeViewFilesReport, } from "../../helpers/adapters/index.js"; import { assertExitCode, @@ -172,6 +214,24 @@ function nonStale(findings: readonly Finding[]): readonly Finding[] { return findings.filter((finding) => finding.condition !== "14.10"); } +/** + * Run a JSON-only surface (or a `--json` invocation) expecting the exact + * exit code (H-5) with exactly one JSON document as the entire stdout (SPEC + * 12.0), returned parsed for the form-exact decoders — the counterpart of + * support.ts `runJson` for answers that carry findings and therefore exit 1 + * with the full answer document still emitted (SPEC 11.2, 11.6, 6.6). + */ +async function runJsonExpecting( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + exitCode: number, + context: string, +): Promise<unknown> { + const result = await expectExit(product, workspace, argv, exitCode, context); + return parseJsonStdout(result, context); +} + /** * Resolve the unique finding carrying `condition` (the caller has already * pinned the condition multiset, so a miss here is a diagnosed count defect). @@ -757,6 +817,21 @@ interface SweepEntry { * staged defect or one per occurrence (the T1.3-5 operationalization). */ readonly perOccurrenceTolerated?: boolean; + /** + * Which machine-interface answers the staged condition accompanies (the + * T14-4 availability rows; SPEC 11.2, module header): a spec-source + * staging accompanies all three of `occurrences`/`view`/`at <file> 0`; a + * code-source staging accompanies `occurrences` alone (`view`'s and + * `at`'s domains hold spec sources only, 11.4/11.5); the conditions of no + * domain file (14.13, 14.22) accompany none of them — they are instead + * reported by the gated reads (13.3), probed via `query nodes`, while the + * three surfaces answer finding-free at exit 0 over `file`, the staging's + * valid spec source. + */ + readonly answers: + | { readonly kind: "spec-source"; readonly file: string } + | { readonly kind: "code-source" } + | { readonly kind: "no-domain-file"; readonly file: string }; } /** Shorthand: a specs-only workspace whose one source stages the condition. */ @@ -767,6 +842,7 @@ function specArm(condition: string, label: string, source: string): SweepEntry { decl: { files: { "xspec.config.ts": SPECS_ONLY_CONFIG, "specs/a.mdx": source }, }, + answers: { kind: "spec-source", file: "specs/a.mdx" }, }; } @@ -782,6 +858,7 @@ function codeArm(condition: string, label: string, source: string): SweepEntry { "src/app.ts": source, }, }, + answers: { kind: "code-source" }, }; } @@ -870,6 +947,7 @@ const SWEEP_ENTRIES: readonly SweepEntry[] = [ ].join("\n"), }, }, + answers: { kind: "code-source" }, }, { condition: "14.13", @@ -888,6 +966,7 @@ const SWEEP_ENTRIES: readonly SweepEntry[] = [ ); await workspace.file(".xspec/journal", GARBAGE_JOURNAL_LINE); }, + answers: { kind: "no-domain-file", file: "specs/a.mdx" }, }, specArm( "14.15", @@ -930,6 +1009,11 @@ const SWEEP_ENTRIES: readonly SweepEntry[] = [ "specs/a#b.mdx": '<S id="a">\nValid content, invalid path.\n</S>\n', }, }, + // The `#`-containing path is valid UTF-8, so the file is nameable by an + // argument value: it keeps its parse-local view, every node identity in + // it explicitly unavailable, its condition-19 finding accompanying every + // answer whose consulted domain includes it (SPEC 11.2, 11.4, 11.5). + answers: { kind: "spec-source", file: "specs/a#b.mdx" }, }, specArm("14.20", "unparseable source", '<S id="x">\nUnclosed element.\n'), { @@ -951,9 +1035,50 @@ export default defineConfig({ dirs: ["real-out"], symlinks: { out: "real-out" }, }, + answers: { kind: "no-domain-file", file: "specs/a.mdx" }, }, ]; +/** + * One availability-surface probe (SPEC 11.2, 11.3–11.5): the invocation + * paired with the form-exact 12.7 document decode, so asserting the decoded + * findings also asserts the full answer member is emitted beside them. + */ +interface AvailabilityProbe { + readonly what: string; + readonly argv: readonly string[]; + readonly findingsOf: (doc: unknown, context: string) => readonly Finding[]; +} + +/** `occurrences` alone — the one surface whose domain holds code sources. */ +const OCCURRENCES_PROBE: AvailabilityProbe = { + what: "`occurrences`", + argv: ["occurrences"], + findingsOf: (doc, context) => decodeOccurrencesReport(doc, context).findings, +}; + +/** + * All three surfaces over one staged spec source. `at` probes offset 0 — a + * within-file offset of every (non-empty) staged file; resolution is total + * over the file (11.5), so the answer never turns on the offset choice. + */ +function availabilityProbes(file: string): readonly AvailabilityProbe[] { + return [ + OCCURRENCES_PROBE, + { + what: "`view`", + argv: ["view"], + findingsOf: (doc, context) => + decodeViewFilesReport(doc, context).findings, + }, + { + what: `\`at ${file} 0\``, + argv: ["at", file, "0"], + findingsOf: (doc, context) => decodeAtReport(doc, context).findings, + }, + ]; +} + /** One command's sweep assertion (build exact; check over non-14.10). */ function assertSweepFindings( findings: readonly Finding[], @@ -982,7 +1107,7 @@ function assertSweepFindings( const T14_4 = defineProductTest({ id: "T14-4", title: - "the reporter matrix: 14.10 and 14.12 reported by `check` only (a stale workspace `build`s successfully by regenerating; a policy-violating workspace `build`s successfully); 14.21 reported by `check`, by `review` subcommands naming the session, and by `review list` — not by `build`; every other condition reported by both `build` and `check` (14.14 as the every-command usage error) (SPEC 14, 12.1, 12.2, 10.1)", + "the reporter matrix: 14.10 and 14.12 reported by `check` only (a stale workspace `build`s successfully by regenerating; a policy-violating workspace `build`s successfully); 14.21 reported by `check`, by `review` subcommands naming the session, and by `review list` — not by `build`, and on a workspace failing `build`'s validations by `check` alone, beside the gate's findings; 14.23 reported by `inventory` and `rename`/`move` previews only — `check` reports the state as 14.10's unit form, and `build` and the refreshing reads never do; 14.14 as the every-command usage error — never `version`; 14.13 and 14.22 reported by `build`, `check`, and the gated reads, yet accompanying no `occurrences`/`view`/`at` answer; every other condition reported by both `build` and `check`, and as a domain file's finding accompanying the answers of each of `occurrences`/`view`/`at` whose domain can hold its staged file — all three for a spec-source staging, `occurrences` alone for a code-source one (SPEC 14, 12.1, 12.2, 10.1, 13.3, 11.2, 11.3-11.6, 6.6, 12.6)", timeoutMs: 480_000, run: async (product) => { // --- 14.10: check-only. A stale workspace `build`s successfully by @@ -1150,6 +1275,171 @@ export default defineConfig({ `content, 12.0; information presence, never exact wording, H-3)`, ); } + + // On a workspace failing `build`'s validations, 14.21 is reported + // by `check` alone, beside the gate's findings: no session is read + // on the failing side, so the gated `review` reads report exactly + // the gate's findings — the validation errors, no condition-21 + // finding beside them (SPEC 14.21, 13.3, 10.1; membership only, the + // module header — the every-subcommand breadth, modifies-nothing + // compares, and bytes-untouched assertions are T10.1-5's). + await workspace.file("specs/a.mdx", "<S>\nNo id.\n</S>\n"); + assertConditionCounts( + await buildFindings( + product, + workspace, + "T14-4 (14.21, failing workspace) `build --json`", + ), + { "14.1": 1 }, + "T14-4 (14.21, failing workspace) `build` reports the validation " + + "error alone — `build` does not read sessions, so 14.21 is " + + "never its finding (SPEC 14.21, 12.1)", + ); + assertConditionCounts( + nonStale( + await checkFindings( + product, + workspace, + "T14-4 (14.21, failing workspace) `check --json`", + ), + ), + { "14.1": 1, "14.21": 1 }, + "T14-4 (14.21, failing workspace) `check` reports 14.21 beside " + + "the failing workspace's other findings — the validation error " + + "and the corrupt session together, counted exactly over the " + + "non-14.10 findings (SPEC 14.21, 12.2; module header)", + ); + for (const argv of [ + ["review", "status", "bad", "--json"], + ["review", "list", "--json"], + ] as const) { + const context = `T14-4 (14.21, failing workspace) \`${argv.join(" ")}\``; + const result = await expectExit( + product, + workspace, + argv, + 1, + `${context} — on a workspace failing \`build\`'s validations a ` + + `gated read reports the gate's findings and exits 1 without ` + + `answering (SPEC 13.3, 12.0)`, + ); + assertConditionCounts( + decodeFindingsReport(parseJsonStdout(result, context), context) + .findings, + { "14.1": 1 }, + `${context} — exactly the gate's findings: no session file is ` + + `read on a failing workspace, so no condition-21 finding is ` + + `reported beside them — on this workspace 14.21 is \`check\`'s ` + + `alone (SPEC 14.21, 13.3, 10.1; depth: T10.1-5)`, + ); + } + }, + ); + + // --- 14.23: reported by `inventory` and `rename`/`move` previews only — + // `check` reports the state as 14.10's unit form, and `build` and the + // refreshing reads never do: the rebuild replaces the record; the reads + // leave it unconsulted (SPEC 14.23, 14.10, 13.3, 11.6, 6.6; membership + // by exact counts per the module header — depth: T11.6-4, T6.6-6, + // T12.2-2, T13.3-2). + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/a.mdx": '<S id="a1">\nValid behavior.\n</S>\n', + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T14-4 (14.23) staging `build` — the corruption applies to a " + + "record the product itself wrote (SPEC 12.1, 13.3; H-3)", + ); + await corruptGraphDataShapeBlind(workspace.root, "T14-4 (14.23)"); + + const inventoryContext = "T14-4 (14.23) `inventory`"; + assertConditionCounts( + decodeInventoryFindings( + await runJsonExpecting( + product, + workspace, + ["inventory"], + 1, + `${inventoryContext} — the condition-23 finding accompanies ` + + `the answer and the invocation exits 1 (SPEC 14.23, 11.6)`, + ), + inventoryContext, + ), + { "14.23": 1 }, + `${inventoryContext} — the unreadable record is the inventory ` + + `answer's one finding on the otherwise clean workspace (SPEC ` + + `14.23, 11.6)`, + ); + + const previewContext = + "T14-4 (14.23) `rename specs/a.mdx a1 a2 --preview --json`"; + assertConditionCounts( + decodePreviewReport( + await runJsonExpecting( + product, + workspace, + ["rename", "specs/a.mdx", "a1", "a2", "--preview", "--json"], + 1, + `${previewContext} — the condition-23 finding accompanies the ` + + `answer and the invocation exits 1 (SPEC 14.23, 6.6)`, + ), + previewContext, + ).findings, + { "14.23": 1 }, + `${previewContext} — the preview consults the record for its ` + + `delta, so the otherwise valid plan's report carries exactly ` + + `the condition-23 finding (SPEC 14.23, 6.6; the delta's ` + + `unavailability and the plan's completeness are T6.6-6's)`, + ); + + assertConditionCounts( + await checkFindings( + product, + workspace, + "T14-4 (14.23) `check --json`", + ), + { "14.10": 1 }, + "T14-4 (14.23) `check` reports the state as staleness — exactly " + + "one condition-10 finding, the unit form: never 14.23, never " + + "the mismatch form or a per-file finding beside it on the " + + "freshly built, otherwise clean workspace (SPEC 14.23, 14.10; " + + "depth: T12.2-2)", + ); + + await expectExit( + product, + workspace, + ["query", "nodes"], + 0, + "T14-4 (14.23) `query nodes` on the corrupt-record state — the " + + "refreshing reads never report 14.23: they leave the record " + + "unconsulted and answer finding-free, exit 0 (SPEC 14.23, 13.3; " + + "depth: T13.3-2)", + ); + + await expectExit( + product, + workspace, + ["build"], + 0, + "T14-4 (14.23) `build` on the corrupt-record state — `build` " + + "never reports 14.23: its rebuild replaces the record (SPEC " + + "14.23, 12.1)", + ); + await expectExit( + product, + workspace, + ["check"], + 0, + "T14-4 (14.23) `check` after the rebuild — the successful " + + "`build` replaced the unreadable state (SPEC 14.23, 12.1, 13.3)", + ); }, ); @@ -1185,10 +1475,36 @@ export default defineConfig({ "T14-4 (14.14) `check` under the same configuration (SPEC 14.14, " + "7, 12.0)", ); + + // Never `version`: it loads no configuration, so configuration-error + // precedence cannot reach it — on the same invalid configuration + // that makes `build`/`check` exit 2, `version` answers at exit 0 + // with a single JSON document as its entire stdout (12.6 is + // JSON-only). Membership only; the byte-identity and document-form + // depth is T12.6-1/2's. + const versionContext = + "T14-4 (14.14) `version` under the same invalid configuration"; + parseJsonStdout( + await expectExit( + product, + workspace, + ["version"], + 0, + `${versionContext} — \`version\` loads no configuration and ` + + `cannot fail for workspace or configuration reasons: 14.14 is ` + + `delivered by every command that loads configuration, never ` + + `\`version\` (SPEC 12.6, 14.14)`, + ), + `${versionContext} — a JSON-only surface: a single JSON document ` + + `is its only output form, with or without --json (SPEC 12.6, 12.0)`, + ); }, ); - // --- Every other condition: reported by both `build` and `check`. + // --- Every other condition: reported by both `build` and `check`, and + // per its staging's kind by the machine-interface answers (SPEC 11.2; + // the availability rows of the module header). 14.13 and 14.22 instead + // ride the gated reads and accompany no such answer. for (const entry of SWEEP_ENTRIES) { await withWorkspace(entry.decl, async (workspace) => { await entry.prepare?.(product, workspace); @@ -1208,6 +1524,90 @@ export default defineConfig({ `finding, counted exactly over the non-14.10 findings (see the ` + `module header; SPEC 14, 12.2)`, ); + + if (entry.answers.kind === "no-domain-file") { + // Reported by the gated reads (SPEC 13.3: the gate is over every + // finding a `build` would report — journal errors and refused + // writes alike), probed via one read; the six-read breadth and + // modifies-nothing compares are T13.3-3's. + const gatedContext = `T14-4 (${entry.label}) \`query nodes\``; + assertSweepFindings( + decodeFindingsReport( + await runJsonExpecting( + product, + workspace, + ["query", "nodes"], + 1, + `${gatedContext} — a gated read on the failing workspace ` + + `reports the gate's findings and exits 1 without ` + + `answering (SPEC 13.3, 12.0)`, + ), + gatedContext, + ).findings, + entry, + `${gatedContext} — condition ${entry.condition} is the gated ` + + `reads' finding, exactly as a \`build\`'s (SPEC 13.3, 14; ` + + `depth: T13.3-3)`, + ); + // ...yet accompanying no `occurrences`/`view`/`at` answer: the + // condition is the finding of no domain file — the journal and a + // write-path component are never domain files — so these + // surfaces answer finding-free at exit 0 over the staged valid + // spec source (SPEC 11.2; depth: T11.2-6). + for (const probe of availabilityProbes(entry.answers.file)) { + const context = `T14-4 (${entry.label}) ${probe.what}`; + assertConditionCounts( + probe.findingsOf( + await runJson( + product, + workspace, + probe.argv, + `${context} — a complete, finding-free answer exits 0 ` + + `whatever journal or write-path state the workspace ` + + `holds (SPEC 11.2)`, + ), + context, + ), + {}, + `${context} — condition ${entry.condition} is the finding of ` + + `no domain file, so it accompanies no answer of this ` + + `surface (SPEC 11.2, 14; depth: T11.2-6)`, + ); + } + return; + } + + // A domain file's finding accompanies the answers of each surface + // whose domain can hold its staged file: all three for a + // spec-source staging; `occurrences` alone for a code-source one — + // 14.7/14.11/14.18 locate in code sources alone, and `view`'s and + // `at`'s domains hold spec sources only (SPEC 11.2, 11.3-11.5; + // depth: T11.2-5). + const probes = + entry.answers.kind === "spec-source" + ? availabilityProbes(entry.answers.file) + : [OCCURRENCES_PROBE]; + for (const probe of probes) { + const context = `T14-4 (${entry.label}) ${probe.what}`; + assertSweepFindings( + probe.findingsOf( + await runJsonExpecting( + product, + workspace, + probe.argv, + 1, + `${context} — an answer carrying any finding exits 1 with ` + + `the full answer document still emitted (SPEC 11.2)`, + ), + context, + ), + entry, + `${context} — condition ${entry.condition} is a domain file's ` + + `finding and accompanies the answer, counted exactly (these ` + + `surfaces never report 14.10, which is \`check\`'s alone; ` + + `SPEC 11.2, 11.3-11.5, 14)`, + ); + } }); } }, From 917683eb3f62325a9f9cc229b0c046837fd59608 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 21:36:22 +0000 Subject: [PATCH 094/226] sdg(phase-9): implement T10.1-5's gate-precedence-over-corruption pair (FP-049) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 33 +++- test/suite/registry/section-10.1.ts | 288 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 319 insertions(+), 3 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 2171f4d..72696e5 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2078,10 +2078,41 @@ T11.2-4's record observations ride `occurrences`/`view`; T11.4-1 stages no undefined datum. The six in-scope tests are marked (CONF-AVAIL) — they must certify against FP-091's fixtures once those land. -- [ ] FP-049 — Implement T10.1-5: failing-workspace gate precedence over +- [x] FP-049 — Implement T10.1-5: failing-workspace gate precedence over session corruption, with the `check`-reports-14.21-beside-gate-findings discriminating pair. [R2 #1; TEST-SPEC §10.1] Registry `section-10.1.ts`; map `"10.1"` (+ `"14"`). + [Done 2026-08-13: registered in section-10.1.ts with traceability + ["10.1","14"]. One workspace in TEST-SPEC's staging order — valid build, + audit session `cor`, T10.1-4's shape-independent garbage-bytes corruption, + then specs/B.mdx overwritten to a childless `<S>` (exactly one 14.1, no + masking interplay). A `build --json` gate-reference probe pins the gate's + findings as exactly {14.1: 1} located in specs/B.mdx — the exact count + doubling as condition 21's not-by-build half — then seven gated probes + (`status`, `next`, `show`/`resolve --status updated`/`split` with an item + ID no session ever held — judged only against session content, never + reached, 12.0 — `export`, and `review list`) each assert exit 1, the + form-exact 12.7 findings report holding exactly {14.1: 1} in specs/B.mdx + (the exact multiset realizes "no condition-21 finding beside them"; the + one-member decode realizes list's whole-report replacement, 10.7), inside + whole-root modifies-nothing compares; `check` asserts 14.21 present and + concerning `.xspec/reviews/cor.json` via the 12.7 path member together + with the 14.1 (presence-based beside them — 14.10 detectability is + T14-4's, the T13.3-3 precedent); a final byte-compare restates the + corrupt session untouched across the sweep. No certification scope + (CERTIFICATIONS.md Exclusions: T10.1-5 carries its own in-test + check-vs-subcommand contrast). Verified: typecheck/format clean; suite + section-10.1 went 3 failed/1 passed → 4 failed/1 passed — T10.1-5 + red-as-diagnosed at the first arm's FP-001-class form-exact decode + ("expected no member \"condition\""); direct probes against the built + product prove every arm sound and the deeper diagnosed gap real: the six + naming subcommands open the corrupt session first and report the old-shape + 14.21 instead of the gate's findings (exactly the TEST-SPEC-named defect), + while `list` gates correctly and `check` reports both conditions with + `file` naming the session path, everything exit 1, nothing modified, + cor.json bytes untouched. `npm run test:self`: unchanged 4 planned + mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys + {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] - [ ] FP-050 — Implement T11.2-1: parse-local structure and per-file masking with no writes. [R2 #2; TEST-SPEC §11.2] New registry module(s) diff --git a/test/suite/registry/section-10.1.ts b/test/suite/registry/section-10.1.ts index 1dd0d46..0ae7875 100644 --- a/test/suite/registry/section-10.1.ts +++ b/test/suite/registry/section-10.1.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §10.1 (review sessions) — SUITE-33: T10.1-1…T10.1-4. +// TEST-SPEC §10.1 (review sessions) — SUITE-33: T10.1-1…T10.1-5. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -38,6 +38,15 @@ // NAME` against only `NAME.JSON`) stage exactly one casing, so the // Windows-leg rerun (E-6; implemented by CI-01 in test/windows/) meets a // case-insensitive filesystem with the discriminating state intact. +// - T10.1-5's gate probes (SPEC 13.3): "report exactly the gate's findings" +// is exit 1 with stdout the single form-exact 12.7 findings report holding +// exactly the staged validation finding — for `review list`, that same +// one-member decode realizes "the gate's report replaces the per-session +// report whole" (SPEC 10.7): a document carrying session rows fails it. +// The `show`/`resolve`/`split` probes pass an item ID no session ever +// held: an item ID is judged only against its session's content (SPEC +// 12.0), which no gated command reads on a failing workspace (13.3), so +// the probes must gate identically whatever the ID. // // T10.1-4 staging is blackbox (H-3): every shape-dependent corrupt fixture // starts from a session file the product itself wrote and is corrupted @@ -52,7 +61,10 @@ // `split` — the decomposition is recorded durably in the session (SPEC 10.7) // — so the garbled member holds a genuine product-recorded decomposition. -import type { SessionStatusRow } from "../../helpers/adapters/index.js"; +import type { + Finding, + SessionStatusRow, +} from "../../helpers/adapters/index.js"; import { assertReportMentions, decodeFindingsReport, @@ -82,6 +94,8 @@ import { import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { + assertConditionCounts, + assertFindingLocated, assertSameJson, buildOk, expectErrorDocument, @@ -1165,10 +1179,280 @@ const T10_1_4 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T10.1-5 — failing workspace: gate precedence over corruption +// --------------------------------------------------------------------------- + +// The invalidating edit's target: valid at staging, then overwritten with a +// non-root section carrying no `id` — after the edit the workspace's one +// `build` validation finding is that 14.1 (the section has no children, so +// condition 2's masking never enters), making "exactly the gate's findings" +// a one-element multiset (SPEC 13.3, 14.1). A.mdx — the session's item +// source — is never touched, so the gate alone flips every subcommand's +// behavior. +const T10_1_5_B_VALID = ['<S id="b">', "Beta text.", "</S>", ""].join("\n"); +const T10_1_5_B_INVALID = ["<S>", "Beta text.", "</S>", ""].join("\n"); + +// T10.1-4's shape-independent garbage-bytes corruption: staged directly, no +// assumed session layout — the bytes parse as no JSON document (SPEC 10.1, +// 14.21). +const T10_1_5_GARBAGE = "this is deliberately not a JSON document ][}{\n"; + +// Item ID for the gated `show`/`resolve`/`split` probes: deliberately one no +// session ever held. An item ID is judged only against its session's content +// (SPEC 12.0), which no gated command reads on a failing workspace (13.3) — +// and the corruption would withhold anyway — so the probes must report the +// gate's findings whatever the ID: a product judging the ID before the gate +// (exit 2, unknown item) or opening the session to judge it (a corruption +// report) fails the exact-findings assertions below. +const T10_1_5_ITEM_ID = "no-such-item"; + +const T10_1_5 = defineProductTest({ + id: "T10.1-5", + title: + "failing workspace: gate precedence over corruption — a session created on a valid build is corrupted shape-independently (garbage bytes), then a source edited to fail build validation: `status`, `next`, `show`, `export`, `resolve` and `split` with an item ID no session held, and `review list` each report exactly the gate's findings as the form-exact findings report — the one staged 14.1, no condition-21 finding beside it — exit 1 and modify nothing, the corrupt session's bytes untouched (no session file is read; for `list` the gate's report replaces the per-session report whole), while `check` reports 14.21 concerning the session file together with the validation finding — the discriminating pair (SPEC 10.1, 10.7, 13.3, 14.21, 12.0)", + run: async (product) => { + await withWorkspace( + { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/A.mdx": A_MDX, + "specs/B.mdx": T10_1_5_B_VALID, + }, + async (workspace) => { + // --- Staging, in TEST-SPEC's order: session on a valid build, + // shape-independent corruption, then the invalidating source edit. + await buildOk(product, workspace, "T10.1-5 staging `build`"); + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", CORRUPT_NAME], + 0, + `T10.1-5 staging \`review create --strategy audit --name ${CORRUPT_NAME}\``, + ); + await workspace.file(sessionRel(CORRUPT_NAME), T10_1_5_GARBAGE); + const corruptBytes = await readSessionBytes( + workspace, + CORRUPT_NAME, + "T10.1-5 staging (the corrupted session file)", + ); + await workspace.file("specs/B.mdx", T10_1_5_B_INVALID); + + // --- The gate reference: `build` itself reports exactly the staged + // validation error — "the findings a `build` would now report" is + // what every gated probe below must reproduce (SPEC 13.3) — and the + // exact one-element count doubles as condition 21's not-by-build + // half: `build` reads no sessions (SPEC 14 condition 21). A failing + // build modifies nothing (SPEC 12.1). + const buildContext = "T10.1-5 `build --json` (the gate reference)"; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + ["build", "--json"], + 1, + `${buildContext} — the edited source fails build validation (SPEC 12.1, 14.1)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, buildContext), + buildContext, + ).findings; + assertConditionCounts( + findings, + { "14.1": 1 }, + `${buildContext} — exactly the staged validation error, and ` + + `never 14.21: \`build\` does not read sessions (SPEC 14 ` + + `condition 21)`, + ); + assertFindingLocated( + findings[0] as Finding, + { file: "specs/B.mdx" }, + `${buildContext} — the validation error identifies the broken source (SPEC 14)`, + ); + }, + `${buildContext} — a failing build modifies nothing (SPEC 12.1)`, + ); + + /** + * One gated probe (SPEC 13.3, 10.1): exit 1 with stdout the single + * form-exact findings report holding exactly the gate's findings — + * the staged 14.1 alone, so no condition-21 finding beside it — and + * nothing modified: sources, graph data, and the corrupt session's + * bytes byte-identical around the invocation. + */ + const probeGate = async ( + argv: readonly string[], + what: string, + ): Promise<void> => { + const context = `T10.1-5 ${what}`; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await runCli(product, workspace, argv); + assertExitCode( + result, + 1, + `${context} — on a workspace failing \`build\`'s ` + + `validations the gate's findings are reported and the ` + + `command exits 1; no session file is read, so the ` + + `corruption is not the outcome (SPEC 13.3, 10.1, 12.0)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, context), + context, + ).findings; + assertConditionCounts( + findings, + { "14.1": 1 }, + `${context} — exactly the gate's findings: the staged ` + + `validation error alone, no condition-21 finding beside ` + + `it (SPEC 13.3, 14.21)`, + ); + assertFindingLocated( + findings[0] as Finding, + { file: "specs/B.mdx" }, + `${context} — the gate's finding identifies the broken source (SPEC 14)`, + ); + }, + `${context} — nothing modified: sources, graph data, and the ` + + `corrupt session's bytes stay byte-identical (SPEC 13.3, 10.1)`, + ); + }; + + // Every `review` subcommand naming the session (TEST-SPEC's list). + await probeGate( + ["review", "status", CORRUPT_NAME, "--json"], + `\`review status ${CORRUPT_NAME} --json\``, + ); + await probeGate( + ["review", "next", CORRUPT_NAME, "--json"], + `\`review next ${CORRUPT_NAME} --json\``, + ); + await probeGate( + ["review", "show", CORRUPT_NAME, T10_1_5_ITEM_ID, "--json"], + `\`review show ${CORRUPT_NAME} ${T10_1_5_ITEM_ID} --json\``, + ); + await probeGate( + ["review", "export", CORRUPT_NAME, "--json"], + `\`review export ${CORRUPT_NAME} --json\``, + ); + await probeGate( + [ + "review", + "resolve", + CORRUPT_NAME, + T10_1_5_ITEM_ID, + "--status", + "updated", + "--json", + ], + `\`review resolve ${CORRUPT_NAME} ${T10_1_5_ITEM_ID} --status updated --json\``, + ); + await probeGate( + ["review", "split", CORRUPT_NAME, T10_1_5_ITEM_ID, "--json"], + `\`review split ${CORRUPT_NAME} ${T10_1_5_ITEM_ID} --json\``, + ); + // `review list`: the gate's report replaces the per-session report + // whole (SPEC 10.7) — realized by the same form-exact one-member + // decode, which no session-row-carrying document passes. + await probeGate(["review", "list", "--json"], "`review list --json`"); + + // --- The discriminating pair's other half: `check` reports 14.21 + // together with the validation findings (SPEC 14 condition 21: + // beside a failing workspace's other findings; 12.2). + // Presence-based beside the two staged conditions: with invalid + // sources, the detectability of staleness findings (14.10) beside + // them is T14-4's reporter-matrix business (the T13.3-3 precedent). + const checkContext = "T10.1-5 `check --json`"; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${checkContext} — the workspace carries findings (SPEC 12.2)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, checkContext), + checkContext, + ).findings; + if ( + !findings.some( + (finding) => + finding.condition === "14.1" && + finding.locations.some( + (location) => location.file === "specs/B.mdx", + ), + ) + ) { + fail( + `${checkContext}: the staged validation error (14.1 in ` + + `specs/B.mdx) must be reported (SPEC 12.2, 14.1); got ` + + JSON.stringify( + findings.map((finding) => ({ + condition: finding.condition, + locations: finding.locations, + })), + ), + ); + } + const corrupt = findings.filter( + (finding) => finding.condition === "14.21", + ); + if (corrupt.length === 0) { + fail( + `${checkContext}: \`check\` must report 14.21 together ` + + `with the validation findings — beside a failing ` + + `workspace's other findings, the discriminating half ` + + `against a product dropping 14.21 on the failing side ` + + `(SPEC 14 condition 21, 12.2); reported conditions: ` + + JSON.stringify(findings.map((finding) => finding.condition)), + ); + } + if ( + !corrupt.some( + (finding) => finding.path === sessionRel(CORRUPT_NAME), + ) + ) { + fail( + `${checkContext}: the 14.21 finding carries the corrupt ` + + `session file it concerns, ${sessionRel(CORRUPT_NAME)}, ` + + `as its 12.7 path member (SPEC 14: session conditions ` + + `carry the file they concern); got paths ` + + JSON.stringify(corrupt.map((finding) => finding.path)), + ); + } + }, + `${checkContext} — \`check\` never writes (SPEC 12.2, 13.3)`, + ); + + // --- Pointed restatement of "the corrupt session's bytes + // untouched" across the whole sweep (each probe's whole-root + // compare already covers its own invocation). + assertBytesEqual( + await readSessionBytes( + workspace, + CORRUPT_NAME, + "T10.1-5 (after every probe)", + ), + corruptBytes, + "T10.1-5: the corrupt session's bytes are untouched by the whole " + + "probe sweep — no session file is read or written on a failing " + + "workspace (SPEC 13.3, 10.1)", + ); + }, + ); + }, +}); + /** TEST-SPEC §10.1, in canonical ID order (SUITE-33). */ export const section101Tests: readonly ProductTestEntry[] = [ T10_1_1, T10_1_2, T10_1_3, T10_1_4, + T10_1_5, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index b41818f..3f915f1 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -258,6 +258,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T10.1-2": ["10.1"], "T10.1-3": ["10.1"], "T10.1-4": ["10.1", "14"], + "T10.1-5": ["10.1", "14"], "T10.2-1": ["10.2"], "T10.2-2": ["10.2"], "T10.2-3": ["10.2"], From 46168b73ad61103a766784d2898b758ab6cc5db9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 22:04:26 +0000 Subject: [PATCH 095/226] sdg(phase-9): implement T11.2-1's parse-local availability suite (FP-050) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 51 +- test/helpers/adapters/forms.ts | 396 ++++++++++++++++ test/helpers/adapters/model.ts | 91 ++++ test/self/s5-output-adapters.test.ts | 470 +++++++++++++++++- test/suite/registry/index.ts | 2 + test/suite/registry/section-11.2.ts | 679 +++++++++++++++++++++++++++ test/suite/registry/traceability.ts | 1 + test/suite/section-11.2.test.ts | 9 + 8 files changed, 1697 insertions(+), 2 deletions(-) create mode 100644 test/suite/registry/section-11.2.ts create mode 100644 test/suite/section-11.2.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 72696e5..68853a0 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2114,9 +2114,58 @@ certify against FP-091's fixtures once those land. mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-050 — Implement T11.2-1: parse-local structure and per-file +- [x] FP-050 — Implement T11.2-1: parse-local structure and per-file masking with no writes. [R2 #2; TEST-SPEC §11.2] New registry module(s) for §11.2 (+ suite wrapper, index import); map `"11.2"`. + [Done 2026-08-13: registered in the new registry module section-11.2.ts + (SUITE-52; wrapper + index spread) with traceability ["11.2"] (T11.2-1 in + no TEST-SPEC 14 staging record — the FP-016 precedent; no certification + scope — CERTIFICATIONS.md's Exclusions carry only its answer-side no-write + compares through the machinery VIOL-CORE-CHATTYREADS certifies). One + workspace: A parseable with findings of both levels — `d={"nosuch"}` + (14.5) and the `d={"top"}` self-cycle (14.9), the latter's spelling + RESOLVING and recording its depends occurrence (positions survive + findings, 11.2/5.7) — beside the dup pair (14.3), one-segment `ha#sh` + (14.4), unknown prop (14.17), and `<div>` (14.16); B unparseable (14.20); + C finding-free; a multi-byte prefix shifts every A offset (1.7). The + `build --json` gate reference pins staging integrity: exit 1, exactly + {14.3,14.4,14.5,14.9,14.16,14.17 ×1 located in A; 14.20 ×1 in B} + (file-granular; range precision is T14-8's). Bare `view` (JSON-only: + entire stdout one document, no `--json`) exits 1 with the same finding + multiset — B's 14.20 accompanying — views exactly [A, C] (B contributes + no view): A's full positional tree byte-exact over the pinned projection + (identity three-state per 11.2 — dup bearers and ha#sh unavailable, + top/top.kid/gone/solo defined; construct ranges; raw attribute entries + name/range/text, unknown prop included; the div gets no node), comment + ranges, and the complete two-record occurrence enumeration (5.7 spans: + the d string literal; the whole embedding container); C's view complete, + empty lists as []. Tag-range decompositions and interpreted tags/coverage + stay outside the projection (T11.4-1, T11.2-2/T11.4-3 homes — the form + decode still validates their forms). Failing-side `occurrences` (same + findings, same records, exit 1) and `at C 0` (finding-free, exit 0, root + resolution — the per-file domain contrast) ride per T11.2-6's stated + delegation; every invocation (gate build included) sits in a whole-root + assertLeavesUnchanged compare — never-built workspace, so any graph-data + or derived write surfaces. New form-exact machinery: full + `decodeViewReport` in adapters/forms.ts (node/attribute/import/ + occurrence/comment forms, `--text` conditional presence, identity/text + never null, attribute text byteLength = range length, document orders and + per-file record-file equality enforced) with ViewNode/FileView/ViewReport + model types and COVERAGE_ATTRIBUTE_VALUES; S-5 gains two DECODERS entries + (with/without `--text`) — 86/86 green. Verified: typecheck/format clean; + suite section-11.2 red-as-diagnosed at the first arm's FP-001-class + form-exact decode ("expected no member \"condition\"" on the gate build; + view/occurrences/at are unknown commands on this pre-patch product — + further diagnosed exit failures once that closes); a direct probe of the + staged fixture against the built product returned exactly the expected + old-shape condition multiset; a scratch conforming fake product (deriving + every range independently from the workspace bytes) ran the registered + body green end-to-end, and three deviation fakes (cycle occurrence + dropped; masked B served a view entry; view writing graph data) each + failed diagnosed — expectations satisfiable, assertions toothed. `npm run + test:self`: 4 planned mid-loop reds (certification-document ×3 → FP-091; + S-1's unmapped keys narrowed 7 → 6, now {11.3, 11.4, 11.5, 11.6, 12.6, + 12.7} → stage G), S-5 and certification green.] - [ ] FP-051 — Implement T11.2-2 (CONF-AVAIL): spelled-identity / interpreted-data definedness matrix. [R2 #2, R3 gap 1, VERIFY; TEST-SPEC §11.2] diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts index 7b8751c..a3c0779 100644 --- a/test/helpers/adapters/forms.ts +++ b/test/helpers/adapters/forms.ts @@ -27,6 +27,9 @@ // - the at document {"findings","resolution"} (11.5) // - the scoped view decode: top level {"findings","views"} and each // per-file wrapper's form with its `file` member (11.4) +// - the full view decode: per-file positional trees with node, attribute, +// import, occurrence, and comment forms, `--text` conditional presence +// (11.4, T11.2-1, T11.4-*) // - the rename/move preview document {"findings","mapping","files","delta"} // (6.6) with the ten edit classes and the pinned orders // - the unavailability-marker structural walk T12.7-1 relies on: no object @@ -39,6 +42,7 @@ import type { AtResolution, AtSection, ErrorDocument, + FileView, Finding, FindingLocation, FindingsReport, @@ -54,10 +58,15 @@ import type { PreviewFileEntry, PreviewReport, SourceRange, + ViewAttributeEntry, ViewFilesReport, + ViewImportEntry, + ViewNode, + ViewReport, } from "./model.js"; import { CONDITION_CODE_TOKENS, + COVERAGE_ATTRIBUTE_VALUES, DEPENDENCY_EDGE_KINDS, PREVIEW_EDIT_CLASSES, REFUSAL_CODE_TOKENS, @@ -71,6 +80,7 @@ import { expectNonEmptyString, expectNonNegativeInteger, expectObject, + expectString, expectToken, requiredKey, requiredMember, @@ -874,6 +884,392 @@ export function decodeViewFilesReport( return { findings, files }; } +// --- the full view decode (11.4, 12.7) ---------------------------------------- + +const VIEW_NODE_MEMBERS = [ + "identity", + "range", + "opening", + "closing", + "attributes", + "tags", + "coverage", + "children", +] as const; +const VIEW_NODE_TEXT_MEMBERS = ["ownText", "subtreeText"] as const; + +/** A tag-range member: a range form or `null` where none exists (11.4). */ +function decodeTagRangeMember( + value: unknown, + site: DecodeSite, +): SourceRange | null { + if (value === undefined) { + formFail( + site, + "a present member: null is never omission (SPEC 12.7)", + value, + ); + } + return value === null ? null : decodeRangeForm(value, site); +} + +/** One attribute entry: `{"name", "range", "text"}` exactly (11.4, 12.7). */ +function decodeViewAttributeEntry( + value: unknown, + site: DecodeSite, +): ViewAttributeEntry { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["name", "range", "text"], site); + const nameValue = requiredMember(obj, "name", site); + const range = decodeRangeForm( + requiredKey(obj, "range", site), + at(site, "range"), + ); + const text = expectNonEmptyString( + requiredKey(obj, "text", site), + at(site, "text"), + ); + if (Buffer.byteLength(text, "utf8") !== range.end - range.start) { + formFail( + at(site, "text"), + "the attribute's own characters — the source text's byte length " + + "equals its range's length (SPEC 11.4, 1.7)", + value, + ); + } + return { + name: + nameValue === null + ? null + : expectNonEmptyString(nameValue, at(site, "name")), + range, + text, + }; +} + +/** A text-member datum: a plain string or the marker, never `null` (11.2). */ +function decodeViewTextMember( + value: unknown, + site: DecodeSite, +): string | { readonly unavailable: true } { + const datum = decodeDatum(value, site, expectString); + if (datum.state === "null") { + formFail( + site, + "an own/subtree text value — a plain string, or the unavailability " + + "marker where 11.2 leaves the whole value undefined, never null " + + "(SPEC 11.2, 11.4, 12.7)", + null, + ); + } + return datum.state === "value" ? datum.value : { unavailable: true as const }; +} + +/** + * One node of the positional section tree in the literal 12.7 form: + * `{"identity", "range", "opening", "closing", "attributes", "tags", + * "coverage", "children"}` plus `"ownText"`/`"subtreeText"` exactly when + * `--text` is given (the stated conditional presence — absent without the + * flag, both present with it). `identity` is a plain identity string or the + * unavailability marker, never `null` (11.2 defines no structural absence + * for it); `tags`/`coverage` are three-state datums (a root's stated `null`, + * 11.4); `attributes` entries are in tag order and `children` in document + * order — both strictly ascending by range start (distinct constructs occupy + * distinct spans). + */ +function decodeViewNodeForm( + value: unknown, + site: DecodeSite, + text: boolean, +): ViewNode { + const obj = expectObject(value, site); + const allowed = text + ? [...VIEW_NODE_MEMBERS, ...VIEW_NODE_TEXT_MEMBERS] + : [...VIEW_NODE_MEMBERS]; + expectOnlyMembers(obj, allowed, site); + + const identitySite = at(site, "identity"); + const identityDatum = decodeDatum( + obj["identity"], + identitySite, + expectNonEmptyString, + ); + if (identityDatum.state === "null") { + formFail( + identitySite, + "the node's identity — a plain identity string, or the unavailability " + + "marker where 11.2 leaves it undefined, never null (SPEC 11.2, " + + "11.4, 12.7)", + null, + ); + } + + const range = decodeRangeForm( + requiredKey(obj, "range", site), + at(site, "range"), + ); + const opening = decodeTagRangeMember(obj["opening"], at(site, "opening")); + const closing = decodeTagRangeMember(obj["closing"], at(site, "closing")); + + const attributesSite = at(site, "attributes"); + const attributes = expectArray( + requiredKey(obj, "attributes", site), + attributesSite, + ).map((element, index) => + decodeViewAttributeEntry(element, at(attributesSite, index)), + ); + for (let i = 1; i < attributes.length; i += 1) { + if (attributes[i - 1]!.range.start >= attributes[i]!.range.start) { + formFail( + at(attributesSite, i), + "one entry per spelled attribute in tag order — ranges strictly " + + "ascending (SPEC 11.4, 12.7)", + obj["attributes"], + ); + } + } + + const tagsDatum = decodeDatum( + obj["tags"], + at(site, "tags"), + (tagsValue, tagsSite) => + expectArray(tagsValue, tagsSite).map((element, index) => + expectNonEmptyString(element, at(tagsSite, index)), + ), + ); + const coverageDatum = decodeDatum( + obj["coverage"], + at(site, "coverage"), + (coverageValue, coverageSite) => + expectToken(coverageValue, COVERAGE_ATTRIBUTE_VALUES, coverageSite), + ); + + const childrenSite = at(site, "children"); + const children = expectArray( + requiredKey(obj, "children", site), + childrenSite, + ).map((element, index) => + decodeViewNodeForm(element, at(childrenSite, index), text), + ); + for (let i = 1; i < children.length; i += 1) { + if (children[i - 1]!.range.start >= children[i]!.range.start) { + formFail( + at(childrenSite, i), + "child nodes in document order — construct ranges strictly " + + "ascending by start (SPEC 11.4, 12.7)", + obj["children"], + ); + } + } + + const node: { + identity: ViewNode["identity"]; + range: SourceRange; + opening: SourceRange | null; + closing: SourceRange | null; + attributes: ViewAttributeEntry[]; + tags: ViewNode["tags"]; + coverage: ViewNode["coverage"]; + children: ViewNode[]; + ownText?: ViewNode["ownText"]; + subtreeText?: ViewNode["subtreeText"]; + } = { + identity: + identityDatum.state === "value" + ? identityDatum.value + : { unavailable: true as const }, + range, + opening, + closing, + attributes, + tags: + tagsDatum.state === "value" + ? tagsDatum.value + : tagsDatum.state === "null" + ? null + : { unavailable: true as const }, + coverage: + coverageDatum.state === "value" + ? coverageDatum.value + : coverageDatum.state === "null" + ? null + : { unavailable: true as const }, + children, + }; + if (text) { + node.ownText = decodeViewTextMember(obj["ownText"], at(site, "ownText")); + node.subtreeText = decodeViewTextMember( + obj["subtreeText"], + at(site, "subtreeText"), + ); + } + return node; +} + +/** One import entry: `{"range", "name", "target"}` exactly (11.4, 12.7). */ +function decodeViewImportEntry( + value: unknown, + site: DecodeSite, +): ViewImportEntry { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["range", "name", "target"], site); + const nameValue = requiredMember(obj, "name", site); + const targetSite = at(site, "target"); + const targetDatum = decodeDatum(obj["target"], targetSite, decodePathValue); + if (targetDatum.state === "null") { + formFail( + targetSite, + "the import's resolved target — a path value where specifier form " + + "and discovery define one, the unavailability marker otherwise, " + + "never null (SPEC 11.4, 11.2, 12.7)", + null, + ); + } + return { + range: decodeRangeForm(requiredKey(obj, "range", site), at(site, "range")), + name: + nameValue === null + ? null + : expectNonEmptyString(nameValue, at(site, "name")), + target: + targetDatum.state === "value" + ? targetDatum.value + : { unavailable: true as const }, + }; +} + +/** + * The full `view` document (SPEC 11.4) — `{"findings", "views"}` exactly, + * each per-file view `{"file", "root", "imports", "occurrences", "comments"}` + * exactly, decoded in the literal 12.7 forms (H-3: form-exact, never + * adjustable to a product's shape). `text` states whether the invocation + * carried `--text`: the node text members must be present exactly then + * (12.7's stated conditional presence). Enforced orders: per-file views by + * file path bytes, strictly ascending (the requested files form a set, + * 11.4); per file, imports and comments in document order and occurrence + * records in document order over distinct spans (5.7), each record's `file` + * equal to the view's file (11.4: the FILE's occurrence records). + */ +export function decodeViewReport( + doc: unknown, + options: { readonly text: boolean }, + context?: string, +): ViewReport { + const site = rootSite("12.7 view document", context); + const obj = expectObject(doc, site); + expectOnlyMembers(obj, ["findings", "views"], site); + const findings = decodeFindingsArray( + requiredKey(obj, "findings", site), + at(site, "findings"), + ); + const viewsSite = at(site, "views"); + const views = expectArray(requiredKey(obj, "views", site), viewsSite).map( + (element, index): FileView => { + const entrySite = at(viewsSite, index); + const entry = expectObject(element, entrySite); + expectOnlyMembers(entry, VIEW_FILE_ENTRY_MEMBERS, entrySite); + const file = decodePathValue( + requiredKey(entry, "file", entrySite), + at(entrySite, "file"), + ); + const root = decodeViewNodeForm( + requiredKey(entry, "root", entrySite), + at(entrySite, "root"), + options.text, + ); + const importsSite = at(entrySite, "imports"); + const imports = expectArray( + requiredKey(entry, "imports", entrySite), + importsSite, + ).map((importValue, importIndex) => + decodeViewImportEntry(importValue, at(importsSite, importIndex)), + ); + for (let i = 1; i < imports.length; i += 1) { + if (imports[i - 1]!.range.start >= imports[i]!.range.start) { + formFail( + at(importsSite, i), + "import declarations in document order — ranges strictly " + + "ascending by start (SPEC 11.4, 12.7)", + entry["imports"], + ); + } + } + const occurrencesSite = at(entrySite, "occurrences"); + const occurrences = expectArray( + requiredKey(entry, "occurrences", entrySite), + occurrencesSite, + ).map((recordValue, recordIndex) => { + const recordSite = at(occurrencesSite, recordIndex); + const record = decodeOccurrenceRecordForm(recordValue, recordSite); + if ( + Buffer.compare(pathValueBytes(record.file), pathValueBytes(file)) !== + 0 + ) { + formFail( + at(recordSite, "file"), + `the viewed file's own occurrence records — each record's file ` + + `equals the view's file (SPEC 11.4, 12.7); the view is of ` + + `${JSON.stringify(renderPathValue(file))}`, + recordValue, + ); + } + return record; + }); + for (let i = 1; i < occurrences.length; i += 1) { + const previous = occurrences[i - 1]!; + const current = occurrences[i]!; + const ordered = + previous.range.start < current.range.start || + (previous.range.start === current.range.start && + previous.range.end < current.range.end); + if (!ordered) { + formFail( + at(occurrencesSite, i), + "occurrence records in document order over distinct spans — " + + "(start, end) strictly ascending (SPEC 5.7, 11.4, 12.7)", + entry["occurrences"], + ); + } + } + const commentsSite = at(entrySite, "comments"); + const comments = expectArray( + requiredKey(entry, "comments", entrySite), + commentsSite, + ).map((commentValue, commentIndex) => + decodeRangeForm(commentValue, at(commentsSite, commentIndex)), + ); + for (let i = 1; i < comments.length; i += 1) { + if (comments[i - 1]!.start >= comments[i]!.start) { + formFail( + at(commentsSite, i), + "comment ranges in document order — strictly ascending by " + + "start (SPEC 11.4, 12.7)", + entry["comments"], + ); + } + } + return { file, root, imports, occurrences, comments }; + }, + ); + for (let i = 1; i < views.length; i += 1) { + if ( + Buffer.compare( + pathValueBytes(views[i - 1]!.file), + pathValueBytes(views[i]!.file), + ) >= 0 + ) { + formFail( + at(viewsSite, i), + "per-file views ordered by byte order of workspace-relative path — " + + "the requested files form a set, so the order is strict " + + "(SPEC 11.4, 12.7)", + obj["views"], + ); + } + } + return { findings, views }; +} + // --- the rename/move preview document (6.6, 12.7) ----------------------------- /** One `mapping` entry: `{"from", "to"}` exactly, identities are strings. */ diff --git a/test/helpers/adapters/model.ts b/test/helpers/adapters/model.ts index 002e274..eb9b108 100644 --- a/test/helpers/adapters/model.ts +++ b/test/helpers/adapters/model.ts @@ -387,6 +387,97 @@ export interface ViewFilesReport { readonly files: readonly PathValue[]; } +/** + * The interpreted coverage attribute's defined values (SPEC.md 2.5): a view + * node's `coverage` member, where it is a plain value, is one of these — any + * other spelled value leaves the interpreted datum unavailable (11.2), so no + * other plain value exists. + */ +export const COVERAGE_ATTRIBUTE_VALUES = ["required", "none"] as const; +export type CoverageAttributeValue = (typeof COVERAGE_ATTRIBUTE_VALUES)[number]; + +/** + * One raw attribute entry of a view node — `{"name", "range", "text"}` + * exactly (SPEC.md 11.4, 12.7): the attribute's name as spelled (`null` for a + * spread attribute), its source range, and its source text — for a named + * attribute its name through the last character of its value or the bare name + * where it spells no value, for a spread attribute its entire braced + * construct. One entry per attribute the tag spells, in tag order; inclusion + * is by form (repeated, unknown, and spread attributes included). + */ +export interface ViewAttributeEntry { + readonly name: string | null; + readonly range: SourceRange; + readonly text: string; +} + +/** + * One node of a view's positional section tree — `{"identity", "range", + * "opening", "closing", "attributes", "tags", "coverage", "children"}` plus, + * exactly when `--text` is given, `"ownText"` and `"subtreeText"` (SPEC.md + * 11.4, 12.7). `identity` is defined or explicitly unavailable per 11.2 — + * never `null` (no passage defines structural absence for it); `tags` and + * `coverage` are each a plain value, the stated `null` (a root's structural + * absence, 11.4), or unavailable; the text members are each a plain string or + * unavailable (whole-value poisoning, 11.2). `opening`/`closing` are the tag + * ranges, `null` where none exists (self-closing: no closing; root: neither). + */ +export interface ViewNode { + readonly identity: string | { readonly unavailable: true }; + readonly range: SourceRange; + readonly opening: SourceRange | null; + readonly closing: SourceRange | null; + readonly attributes: readonly ViewAttributeEntry[]; + readonly tags: readonly string[] | null | { readonly unavailable: true }; + readonly coverage: + CoverageAttributeValue | null | { readonly unavailable: true }; + readonly children: readonly ViewNode[]; + /** Present exactly when the invocation carried `--text` (12.7). */ + readonly ownText?: string | { readonly unavailable: true }; + /** Present exactly when the invocation carried `--text` (12.7). */ + readonly subtreeText?: string | { readonly unavailable: true }; +} + +/** + * One import declaration of a per-file view — `{"range", "name", "target"}` + * exactly (SPEC.md 11.4, 12.7): its source range; its default binding's + * identifier, `null` where the declaration binds no default (the side-effect- + * only, named-only, and namespace-only forms — structural absence, never + * unavailability); and its resolved target file where specifier form and + * discovery define one, explicitly unavailable otherwise — never `null`. + */ +export interface ViewImportEntry { + readonly range: SourceRange; + readonly name: string | null; + readonly target: PathValue | { readonly unavailable: true }; +} + +/** + * One parseable requested file's view — `{"file", "root", "imports", + * "occurrences", "comments"}` exactly (SPEC.md 11.4, 12.7): the file (a 12.7 + * path value), the root node of the positional section tree, every import + * declaration in document order, the file's occurrence records in document + * order, and every MDX comment's source range in document order. + */ +export interface FileView { + readonly file: PathValue; + readonly root: ViewNode; + readonly imports: readonly ViewImportEntry[]; + readonly occurrences: readonly OccurrenceRecord[]; + readonly comments: readonly SourceRange[]; +} + +/** + * The full `view` document (SPEC.md 11.4) — `{"findings", "views"}` exactly + * (12.7): the consulted domain's findings, and one per-file view per + * parseable requested file, ordered by byte order of workspace-relative path + * (an unparseable requested file contributes no entry). + */ +export interface ViewReport { + readonly findings: readonly Finding[]; + readonly views: readonly FileView[]; +} + /** `coverage` (T8.2-1): all profiles by default, one when named. */ export interface CoverageReport { readonly profiles: readonly CoverageProfileReport[]; diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index ce86f9d..ba3bb67 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -20,7 +20,7 @@ import { Buffer } from "node:buffer"; import { expect, onTestFinished, test } from "vitest"; import { HarnessAssertionError } from "../helpers/assertions.js"; import type { RunResult } from "../helpers/subprocess.js"; -import type { Finding } from "../helpers/adapters/index.js"; +import type { Finding, ViewReport } from "../helpers/adapters/index.js"; import { GRAPH_DATA_AREA_PATH, ITEM_STATUSES, @@ -62,6 +62,7 @@ import { decodeSessionListReport, decodeSessionStatusReport, decodeViewFilesReport, + decodeViewReport, expectNonNegativeInteger, isGraphDataKey, rootSite, @@ -364,6 +365,144 @@ const GOOD_VIEWS = { ], }; +// The FULL view decode (11.4, 12.7; decodeViewReport): one per-file view +// carrying a complete positional tree — root with the stated-`null` +// tags/coverage, a paired child with a named and a spread attribute, a +// self-closing child with identity/tags unavailable — imports in both target +// states, the file's own occurrence records in document order, and comment +// ranges. Attribute text lengths equal their ranges (the decoder's 1.7 +// invariant). Without `--text` the node text members are absent (the stated +// conditional presence); GOOD_VIEW_FULL_TEXT is the `--text` twin. +const GOOD_VIEW_FULL = { + findings: [], + views: [ + { + file: "specs/A.mdx", + root: { + identity: "specs/A.mdx", + range: { start: 0, end: 200 }, + opening: null, + closing: null, + attributes: [], + tags: null, + coverage: null, + children: [ + { + identity: "specs/A.mdx#login", + range: { start: 40, end: 120 }, + opening: { start: 40, end: 62 }, + closing: { start: 116, end: 120 }, + attributes: [ + { + name: "id", + range: { start: 43, end: 53 }, + text: 'id="login"', + }, + { name: null, range: { start: 54, end: 60 }, text: "{...p}" }, + ], + tags: ["auth", "v2"], + coverage: "required", + children: [], + }, + { + identity: { unavailable: true }, + range: { start: 130, end: 146 }, + opening: { start: 130, end: 146 }, + closing: null, + attributes: [ + { + name: "id", + range: { start: 133, end: 142 }, + text: 'id="du.p"', + }, + ], + tags: { unavailable: true }, + coverage: "none", + children: [], + }, + ], + }, + imports: [ + { range: { start: 0, end: 31 }, name: "BASE", target: "specs/B.mdx" }, + { + range: { start: 32, end: 39 }, + name: null, + target: { unavailable: true }, + }, + ], + occurrences: [ + { + file: "specs/A.mdx", + range: { start: 70, end: 84 }, + kind: "embeds", + source: { + identity: "specs/A.mdx#login", + range: { start: 40, end: 120 }, + }, + target: "specs/B.mdx#base", + }, + { + file: "specs/A.mdx", + range: { start: 90, end: 104 }, + kind: "depends", + source: { unavailable: true }, + target: "specs/B.mdx#base", + }, + ], + comments: [ + { start: 150, end: 170 }, + { start: 175, end: 195 }, + ], + }, + ], +}; + +// The `--text` twin: every node additionally carries ownText/subtreeText — +// a plain string (empty legitimate: an empty leaf, SPEC 1.1) or the +// unavailability marker (whole-value poisoning, 11.2), never `null`. +const GOOD_VIEW_FULL_TEXT = { + findings: [], + views: [ + { + file: "specs/A.mdx", + root: { + identity: "specs/A.mdx", + range: { start: 0, end: 100 }, + opening: null, + closing: null, + attributes: [], + tags: null, + coverage: null, + children: [ + { + identity: "specs/A.mdx#login", + range: { start: 10, end: 90 }, + opening: { start: 10, end: 24 }, + closing: { start: 86, end: 90 }, + attributes: [ + { + name: "id", + range: { start: 13, end: 23 }, + text: 'id="login"', + }, + ], + tags: [], + coverage: "required", + children: [], + ownText: "", + subtreeText: { unavailable: true }, + }, + ], + ownText: "Prose.\n", + subtreeText: { unavailable: true }, + }, + imports: [], + occurrences: [], + comments: [], + }, + ], +}; + const GOOD_COVERAGE = { profiles: [ { @@ -1708,6 +1847,335 @@ const DECODERS: readonly DecoderSpec[] = [ }, ], }, + { + name: "12.7 view document (full)", + decode: (doc: unknown) => decodeViewReport(doc, { text: false }), + good: GOOD_VIEW_FULL, + verify: (decoded: ViewReport) => { + expect(decoded.findings).toEqual([]); + expect(decoded.views).toHaveLength(1); + const view = decoded.views[0]!; + expect(view.file).toBe("specs/A.mdx"); + // The tree decodes literally: root with the stated-null tags/coverage + // and no tag ranges; the paired child with both tag ranges, the named + // and the spread attribute entry; the self-closing child with + // identity/tags as the one-datum unavailability state (11.2, 12.7). + expect(view.root.identity).toBe("specs/A.mdx"); + expect(view.root.tags).toBeNull(); + expect(view.root.coverage).toBeNull(); + expect(view.root.opening).toBeNull(); + expect(view.root.attributes).toEqual([]); + expect(view.root.children).toHaveLength(2); + const paired = view.root.children[0]!; + expect(paired.identity).toBe("specs/A.mdx#login"); + expect(paired.opening).toEqual({ start: 40, end: 62 }); + expect(paired.closing).toEqual({ start: 116, end: 120 }); + expect(paired.attributes).toEqual([ + { name: "id", range: { start: 43, end: 53 }, text: 'id="login"' }, + { name: null, range: { start: 54, end: 60 }, text: "{...p}" }, + ]); + expect(paired.tags).toEqual(["auth", "v2"]); + expect(paired.coverage).toBe("required"); + // Without --text the text members are absent (12.7's stated + // conditional presence), never defaulted in. + expect("ownText" in paired).toBe(false); + expect("subtreeText" in paired).toBe(false); + const selfClosing = view.root.children[1]!; + expect(selfClosing.identity).toEqual({ unavailable: true }); + expect(selfClosing.closing).toBeNull(); + expect(selfClosing.tags).toEqual({ unavailable: true }); + // Imports decode in both target states; the file's occurrence records + // and comment ranges decode literally. + expect(view.imports).toHaveLength(2); + expect(view.imports[0]!.name).toBe("BASE"); + expect(view.imports[0]!.target).toBe("specs/B.mdx"); + expect(view.imports[1]!.name).toBeNull(); + expect(view.imports[1]!.target).toEqual({ unavailable: true }); + expect(view.occurrences).toHaveLength(2); + expect(view.occurrences[0]!.kind).toBe("embeds"); + expect(view.occurrences[1]!.source).toEqual({ unavailable: true }); + expect(view.comments).toEqual([ + { start: 150, end: 170 }, + { start: 175, end: 195 }, + ]); + }, + alsoGood: [ + { + label: + "an empty request with findings accompanying (a masked domain: " + + "every requested file unparseable contributes no entry, 11.4)", + doc: { + findings: [structuredClone(GOOD_FINDINGS.findings[0])], + views: [], + }, + verify: (decoded: ViewReport): void => { + expect(decoded.findings).toHaveLength(1); + expect(decoded.views).toEqual([]); + }, + }, + ], + bad: [ + { + label: "ownText present without --text (12.7 conditional presence)", + doc: put(GOOD_VIEW_FULL, "x", "views", 0, "root", "ownText"), + }, + { + label: "node missing its identity member", + doc: omit(GOOD_VIEW_FULL, "views", 0, "root", "identity"), + }, + { + label: + "null node identity (defined or explicitly unavailable, never " + + "null — SPEC 11.2, 12.7)", + doc: put(GOOD_VIEW_FULL, null, "views", 0, "root", "identity"), + }, + { + label: + "a widened unavailability marker as a node identity (12.7: the " + + 'marker is exactly {"unavailable": true})', + doc: put( + GOOD_VIEW_FULL, + { unavailable: true, id: "x" }, + "views", + 0, + "root", + "children", + 1, + "identity", + ), + }, + { + label: "node missing its range", + doc: omit(GOOD_VIEW_FULL, "views", 0, "root", "range"), + }, + { + label: "node missing its opening member (null is never omission)", + doc: omit(GOOD_VIEW_FULL, "views", 0, "root", "opening"), + }, + { + label: "node missing its attributes member", + doc: omit(GOOD_VIEW_FULL, "views", 0, "root", "attributes"), + }, + { + label: "null attributes (a root's empty list is [], SPEC 12.7)", + doc: put(GOOD_VIEW_FULL, null, "views", 0, "root", "attributes"), + }, + { + label: "an extra member on a node", + doc: put(GOOD_VIEW_FULL, 1, "views", 0, "root", "note"), + }, + { + label: "attribute entry missing its text", + doc: omit( + GOOD_VIEW_FULL, + "views", + 0, + "root", + "children", + 0, + "attributes", + 0, + "text", + ), + }, + { + label: + "attribute text whose byte length differs from its range " + + "(11.4: the attribute's own characters)", + doc: put( + GOOD_VIEW_FULL, + 'id="log"', + "views", + 0, + "root", + "children", + 0, + "attributes", + 0, + "text", + ), + }, + { + label: "an extra member on an attribute entry", + doc: put( + GOOD_VIEW_FULL, + true, + "views", + 0, + "root", + "children", + 0, + "attributes", + 0, + "spread", + ), + }, + { + label: "non-string attribute name (null only for a spread)", + doc: put( + GOOD_VIEW_FULL, + 7, + "views", + 0, + "root", + "children", + 0, + "attributes", + 0, + "name", + ), + }, + { + label: "a non-string tag element", + doc: put( + GOOD_VIEW_FULL, + [3], + "views", + 0, + "root", + "children", + 0, + "tags", + ), + }, + { + label: + 'coverage outside the defined values ("required"/"none" — an ' + + "invalid-valued prop is the unavailability marker instead, 11.2)", + doc: put( + GOOD_VIEW_FULL, + "optional", + "views", + 0, + "root", + "children", + 0, + "coverage", + ), + }, + { + label: "node missing its children member", + doc: omit(GOOD_VIEW_FULL, "views", 0, "root", "children"), + }, + { + label: "children out of document order (SPEC 11.4)", + doc: put( + GOOD_VIEW_FULL, + [ + structuredClone(GOOD_VIEW_FULL.views[0]!.root.children[1]), + structuredClone(GOOD_VIEW_FULL.views[0]!.root.children[0]), + ], + "views", + 0, + "root", + "children", + ), + }, + { + label: + "an occurrence record whose file differs from the view's file " + + "(11.4: the file's own occurrence records)", + doc: put( + GOOD_VIEW_FULL, + "specs/Z.mdx", + "views", + 0, + "occurrences", + 0, + "file", + ), + }, + { + label: "occurrence records out of document order (SPEC 5.7, 11.4)", + doc: put( + GOOD_VIEW_FULL, + [ + structuredClone(GOOD_VIEW_FULL.views[0]!.occurrences[1]), + structuredClone(GOOD_VIEW_FULL.views[0]!.occurrences[0]), + ], + "views", + 0, + "occurrences", + ), + }, + { + label: "comment ranges out of document order (SPEC 11.4)", + doc: put( + GOOD_VIEW_FULL, + [ + structuredClone(GOOD_VIEW_FULL.views[0]!.comments[1]), + structuredClone(GOOD_VIEW_FULL.views[0]!.comments[0]), + ], + "views", + 0, + "comments", + ), + }, + { + label: "import entry missing its name member (null is never omission)", + doc: omit(GOOD_VIEW_FULL, "views", 0, "imports", 0, "name"), + }, + { + label: + "null import target (a path value or the unavailability marker, " + + "never null — SPEC 11.4, 12.7)", + doc: put(GOOD_VIEW_FULL, null, "views", 0, "imports", 1, "target"), + }, + ], + }, + { + name: "12.7 view document (full, --text)", + decode: (doc: unknown) => decodeViewReport(doc, { text: true }), + good: GOOD_VIEW_FULL_TEXT, + verify: (decoded: ViewReport) => { + const root = decoded.views[0]!.root; + // With --text both text members are present per node: plain strings + // (empty legitimate) and the marker decode as distinct states, + // never collapsed (11.2, 12.7). + expect(root.ownText).toBe("Prose.\n"); + expect(root.subtreeText).toEqual({ unavailable: true }); + const child = root.children[0]!; + expect(child.ownText).toBe(""); + expect(child.subtreeText).toEqual({ unavailable: true }); + }, + bad: [ + { + label: + "text members absent under --text (12.7 conditional presence: " + + "present exactly when the flag is given)", + doc: structuredClone(GOOD_VIEW_FULL), + }, + { + label: "node missing its subtreeText under --text", + doc: omit( + GOOD_VIEW_FULL_TEXT, + "views", + 0, + "root", + "children", + 0, + "subtreeText", + ), + }, + { + label: + "null ownText (a plain string or the unavailability marker, " + + "never null — SPEC 11.2, 12.7)", + doc: put(GOOD_VIEW_FULL_TEXT, null, "views", 0, "root", "ownText"), + }, + { + label: "a widened unavailability marker as subtreeText", + doc: put( + GOOD_VIEW_FULL_TEXT, + { unavailable: true, partial: "x" }, + "views", + 0, + "root", + "subtreeText", + ), + }, + ], + }, { name: "12.7 preview document", decode: decodePreviewReport, diff --git a/test/suite/registry/index.ts b/test/suite/registry/index.ts index 847040c..7c5ebb0 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -56,6 +56,7 @@ import { section106Tests } from "./section-10.6.js"; import { section107iTests } from "./section-10.7-i.js"; import { section107iiTests } from "./section-10.7-ii.js"; import { section11Tests } from "./section-11.js"; +import { section112Tests } from "./section-11.2.js"; import { section120iTests } from "./section-12.0-i.js"; import { section120iiTests } from "./section-12.0-ii.js"; import { section121to122Tests } from "./section-12.1-12.2.js"; @@ -120,6 +121,7 @@ export const productTestSuite = new ProductTestSuite([ ...section107iTests, ...section107iiTests, ...section11Tests, + ...section112Tests, ...section120iTests, ...section120iiTests, ...section121to122Tests, diff --git a/test/suite/registry/section-11.2.ts b/test/suite/registry/section-11.2.ts new file mode 100644 index 0000000..8d3d7cb --- /dev/null +++ b/test/suite/registry/section-11.2.ts @@ -0,0 +1,679 @@ +// TEST-SPEC §11.2 (availability on imperfect files) — SUITE-52: T11.2-1. +// +// Registered product-facing bodies (C-2 "one code path"): each builds its own +// fresh workspace (H-1), drives the product strictly as a subprocess (H-2), +// asserts exact exit codes (H-5), and rejects a product only via diagnosed +// assertion failures (H-8). +// +// SPEC 11.2: `occurrences`, `view`, and `at` answer per file, from parsing +// alone, never gated on workspace-wide validity — parse-local structure (the +// positional tree, construct ranges, raw attribute spellings, comment +// ranges, reference-occurrence positions) survives the file's own findings +// and other files' invalidity; only an unparseable file (14.20) loses its +// structural data, per file. The three surfaces are JSON-only (SPEC 11): a +// single JSON document is the only output form, with or without `--json`, +// in the form-exact 12.7 document forms (H-3) — so every invocation below +// runs bare and its entire stdout is parsed as one JSON document. +// +// Conservative operationalizations (noted per H-3/H-4): +// - "`view` over all three" is the bare whole-domain form (SPEC 11.4: with +// neither operands nor `--file`, every discovered spec source is viewed). +// - "all served" is realized byte-exactly over a projection of each per-file +// view: tree shape, per-node identity datum (the 11.2 three-state — the +// tree's anchoring), construct range, and raw attribute entries +// (name/range/text), plus the comment ranges and the full occurrence +// records (SPEC 5.7 pins every member). Every expected range is composed +// from the same string parts the staged files are — never measured from +// product output — and fixture self-checks slice claimed ranges back out +// of the staged bytes before the product runs (the T5.7-2 discipline). +// Deliberately OUTSIDE the projection, at their home tests: the +// opening/closing tag-range decompositions (T11.4-1 byte-asserts them), +// and the interpreted `tags`/`coverage` datums (T11.2-2's matrix, +// T11.4-3) — the form-exact decode still validates their forms. +// - "modify nothing: graph data and derived files byte-identical around each +// invocation" is a whole-workspace-root snapshot compare around every +// invocation (H-4): the workspace never passes `build`, so no graph data +// and no derived files exist — any write (`.xspec/`, a module, Markdown) +// surfaces in the diff. The gate-reference `build` rides the same compare +// (a failing build modifies nothing, SPEC 12.1). +// - The failing-side `occurrences` and `at` answers are asserted here per +// T11.2-6's delegation ("on a failing one they answer from current +// sources and write nothing (T11.2-1)"): `occurrences` bare answers the +// whole discovered set's enumeration with the workspace's findings +// (exit 1), while `at` on the finding-free C answers finding-free with +// exit 0 — its consulted domain is the named file alone (SPEC 11.5), the +// sharpest per-file contrast on a failing workspace. +// - The `build --json` gate reference doubles as staging integrity: exactly +// the staged condition multiset — findings of both levels in A (14.5, +// 14.9 resolution-level; 14.3, 14.4, 14.16, 14.17 per-file structural) +// and B's 14.20 — so "the workspace fails `build`" and every later +// exact-findings assertion stand on pinned ground. Finding LOCATIONS are +// asserted at file granularity only (range precision is T14-8's). + +import { Buffer } from "node:buffer"; +import type { + Finding, + OccurrenceRecord, + SourceRange, + ViewAttributeEntry, + ViewNode, +} from "../../helpers/adapters/index.js"; +import { + decodeAtReport, + decodeFindingsReport, + decodeOccurrencesReport, + decodeViewReport, +} from "../../helpers/adapters/index.js"; +import { assertExitCode, parseJsonStdout } from "../../helpers/assertions.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { + assertConditionCounts, + assertFindingLocated, + assertSameJson, + expectExit, + runCli, +} from "./support.js"; + +// Minimal declarative configuration (SPEC 7): exactly one spec group. +const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`; + +const A_FILE = "specs/A.mdx"; +const B_FILE = "specs/B.mdx"; +const C_FILE = "specs/C.mdx"; + +/** + * Running byte-offset fixture assembler (the T5.7-2/T1.7-2 discipline): + * `add` appends a segment and returns its byte range, `attr` an attribute + * segment as the expected `{name, range, text}` view entry (SPEC 11.4: the + * source text is the attribute's own characters, so entry text = segment). + * Every expected offset is composed from the same parts the staged file is. + */ +class ByteFixture { + private readonly parts: string[] = []; + private bytes = 0; + + get pos(): number { + return this.bytes; + } + + get source(): string { + return this.parts.join(""); + } + + add(segment: string): SourceRange { + const start = this.bytes; + this.parts.push(segment); + this.bytes += Buffer.byteLength(segment, "utf8"); + return { start, end: this.bytes }; + } + + attr(name: string, text: string): ViewAttributeEntry { + return { name, range: this.add(text), text }; + } +} + +/** The 12.7 unavailability marker, as decoded (one-datum state). */ +const UNAVAILABLE = { unavailable: true } as const; + +// --- specs/A.mdx — parseable, findings of both levels ------------------------ +// +// Resolution-level: `gone`'s `d={"nosuch"}` is unresolved (14.5, records no +// occurrence); `top`'s `d={"top"}` is a dependency self-cycle of length one +// (SPEC 5.3, 14.9) — the spelling RESOLVES (its target's identity is +// defined), so it records a `depends` occurrence: exactly the +// positions-survive-findings demonstration. Per-file structural: the two +// `dup` bearers (14.3, every bearer's identity undefined, no winner — +// SPEC 11.2), the malformed one-segment `ha#sh` (14.4; its spelled identity +// is malformed, so its node identity is undefined), `top.kid`'s unknown +// `bogus` prop (14.17; identity untouched), and the `<div>` element (14.16 — +// no view entry, located by its finding instead, SPEC 11.4). `top`'s +// `{text("solo")}` resolves to the self-closing `solo` leaf and records the +// second occurrence (`embeds`, spanning the whole braced container, 5.7). +// The multi-byte prefix (é: 2 bytes; —: 3 bytes) shifts every later offset, +// so byte offsets diverge from code-point and UTF-16 counts (SPEC 1.7). + +const A = new ByteFixture(); +A.add("Prélude — multi-byte guard prose.\n\n"); +const A_COMMENT_TEXT = "{/* availability survey */}"; +const A_COMMENT_RANGE = A.add(A_COMMENT_TEXT); +A.add("\n\n"); +const A_TOP_START = A.pos; +A.add("<S "); +const A_TOP_ID = A.attr("id", 'id="top"'); +A.add(" "); +const A_TOP_D = A.attr("d", 'd={"top"}'); +A.add(">\nTop text.\n\n"); +const A_EMBED_TEXT = '{text("solo")}'; +const A_EMBED_RANGE = A.add(A_EMBED_TEXT); +A.add("\n\n"); +const A_KID_START = A.pos; +A.add("<S "); +const A_KID_ID = A.attr("id", 'id="top.kid"'); +A.add(" "); +const A_KID_BOGUS = A.attr("bogus", 'bogus="x"'); +A.add(">\nKid text.\n</S>"); +const A_KID_RANGE: SourceRange = { start: A_KID_START, end: A.pos }; +A.add("\n</S>"); +const A_TOP_RANGE: SourceRange = { start: A_TOP_START, end: A.pos }; +A.add("\n\n"); +const A_DUP1_START = A.pos; +A.add("<S "); +const A_DUP1_ID = A.attr("id", 'id="dup"'); +A.add(">\nFirst bearer.\n</S>"); +const A_DUP1_RANGE: SourceRange = { start: A_DUP1_START, end: A.pos }; +A.add("\n\n"); +const A_DUP2_START = A.pos; +A.add("<S "); +const A_DUP2_ID = A.attr("id", 'id="dup"'); +A.add(">\nSecond bearer.\n</S>"); +const A_DUP2_RANGE: SourceRange = { start: A_DUP2_START, end: A.pos }; +A.add("\n\n"); +const A_HASH_START = A.pos; +A.add("<S "); +const A_HASH_ID = A.attr("id", 'id="ha#sh"'); +A.add(">\nMalformed segment.\n</S>"); +const A_HASH_RANGE: SourceRange = { start: A_HASH_START, end: A.pos }; +A.add("\n\n"); +const A_GONE_START = A.pos; +A.add("<S "); +const A_GONE_ID = A.attr("id", 'id="gone"'); +A.add(" "); +const A_GONE_D = A.attr("d", 'd={"nosuch"}'); +A.add(">\nUnresolved dependency.\n</S>"); +const A_GONE_RANGE: SourceRange = { start: A_GONE_START, end: A.pos }; +A.add("\n\n<div>stray</div>\n\n"); +const A_SOLO_TEXT_START = A.pos; +A.add("<S "); +const A_SOLO_ID = A.attr("id", 'id="solo"'); +A.add(" />"); +const A_SOLO_RANGE: SourceRange = { start: A_SOLO_TEXT_START, end: A.pos }; +A.add("\n"); +const A_SOURCE = A.source; +const A_ROOT_RANGE: SourceRange = { start: 0, end: A.pos }; + +/** + * The string-literal reference inside a `d={"…"}` attribute: `d={` and the + * closing `}` excluded — a `d` occurrence spans that one reference's own + * expression, the string literal's characters quotes included (SPEC 5.7, + * 2.2; the T5.7-2 local-form convention). ASCII segment, so character + * arithmetic is byte arithmetic. + */ +function dLiteralRange(attribute: ViewAttributeEntry): SourceRange { + return { + start: attribute.range.start + "d={".length, + end: attribute.range.end - 1, + }; +} +const A_TOP_D_REF = dLiteralRange(A_TOP_D); + +// --- specs/B.mdx — unparseable (14.20: unclosed section tag) ------------------ +const B_SOURCE = '<S id="broken">\nNever closed.\n'; + +// --- specs/C.mdx — finding-free ---------------------------------------------- +const C = new ByteFixture(); +C.add("Intro prose.\n\n"); +const C_SECTION_START = C.pos; +C.add("<S "); +const C_ID = C.attr("id", 'id="c"'); +C.add(">\nComplete text.\n</S>"); +const C_SECTION_RANGE: SourceRange = { start: C_SECTION_START, end: C.pos }; +C.add("\n"); +const C_SOURCE = C.source; +const C_ROOT_RANGE: SourceRange = { start: 0, end: C.pos }; + +// --- expected values ---------------------------------------------------------- + +/** + * The tree projection T11.2-1 pins (its named clauses): per node, the + * identity datum (11.2 three-state), the construct range (1.7), the raw + * attribute entries as parsed, and the children in document order. The + * opening/closing decompositions and interpreted tags/coverage stay outside + * — T11.4-1, T11.2-2/T11.4-3 pin those; the form-exact decode has already + * validated their forms. + */ +interface TreeExpectation { + readonly identity: string | { readonly unavailable: true }; + readonly range: SourceRange; + readonly attributes: readonly ViewAttributeEntry[]; + readonly children: readonly TreeExpectation[]; +} + +function projectNode(node: ViewNode): TreeExpectation { + return { + identity: node.identity, + range: node.range, + attributes: node.attributes.map((entry) => ({ + name: entry.name, + range: entry.range, + text: entry.text, + })), + children: node.children.map(projectNode), + }; +} + +// A's full positional tree: the `<div>` gets no node (14.16 — located by its +// finding, never a view entry); the duplicate bearers and the malformed +// `ha#sh` keep their structure with identities explicitly unavailable +// (SPEC 11.2: no winner picked; a malformed spelled identity is undefined), +// while `top`, `top.kid`, `gone`, and `solo` stay defined — an unknown prop +// (14.17) and resolution-level findings never undefine an identity. +const A_TREE: TreeExpectation = { + identity: A_FILE, + range: A_ROOT_RANGE, + attributes: [], + children: [ + { + identity: `${A_FILE}#top`, + range: A_TOP_RANGE, + attributes: [A_TOP_ID, A_TOP_D], + children: [ + { + identity: `${A_FILE}#top.kid`, + range: A_KID_RANGE, + attributes: [A_KID_ID, A_KID_BOGUS], + children: [], + }, + ], + }, + { + identity: UNAVAILABLE, + range: A_DUP1_RANGE, + attributes: [A_DUP1_ID], + children: [], + }, + { + identity: UNAVAILABLE, + range: A_DUP2_RANGE, + attributes: [A_DUP2_ID], + children: [], + }, + { + identity: UNAVAILABLE, + range: A_HASH_RANGE, + attributes: [A_HASH_ID], + children: [], + }, + { + identity: `${A_FILE}#gone`, + range: A_GONE_RANGE, + attributes: [A_GONE_ID, A_GONE_D], + children: [], + }, + { + identity: `${A_FILE}#solo`, + range: A_SOLO_RANGE, + attributes: [A_SOLO_ID], + children: [], + }, + ], +}; + +const C_TREE: TreeExpectation = { + identity: C_FILE, + range: C_ROOT_RANGE, + attributes: [], + children: [ + { + identity: `${C_FILE}#c`, + range: C_SECTION_RANGE, + attributes: [C_ID], + children: [], + }, + ], +}; + +// A's complete occurrence enumeration (SPEC 5.7): the self-cycle's `d` +// spelling and the embedding — the unresolved `d={"nosuch"}` records none, +// and B's spellings are hidden with the rest of it (11.2). Member order +// mirrors the form decode's construction (assertSameJson is order-exact). +const A_EXPECTED_OCCURRENCES: readonly OccurrenceRecord[] = [ + { + file: A_FILE, + range: A_TOP_D_REF, + kind: "depends", + source: { identity: `${A_FILE}#top`, range: A_TOP_RANGE }, + target: `${A_FILE}#top`, + }, + { + file: A_FILE, + range: A_EMBED_RANGE, + kind: "embeds", + source: { identity: `${A_FILE}#top`, range: A_TOP_RANGE }, + target: `${A_FILE}#solo`, + }, +]; + +// The staged condition multiset (SPEC 14: each present condition reported): +// A's six findings — one 14.3 locating both bearers, 14.4, 14.5, 14.9 (the +// self-cycle), 14.16, 14.17 — plus B's 14.20. No masking interplay: every +// section spells an `id` (no 14.1), every spelled identity is one segment at +// top level or parent-plus-one (`top.kid`), so no 14.2 arises. +const WORKSPACE_CONDITION_COUNTS: Readonly<Record<string, number>> = { + "14.3": 1, + "14.4": 1, + "14.5": 1, + "14.9": 1, + "14.16": 1, + "14.17": 1, + "14.20": 1, +}; + +/** + * Every finding locates in its home file: B's 14.20 in specs/B.mdx (the + * parse-failure location), everything else in specs/A.mdx — at file + * granularity (range precision is T14-8's business). + */ +function assertFindingHomes( + findings: readonly Finding[], + context: string, +): void { + for (const finding of findings) { + const home = finding.condition === "14.20" ? B_FILE : A_FILE; + assertFindingLocated( + finding, + { file: home }, + `${context} — the ${finding.condition ?? finding.code ?? "code-less"} finding`, + ); + } +} + +/** Fixture self-check (T5.7-2 discipline): a claimed range slices the staged bytes to exactly `expected` — before the product is ever invoked. */ +function sliceCheck( + source: string, + range: SourceRange, + expected: string, + what: string, +): void { + const actual = Buffer.from(source, "utf8") + .subarray(range.start, range.end) + .toString("utf8"); + if (actual !== expected) { + throw new Error( + `section-11.2 fixture self-check: ${what} — expected the range ` + + `[${String(range.start)}, ${String(range.end)}) to slice to ` + + `${JSON.stringify(expected)}, got ${JSON.stringify(actual)}; the ` + + `staging arithmetic is wrong (harness defect, not a product result)`, + ); + } +} + +// --------------------------------------------------------------------------- +// T11.2-1 — parse-local structure, per-file masking, no writes +// --------------------------------------------------------------------------- + +const T11_2_1 = defineProductTest({ + id: "T11.2-1", + title: + "three spec files — A parseable with findings of both levels (unresolved `d`, self-cycle; duplicate-ID pair, malformed segment, unknown prop, invalid construct), B unparseable, C finding-free — fail `build` with exactly the staged conditions; the bare whole-domain `view` (one JSON document, no `--json`) serves A's full positional tree with byte-exact construct ranges, raw attribute spellings, comment ranges, and occurrence records — structure surviving A's own findings and B's invalidity — and C's complete view, while B contributes no view, its parse-failure finding accompanying, exit 1; on the same failing workspace `occurrences` answers the whole enumeration (exit 1) and `at` on C answers finding-free (exit 0, per-file domain); every invocation modifies nothing — no graph data, no derived files (SPEC 11.2, 11.3–11.5, 13.3, 5.7, 14)", + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline) — composed-range arithmetic + // proven against the staged bytes before any product invocation. + sliceCheck(A_SOURCE, A_TOP_D_REF, '"top"', "the self-cycle d reference"); + sliceCheck( + A_SOURCE, + A_EMBED_RANGE, + A_EMBED_TEXT, + "the embedding container", + ); + sliceCheck(A_SOURCE, A_COMMENT_RANGE, A_COMMENT_TEXT, "the MDX comment"); + sliceCheck(A_SOURCE, A_SOLO_RANGE, '<S id="solo" />', "the solo construct"); + sliceCheck(A_SOURCE, A_TOP_ID.range, A_TOP_ID.text, "top's id attribute"); + sliceCheck( + C_SOURCE, + C_SECTION_RANGE, + '<S id="c">\nComplete text.\n</S>', + "C's section construct", + ); + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [A_FILE]: A_SOURCE, + [B_FILE]: B_SOURCE, + [C_FILE]: C_SOURCE, + }, + }); + try { + // --- The gate reference and staging integrity: `build` fails with + // exactly the staged conditions — findings of both levels in A, the + // parse failure in B — each located in its home file; a failing build + // modifies nothing (SPEC 12.1, 14). + const buildContext = + "T11.2-1 `build --json` (the gate reference: the workspace fails " + + "`build`, with exactly the staged conditions)"; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + ["build", "--json"], + 1, + buildContext, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, buildContext), + buildContext, + ).findings; + assertConditionCounts( + findings, + WORKSPACE_CONDITION_COUNTS, + `${buildContext} — A carries findings of BOTH levels ` + + `(resolution-level 14.5/14.9; per-file structural ` + + `14.3/14.4/14.16/14.17) and B is unparseable (14.20)`, + ); + assertFindingHomes(findings, buildContext); + }, + `${buildContext} — a failing build modifies nothing (SPEC 12.1)`, + ); + + // --- `view` over all three (the bare whole-domain form, SPEC 11.4): + // one JSON document, exit 1 (findings accompany, the answer still + // whole — SPEC 11.2), decoded form-exactly (H-3). + const viewContext = + "T11.2-1 bare `view` (whole domain: every discovered spec source)"; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await runCli(product, workspace, ["view"]); + assertExitCode( + result, + 1, + `${viewContext} — the answer carries findings and ` + + `explicitly-unavailable identities, so the invocation exits 1 ` + + `with the full document still emitted (SPEC 11.2)`, + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${viewContext} — a single JSON document is the only output ` + + `form, with or without --json (SPEC 11)`, + ), + { text: false }, + viewContext, + ); + + // The consulted domain is all three requested files, so every + // staged finding accompanies — B's parse-failure finding included + // (SPEC 11.2, 11.4). + assertConditionCounts( + report.findings, + WORKSPACE_CONDITION_COUNTS, + `${viewContext} — the domain's findings accompany the answer, ` + + `B's 14.20 among them (SPEC 11.2)`, + ); + assertFindingHomes(report.findings, viewContext); + + // B contributes no view; A's and C's views are served, ordered by + // file path bytes (SPEC 11.4). + assertSameJson( + report.views.map((view) => view.file), + [A_FILE, C_FILE], + `${viewContext} — per-file views for exactly the parseable ` + + `files in path-byte order: B is unparseable and contributes ` + + `no entry, its finding reporting it instead (SPEC 11.4, 11.2)`, + ); + const aView = report.views[0]!; + const cView = report.views[1]!; + + // A's full positional tree — structure survives A's own findings + // and B's invalidity (SPEC 11.2): tree shape, construct ranges, + // and raw attribute spellings byte-exact; the invalid `<div>` has + // no node (14.16 — its finding locates it, SPEC 11.4); identities + // per 11.2's three-state rules. + assertSameJson( + projectNode(aView.root), + A_TREE, + `${viewContext} — A's full positional tree: document-order ` + + `nodes with byte-exact construct ranges (SPEC 1.7), raw ` + + `attribute entries as parsed (name/range/text — the unknown ` + + `prop included, its invalidity a finding, never an omission), ` + + `and identity datums per 11.2 (duplicate bearers and the ` + + `malformed ha#sh explicitly unavailable; top, top.kid, gone, ` + + `solo defined)`, + ); + assertSameJson( + aView.comments, + [A_COMMENT_RANGE], + `${viewContext} — A's comment ranges are served (SPEC 11.4)`, + ); + assertSameJson( + aView.occurrences, + A_EXPECTED_OCCURRENCES, + `${viewContext} — A's occurrence positions are served despite ` + + `the findings: the self-cycle's d spelling RESOLVES and ` + + `records its depends occurrence (cycle participation is a ` + + `finding, not an occurrence eraser — SPEC 11.2, 5.7), the ` + + `embedding spans its whole braced container, and the ` + + `unresolved d={"nosuch"} records none`, + ); + assertSameJson( + aView.imports, + [], + `${viewContext} — A declares no imports (SPEC 11.4: [] never null)`, + ); + + // C's view is complete (SPEC 11.2): the finding-free file's whole + // structure, empty lists as [] (12.7). + assertSameJson( + projectNode(cView.root), + C_TREE, + `${viewContext} — C's complete view: root and section with ` + + `byte-exact ranges and defined identities`, + ); + assertSameJson( + [cView.imports, cView.occurrences, cView.comments], + [[], [], []], + `${viewContext} — C holds no imports, occurrences, or comments: ` + + `empty arrays, never null (SPEC 12.7)`, + ); + }, + `${viewContext} — \`view\` on a failing workspace answers from ` + + `current sources and modifies nothing: no graph data, no derived ` + + `files (SPEC 11.2, 13.3)`, + ); + + // --- `occurrences` bare (the whole discovered set, SPEC 11.3): the + // same domain findings, the same two records — answered per file on + // the failing workspace, nothing written (T11.2-6 delegates the + // failing side here). + const occurrencesContext = "T11.2-1 bare `occurrences`"; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await runCli(product, workspace, ["occurrences"]); + assertExitCode( + result, + 1, + `${occurrencesContext} — the enumeration carries the domain's ` + + `findings, so exit 1 with the full answer (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + result, + `${occurrencesContext} — a single JSON document is the only ` + + `output form (SPEC 11)`, + ), + occurrencesContext, + ); + assertConditionCounts( + report.findings, + WORKSPACE_CONDITION_COUNTS, + `${occurrencesContext} — the whole discovered set is the ` + + `consulted domain (SPEC 11.3)`, + ); + assertFindingHomes(report.findings, occurrencesContext); + assertSameJson( + report.occurrences, + A_EXPECTED_OCCURRENCES, + `${occurrencesContext} — the workspace's complete enumeration: ` + + `A's two resolving spellings, byte-exact (SPEC 5.7); the ` + + `unresolved spelling records none and B's content is hidden ` + + `with the rest of it (SPEC 11.2)`, + ); + }, + `${occurrencesContext} — \`occurrences\` on a failing workspace ` + + `answers from current sources and modifies nothing (SPEC 11.2, 13.3)`, + ); + + // --- `at` on C (SPEC 11.5): the consulted domain is the named file + // alone, so the answer is finding-free and exits 0 — per-file + // availability at its sharpest: A's and B's findings do not attach, + // and the failing workspace never gates the answer (SPEC 11.2). + const atContext = "T11.2-1 `at specs/C.mdx 0`"; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await runCli(product, workspace, ["at", C_FILE, "0"]); + assertExitCode( + result, + 0, + `${atContext} — the consulted domain is the named finding-free ` + + `file alone, so the complete answer exits 0 on the failing ` + + `workspace (SPEC 11.5, 11.2)`, + ); + const report = decodeAtReport( + parseJsonStdout( + result, + `${atContext} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + atContext, + ); + assertSameJson( + report.findings, + [], + `${atContext} — C's findings alone accompany: none (SPEC 11.2)`, + ); + assertSameJson( + report.resolution, + { + section: { identity: C_FILE, range: C_ROOT_RANGE }, + occurrence: null, + }, + `${atContext} — offset 0 lies in C's between-section prose, so ` + + `it resolves to the root (identity the path, range the whole ` + + `file) with no containing occurrence (SPEC 11.5, 1.7)`, + ); + }, + `${atContext} — \`at\` on a failing workspace answers from current ` + + `sources and modifies nothing (SPEC 11.2, 13.3)`, + ); + } finally { + await workspace.dispose(); + } + }, +}); + +/** TEST-SPEC §11.2, in canonical ID order (SUITE-52). */ +export const section112Tests: readonly ProductTestEntry[] = [T11_2_1]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 3f915f1..b66bca1 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -298,6 +298,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11-5": ["11", "11.1"], "T11-6": ["11.1"], "T11-7": ["11.1"], + "T11.2-1": ["11.2"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], diff --git a/test/suite/section-11.2.test.ts b/test/suite/section-11.2.test.ts new file mode 100644 index 0000000..1a7b43d --- /dev/null +++ b/test/suite/section-11.2.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §11.2 (SUITE-52): thin Vitest wrapper over the registered +// bodies — the identical bodies the certification runner executes against +// fixture products (C-2 "one code path"). Expected to fail as diagnosed +// assertion failures until the product exists (H-8). + +import { declareProductTests } from "./declare.js"; +import { section112Tests } from "./registry/section-11.2.js"; + +declareProductTests(section112Tests); From aacac62343eab4faa3147de77fdced7f50cceb6e Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 22:28:10 +0000 Subject: [PATCH 096/226] sdg(phase-9): implement T11.2-2's spelled-identity/interpreted-data matrix (FP-051) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 45 ++- test/suite/registry/section-11.2.ts | 506 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 549 insertions(+), 3 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 68853a0..4d5eead 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2166,9 +2166,52 @@ certify against FP-091's fixtures once those land. test:self`: 4 planned mid-loop reds (certification-document ×3 → FP-091; S-1's unmapped keys narrowed 7 → 6, now {11.3, 11.4, 11.5, 11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-051 — Implement T11.2-2 (CONF-AVAIL): spelled-identity / +- [x] FP-051 — Implement T11.2-2 (CONF-AVAIL): spelled-identity / interpreted-data definedness matrix. [R2 #2, R3 gap 1, VERIFY; TEST-SPEC §11.2] + [Done 2026-08-13: registered in section-11.2.ts (SUITE-52) with + traceability ["11.2"] (no TEST-SPEC 14 staging record — the FP-050 + precedent). CONF-AVAIL in scope (VIOL-AVAIL-NULLMARKER and VIOL-AVAIL-OMIT + certify it; the fixture family lands with FP-091), and its scope + constraint is honored: NO gate-reference `build` — CERTIFICATIONS.md pins + every command an in-scope test drives to the enumerated `view`/ + `occurrences` surface — so staging integrity rides the `view` answer's own + exact findings multiset, the staged conditions drawn from the scope's + stated set. One file (specs/M.mdx, multi-byte prefix), bare `view`, exit 1, + full tree projection (identity/range/raw attribute entries per node PLUS + this test's tags/coverage datums; `datumLeaf` defaults pin the absent-prop + defaults — tags [] never null, coverage "required" — on every propless + section; root the stated null/null): solo defined (+`coverage="none"` as + the defined non-default value); repeated agreeing (`ragree` x2) and + disagreeing (`rone`/`rtwo`), braced `id={"x"}`, valueless `id` each + unavailable (14.17, never 14.1); no-`id` section (14.1) with child + `orphan` (14.2 masked) AND grandchild `orphan.deep` — the deep arm + discriminates a product checking only the immediate parent's spelling; + duplicate `x` pair both unavailable (one 14.3 locating both) with `x.y` + defined beneath one (defined without defined prefixes); `ha#sh` (14.4) + with structurally-consistent child `ha#sh.kid` (own 14.4 — extending a + malformed identity cannot avoid its segments; deliberately no 14.2 + anywhere, a condition outside CONF-AVAIL's stated set); unique `z` defined + beside braced `id={"z"}` (contests nothing — a value-reading product + fails the tree compare AND the count map via a second 14.3); tags matrix + `tr`/`tm`/`ti` (repeated/braced/invalid tag `bad#tag` 14.4) and coverage + matrix `cr`/`cm`/`ci` (repeated-agreeing/valueless/`"maybe"`) each + interpreted-unavailable with raw spellings listed and identities STAYING + defined (tags/coverage invalidity never undefines identity). Exact + findings {14.1:1, 14.3:1, 14.4:3, 14.17:10 — one per afflicted element}, + every finding located in M (file granularity; range precision T14-8's); + imports/occurrences/comments []. Verified: typecheck/format clean; suite + section-11.2 red-as-diagnosed — T11.2-2 fails at the exit assertion + (`view` unknown command, exit 2, the pre-patch product gap); scratch + probe: a conforming fake re-deriving the 11.2 rules independently from the + workspace bytes ran the registered body green end-to-end (expectations + satisfiable, findings order accepted), and four deviation fakes each + failed diagnosed — markers-as-null at the identity-datum decode (exactly + VIOL-AVAIL-NULLMARKER's certified failure), take-last-value and + no-inheritance at the tree compare, invalid-forms-contest at the count + map (assertions toothed). `npm run test:self`: unchanged 4 planned + mid-loop reds (certification-document x3 → FP-091; S-1's 6 unmapped keys + {11.3–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] - [ ] FP-052 — Implement T11.2-3: invalid paths (Linux leg). [R2 #2; TEST-SPEC §11.2] - [ ] FP-053 — Implement T11.2-4 (CONF-AVAIL): resolution and expanded-text diff --git a/test/suite/registry/section-11.2.ts b/test/suite/registry/section-11.2.ts index 8d3d7cb..41ad1f1 100644 --- a/test/suite/registry/section-11.2.ts +++ b/test/suite/registry/section-11.2.ts @@ -1,10 +1,21 @@ -// TEST-SPEC §11.2 (availability on imperfect files) — SUITE-52: T11.2-1. +// TEST-SPEC §11.2 (availability on imperfect files) — SUITE-52: T11.2-1, +// T11.2-2. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), // asserts exact exit codes (H-5), and rejects a product only via diagnosed // assertion failures (H-8). // +// Certification (CERTIFICATIONS.md CONF-AVAIL): T11.2-2 is in scope — +// VIOL-AVAIL-NULLMARKER and VIOL-AVAIL-OMIT certify it (the fixture family +// lands with the certification-manifest task). CONF-AVAIL's staging +// constraint pins every command an in-scope test drives to its enumerated +// `view`/`occurrences` surface, so T11.2-2 runs NO gate-reference `build` +// (unlike T11.2-1, which is not in scope): staging integrity rides the +// `view` answer's own exact accompanying-findings multiset instead, and the +// staged conditions are drawn from the scope's stated set (14.1, 14.3, 14.4, +// 14.17). +// // SPEC 11.2: `occurrences`, `view`, and `at` answer per file, from parsing // alone, never gated on workspace-wide validity — parse-local structure (the // positional tree, construct ranges, raw attribute spellings, comment @@ -675,5 +686,496 @@ const T11_2_1 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T11.2-2 — spelled identities and interpreted data +// --------------------------------------------------------------------------- +// +// SPEC 11.2's definedness matrix in one file, every node's identity datum — +// and every node's interpreted tags and coverage — asserted via the bare +// `view` (each a plain value, the root's stated `null`, or the 12.7 +// unavailability marker): +// +// - a section spells an identity exactly when EXACTLY ONE `id` attribute +// occurs on its tag with a quoted static-string value; repeated (values +// agreeing and disagreeing), braced, valueless, and absent `id` each spell +// none — identity explicitly unavailable; +// - duplicate spellings (`x` twice) leave every bearer undefined, no winner, +// while the uniquely spelled `x.y` beneath one bearer keeps its defined +// identity (uniqueness constrains the section's own spelled identity +// alone: a defined identity without defined prefix identities); +// - the chain conditions ARE inherited: descendants of a no-`id` section +// (child and grandchild — the grandchild discriminates a product checking +// only the immediate parent) and of a malformed-`id` section are undefined; +// - uniqueness compares spelled identities only: the unique `z` stays +// defined beside a braced `id={"z"}`, whose invalid form contests nothing; +// - absent `tags`/`coverage` props define the defaults (no tags — the plain +// empty list, never null — and coverage "required"), asserted on every +// propless section; a repeated, malformed (braced/valueless), or +// invalid-valued `tags`/`coverage` leaves the interpreted value +// unavailable, its raw spelling still a listed attribute entry (the full +// T11.4-3 attribute contract stays at its home test — here the entries +// pin exactly that no invalid form is omitted); identity is untouched by +// `tags`/`coverage` invalidity (those sections stay defined). +// +// Staging integrity WITHOUT a `build` gate reference (the CONF-AVAIL surface +// constraint, module header): the answer's findings are pinned as the exact +// staged condition multiset — every finding located in the matrix file — +// so a mis-staged arm (a defect that never fired, or one firing under the +// wrong condition) fails loudly here. Finding locations are asserted at +// file granularity (range precision is T14-8's). + +const M_FILE = "specs/M.mdx"; + +const M = new ByteFixture(); +M.add("Prélude — spelled-identity and interpreted-data matrix.\n\n"); + +// (a) Exactly one quoted static `id` → defined; `coverage="none"` is the +// defined non-default interpreted value (SPEC 2.5). +const M_SOLO_START = M.pos; +M.add("<S "); +const M_SOLO_ID = M.attr("id", 'id="solo"'); +M.add(" "); +const M_SOLO_COVERAGE = M.attr("coverage", 'coverage="none"'); +M.add(">\nSolo text.\n</S>"); +const M_SOLO_RANGE: SourceRange = { start: M_SOLO_START, end: M.pos }; +M.add("\n\n"); + +// (b) Repeated `id`, values agreeing → spells none (14.17, never 14.1); a +// take-any-value product would define #ragree and fail the tree compare. +const M_RAGREE_START = M.pos; +M.add("<S "); +const M_RAGREE_ID1 = M.attr("id", 'id="ragree"'); +M.add(" "); +const M_RAGREE_ID2 = M.attr("id", 'id="ragree"'); +M.add(">\nAgreeing repeat.\n</S>"); +const M_RAGREE_RANGE: SourceRange = { start: M_RAGREE_START, end: M.pos }; +M.add("\n\n"); + +// (c) Repeated `id`, values disagreeing → spells none (14.17); take-first +// (#rone) and take-last (#rtwo) products both fail the tree compare. +const M_RPAIR_START = M.pos; +M.add("<S "); +const M_RPAIR_ID1 = M.attr("id", 'id="rone"'); +M.add(" "); +const M_RPAIR_ID2 = M.attr("id", 'id="rtwo"'); +M.add(">\nDisagreeing repeat.\n</S>"); +const M_RPAIR_RANGE: SourceRange = { start: M_RPAIR_START, end: M.pos }; +M.add("\n\n"); + +// (d) Braced `id={"x"}` → spells none (14.17); TEST-SPEC's own value ties it +// to the duplicate pair below — under any reading its datum is unavailable, +// and the contests-nothing discrimination rides the `z` arm. +const M_BRACEDX_START = M.pos; +M.add("<S "); +const M_BRACEDX_ID = M.attr("id", 'id={"x"}'); +M.add(">\nBraced value.\n</S>"); +const M_BRACEDX_RANGE: SourceRange = { start: M_BRACEDX_START, end: M.pos }; +M.add("\n\n"); + +// (e) Valueless `id` → spells none (14.17); the raw entry is the bare name. +const M_VALUELESS_START = M.pos; +M.add("<S "); +const M_VALUELESS_ID = M.attr("id", "id"); +M.add(">\nValueless id.\n</S>"); +const M_VALUELESS_RANGE: SourceRange = { start: M_VALUELESS_START, end: M.pos }; +M.add("\n\n"); + +// (f) No `id` at all → 14.1, identity unavailable — and (h) inheritance: +// the child spells the well-formed, unique `orphan` (its structural check +// masked by the parent's 14.1 — no 14.2), the grandchild `orphan.deep` +// (structurally clean against `orphan`) — both undefined because the chain +// contains a section spelling no identity. The grandchild discriminates a +// product that checks only its immediate parent's spelling. +const M_NOID_START = M.pos; +M.add("<S>\nNo id here.\n\n"); +const M_ORPHAN_START = M.pos; +M.add("<S "); +const M_ORPHAN_ID = M.attr("id", 'id="orphan"'); +M.add(">\nOrphan text.\n\n"); +const M_DEEP_START = M.pos; +M.add("<S "); +const M_DEEP_ID = M.attr("id", 'id="orphan.deep"'); +M.add(">\nDeep text.\n</S>"); +const M_DEEP_RANGE: SourceRange = { start: M_DEEP_START, end: M.pos }; +M.add("\n</S>"); +const M_ORPHAN_RANGE: SourceRange = { start: M_ORPHAN_START, end: M.pos }; +M.add("\n</S>"); +const M_NOID_RANGE: SourceRange = { start: M_NOID_START, end: M.pos }; +M.add("\n\n"); + +// (g) Two sections both spelling `x` → one 14.3 locating both bearers, both +// identities unavailable, no winner — while the uniquely spelled `x.y` +// beneath the first keeps its defined identity: defined without defined +// prefixes (duplication is not a chain condition). +const M_X1_START = M.pos; +M.add("<S "); +const M_X1_ID = M.attr("id", 'id="x"'); +M.add(">\nFirst duplicate bearer.\n\n"); +const M_XY_START = M.pos; +M.add("<S "); +const M_XY_ID = M.attr("id", 'id="x.y"'); +M.add(">\nUnique descendant.\n</S>"); +const M_XY_RANGE: SourceRange = { start: M_XY_START, end: M.pos }; +M.add("\n</S>"); +const M_X1_RANGE: SourceRange = { start: M_X1_START, end: M.pos }; +M.add("\n\n"); +const M_X2_START = M.pos; +M.add("<S "); +const M_X2_ID = M.attr("id", 'id="x"'); +M.add(">\nSecond duplicate bearer.\n</S>"); +const M_X2_RANGE: SourceRange = { start: M_X2_START, end: M.pos }; +M.add("\n\n"); + +// (i) Malformed spelled identity (`ha#sh`, 14.4) with a structurally +// consistent child `ha#sh.kid` — the child's own spelled identity carries +// the malformed segment too (its own 14.4; extending a malformed identity +// cannot avoid its segments), and both are undefined: the chain contains a +// malformed spelled identity. No 14.2 anywhere: the child extends its +// parent's spelling exactly. +const M_HASH_START = M.pos; +M.add("<S "); +const M_HASH_ID = M.attr("id", 'id="ha#sh"'); +M.add(">\nMalformed bearer.\n\n"); +const M_HASHKID_START = M.pos; +M.add("<S "); +const M_HASHKID_ID = M.attr("id", 'id="ha#sh.kid"'); +M.add(">\nMalformed-chain child.\n</S>"); +const M_HASHKID_RANGE: SourceRange = { start: M_HASHKID_START, end: M.pos }; +M.add("\n</S>"); +const M_HASH_RANGE: SourceRange = { start: M_HASH_START, end: M.pos }; +M.add("\n\n"); + +// (j) The unique `z` stays defined beside the braced `id={"z"}`: uniqueness +// compares spelled identities only — an invalid form contests nothing. A +// product reading the braced value would see `z` duplicated and undefine +// the quoted bearer (tree compare) and report a second 14.3 (count map). +// `tags="lone"` doubles as the defined single-tag interpreted value. +const M_Z_START = M.pos; +M.add("<S "); +const M_Z_ID = M.attr("id", 'id="z"'); +M.add(" "); +const M_Z_TAGS = M.attr("tags", 'tags="lone"'); +M.add(">\nUnique beside invalid forms.\n</S>"); +const M_Z_RANGE: SourceRange = { start: M_Z_START, end: M.pos }; +M.add("\n\n"); +const M_BRACEDZ_START = M.pos; +M.add("<S "); +const M_BRACEDZ_ID = M.attr("id", 'id={"z"}'); +M.add(">\nContests nothing.\n</S>"); +const M_BRACEDZ_RANGE: SourceRange = { start: M_BRACEDZ_START, end: M.pos }; +M.add("\n\n"); + +// Interpreted tags/coverage matrix (each bearer's own `id` valid and unique, +// pinning that tags/coverage invalidity never undefines identity): +// repeated `tags` (values disagreeing — any picked or merged value fails), +// malformed braced `tags`, invalid-valued `tags` (an invalid tag, 14.4), +// repeated `coverage` (values AGREEING — a take-any product yields the +// plain "none" and fails), valueless `coverage`, invalid `coverage` value. +const M_TR_START = M.pos; +M.add("<S "); +const M_TR_ID = M.attr("id", 'id="tr"'); +M.add(" "); +const M_TR_TAGS1 = M.attr("tags", 'tags="alpha"'); +M.add(" "); +const M_TR_TAGS2 = M.attr("tags", 'tags="beta"'); +M.add(">\nRepeated tags.\n</S>"); +const M_TR_RANGE: SourceRange = { start: M_TR_START, end: M.pos }; +M.add("\n\n"); +const M_TM_START = M.pos; +M.add("<S "); +const M_TM_ID = M.attr("id", 'id="tm"'); +M.add(" "); +const M_TM_TAGS = M.attr("tags", 'tags={"alpha"}'); +M.add(">\nBraced tags.\n</S>"); +const M_TM_RANGE: SourceRange = { start: M_TM_START, end: M.pos }; +M.add("\n\n"); +const M_TI_START = M.pos; +M.add("<S "); +const M_TI_ID = M.attr("id", 'id="ti"'); +M.add(" "); +const M_TI_TAGS = M.attr("tags", 'tags="ok bad#tag"'); +M.add(">\nInvalid tag value.\n</S>"); +const M_TI_RANGE: SourceRange = { start: M_TI_START, end: M.pos }; +M.add("\n\n"); +const M_CR_START = M.pos; +M.add("<S "); +const M_CR_ID = M.attr("id", 'id="cr"'); +M.add(" "); +const M_CR_COVERAGE1 = M.attr("coverage", 'coverage="none"'); +M.add(" "); +const M_CR_COVERAGE2 = M.attr("coverage", 'coverage="none"'); +M.add(">\nRepeated coverage.\n</S>"); +const M_CR_RANGE: SourceRange = { start: M_CR_START, end: M.pos }; +M.add("\n\n"); +const M_CM_START = M.pos; +M.add("<S "); +const M_CM_ID = M.attr("id", 'id="cm"'); +M.add(" "); +const M_CM_COVERAGE = M.attr("coverage", "coverage"); +M.add(">\nValueless coverage.\n</S>"); +const M_CM_RANGE: SourceRange = { start: M_CM_START, end: M.pos }; +M.add("\n\n"); +const M_CI_START = M.pos; +M.add("<S "); +const M_CI_ID = M.attr("id", 'id="ci"'); +M.add(" "); +const M_CI_COVERAGE = M.attr("coverage", 'coverage="maybe"'); +M.add(">\nInvalid coverage value.\n</S>"); +const M_CI_RANGE: SourceRange = { start: M_CI_START, end: M.pos }; +M.add("\n"); +const M_SOURCE = M.source; +const M_ROOT_RANGE: SourceRange = { start: 0, end: M.pos }; + +/** + * The staged condition multiset — the answer's exact accompanying findings + * (SPEC 11.2, 14), doubling as staging integrity (no `build` gate reference: + * CONF-AVAIL surface constraint, module header). One finding per afflicted + * element for 14.17 (each element stages exactly one cause); 14.3 is ONE + * finding for the jointly-duplicated `x` (locating both bearers); 14.4 once + * per malformed spelled identity (`ha#sh`, `ha#sh.kid`) plus once for the + * invalid tag (`bad#tag`, T1.4-4's condition). The masked checks contribute + * nothing: no 14.1 from repeated/braced/valueless `id` (condition 17, never + * 1), no 14.2 anywhere (the no-`id` section's child is masked; every other + * child extends its parent's spelling exactly). + */ +const M_CONDITION_COUNTS: Readonly<Record<string, number>> = { + "14.1": 1, + "14.3": 1, + "14.4": 3, + "14.17": 10, +}; + +/** + * T11.2-2's tree projection: T11.2-1's clauses (identity datum, construct + * range, raw attribute entries, children) PLUS the interpreted `tags` and + * `coverage` datums — this test's own matrix. Tag-range decompositions stay + * outside (T11.4-1's home); the form-exact decode has validated their forms. + */ +interface DatumTreeExpectation { + readonly identity: ViewNode["identity"]; + readonly range: SourceRange; + readonly attributes: readonly ViewAttributeEntry[]; + readonly tags: ViewNode["tags"]; + readonly coverage: ViewNode["coverage"]; + readonly children: readonly DatumTreeExpectation[]; +} + +function projectDatumNode(node: ViewNode): DatumTreeExpectation { + return { + identity: node.identity, + range: node.range, + attributes: node.attributes.map((entry) => ({ + name: entry.name, + range: entry.range, + text: entry.text, + })), + tags: node.tags, + coverage: node.coverage, + children: node.children.map(projectDatumNode), + }; +} + +/** Shorthand for a leaf expectation with defaulted tags/coverage. */ +function datumLeaf( + identity: DatumTreeExpectation["identity"], + range: SourceRange, + attributes: readonly ViewAttributeEntry[], + overrides?: Partial<Pick<DatumTreeExpectation, "tags" | "coverage">> & { + readonly children?: readonly DatumTreeExpectation[]; + }, +): DatumTreeExpectation { + return { + identity, + range, + attributes, + // Absent props define the defaults (SPEC 11.2, 2.5, 2.6): no tags — the + // plain empty list, never null (12.7) — and coverage "required". + tags: overrides?.tags ?? [], + coverage: overrides?.coverage ?? "required", + children: overrides?.children ?? [], + }; +} + +// The complete expected tree (document order). Root: identity defined (the +// path is valid), tags/coverage the stated structural-absence `null` (11.4, +// 12.7) — never the marker. +const M_TREE: DatumTreeExpectation = { + identity: M_FILE, + range: M_ROOT_RANGE, + attributes: [], + tags: null, + coverage: null, + children: [ + datumLeaf(`${M_FILE}#solo`, M_SOLO_RANGE, [M_SOLO_ID, M_SOLO_COVERAGE], { + coverage: "none", + }), + datumLeaf(UNAVAILABLE, M_RAGREE_RANGE, [M_RAGREE_ID1, M_RAGREE_ID2]), + datumLeaf(UNAVAILABLE, M_RPAIR_RANGE, [M_RPAIR_ID1, M_RPAIR_ID2]), + datumLeaf(UNAVAILABLE, M_BRACEDX_RANGE, [M_BRACEDX_ID]), + datumLeaf(UNAVAILABLE, M_VALUELESS_RANGE, [M_VALUELESS_ID]), + datumLeaf(UNAVAILABLE, M_NOID_RANGE, [], { + children: [ + datumLeaf(UNAVAILABLE, M_ORPHAN_RANGE, [M_ORPHAN_ID], { + children: [datumLeaf(UNAVAILABLE, M_DEEP_RANGE, [M_DEEP_ID])], + }), + ], + }), + datumLeaf(UNAVAILABLE, M_X1_RANGE, [M_X1_ID], { + children: [datumLeaf(`${M_FILE}#x.y`, M_XY_RANGE, [M_XY_ID])], + }), + datumLeaf(UNAVAILABLE, M_X2_RANGE, [M_X2_ID]), + datumLeaf(UNAVAILABLE, M_HASH_RANGE, [M_HASH_ID], { + children: [datumLeaf(UNAVAILABLE, M_HASHKID_RANGE, [M_HASHKID_ID])], + }), + datumLeaf(`${M_FILE}#z`, M_Z_RANGE, [M_Z_ID, M_Z_TAGS], { + tags: ["lone"], + }), + datumLeaf(UNAVAILABLE, M_BRACEDZ_RANGE, [M_BRACEDZ_ID]), + datumLeaf(`${M_FILE}#tr`, M_TR_RANGE, [M_TR_ID, M_TR_TAGS1, M_TR_TAGS2], { + tags: UNAVAILABLE, + }), + datumLeaf(`${M_FILE}#tm`, M_TM_RANGE, [M_TM_ID, M_TM_TAGS], { + tags: UNAVAILABLE, + }), + datumLeaf(`${M_FILE}#ti`, M_TI_RANGE, [M_TI_ID, M_TI_TAGS], { + tags: UNAVAILABLE, + }), + datumLeaf( + `${M_FILE}#cr`, + M_CR_RANGE, + [M_CR_ID, M_CR_COVERAGE1, M_CR_COVERAGE2], + { + coverage: UNAVAILABLE, + }, + ), + datumLeaf(`${M_FILE}#cm`, M_CM_RANGE, [M_CM_ID, M_CM_COVERAGE], { + coverage: UNAVAILABLE, + }), + datumLeaf(`${M_FILE}#ci`, M_CI_RANGE, [M_CI_ID, M_CI_COVERAGE], { + coverage: UNAVAILABLE, + }), + ], +}; + +const T11_2_2 = defineProductTest({ + id: "T11.2-2", + title: + 'one file\'s definedness matrix via bare `view`: exactly one quoted static `id` is defined while repeated (agreeing and disagreeing), braced (`id={"x"}`), valueless, and absent `id` each spell none — identity explicitly unavailable; duplicate spellings of `x` leave both bearers unavailable, no winner, while the uniquely spelled `x.y` beneath one keeps its defined identity (defined without defined prefixes); descendants of a no-`id` and of a malformed-`id` (`ha#sh`) section are undefined by inheritance (grandchild included); the unique `z` stays defined beside a braced `id={"z"}` (an invalid form contests nothing); absent `tags`/`coverage` props define the defaults (no tags, coverage-required) while repeated, malformed, and invalid-valued ones leave the interpreted value unavailable, raw spellings still listed; the answer carries exactly the staged findings (14.1, 14.3, one 14.4 per malformed identity or tag, one 14.17 per afflicted element), each located in the file, exit 1 (SPEC 11.2, 11.4, 2.5-2.7, 14; CERTIFICATIONS.md CONF-AVAIL in scope)', + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline): composed ranges sliced back + // out of the staged bytes before any product invocation. + sliceCheck( + M_SOURCE, + M_SOLO_RANGE, + '<S id="solo" coverage="none">\nSolo text.\n</S>', + "the solo construct", + ); + sliceCheck( + M_SOURCE, + M_BRACEDX_ID.range, + M_BRACEDX_ID.text, + "the braced id attribute", + ); + sliceCheck(M_SOURCE, M_VALUELESS_ID.range, "id", "the valueless id"); + sliceCheck( + M_SOURCE, + M_DEEP_RANGE, + '<S id="orphan.deep">\nDeep text.\n</S>', + "the deep descendant construct", + ); + sliceCheck( + M_SOURCE, + M_TI_TAGS.range, + 'tags="ok bad#tag"', + "the invalid-valued tags attribute", + ); + sliceCheck(M_SOURCE, M_ROOT_RANGE, M_SOURCE, "the whole matrix file"); + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [M_FILE]: M_SOURCE, + }, + }); + try { + const context = "T11.2-2 bare `view` (the matrix file is the domain)"; + const result = await runCli(product, workspace, ["view"]); + assertExitCode( + result, + 1, + `${context} — the answer carries findings and explicitly-unavailable ` + + `datums, so the invocation exits 1 with the full document still ` + + `emitted (SPEC 11.2)`, + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form, ` + + `with or without --json (SPEC 11)`, + ), + { text: false }, + context, + ); + + // Staging integrity and the reporting side of the matrix: exactly the + // staged conditions accompany, every finding located in the file. + assertConditionCounts( + report.findings, + M_CONDITION_COUNTS, + `${context} — exactly the staged conditions accompany the answer ` + + `(SPEC 11.2, 14): one 14.1 (the id-less section), one 14.3 (the ` + + `duplicated x, locating both bearers), three 14.4 (ha#sh, ` + + `ha#sh.kid, the invalid tag bad#tag), ten 14.17 (repeated ` + + `agreeing/disagreeing id, braced id x2, valueless id, repeated ` + + `tags, braced tags, repeated coverage, valueless coverage, ` + + `invalid coverage value) — and nothing masked reports: no 14.1 ` + + `from an invalid-form id (condition 17, never 1) and no 14.2 ` + + `anywhere (the no-id section's child is masked, every other ` + + `child extends its parent's spelling exactly)`, + ); + for (const finding of report.findings) { + assertFindingLocated( + finding, + { file: M_FILE }, + `${context} — the ${finding.condition ?? finding.code ?? "code-less"} finding ` + + `locates in the matrix file (file granularity; range precision ` + + `is T14-8's)`, + ); + } + + // The one requested file's view, with every node's identity datum and + // interpreted tags/coverage per SPEC 11.2 — the matrix itself. + assertSameJson( + report.views.map((view) => view.file), + [M_FILE], + `${context} — one per-file view: the parseable matrix file (SPEC 11.4)`, + ); + assertSameJson( + projectDatumNode(report.views[0]!.root), + M_TREE, + `${context} — the full positional tree with byte-exact construct ` + + `ranges and raw attribute entries, each node's identity datum per ` + + `11.2's spelling/chain/uniqueness rules (defined string or the ` + + `unavailability marker; the root's identity the path) and its ` + + `interpreted tags/coverage (plain value, the root's stated null, ` + + `or the marker; absent props the defaults — no tags as the plain ` + + `empty list, coverage "required")`, + ); + assertSameJson( + [ + report.views[0]!.imports, + report.views[0]!.occurrences, + report.views[0]!.comments, + ], + [[], [], []], + `${context} — the matrix file holds no imports, occurrences, or ` + + `comments: empty arrays, never null (SPEC 12.7)`, + ); + } finally { + await workspace.dispose(); + } + }, +}); + /** TEST-SPEC §11.2, in canonical ID order (SUITE-52). */ -export const section112Tests: readonly ProductTestEntry[] = [T11_2_1]; +export const section112Tests: readonly ProductTestEntry[] = [T11_2_1, T11_2_2]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index b66bca1..84fc7dd 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -299,6 +299,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11-6": ["11.1"], "T11-7": ["11.1"], "T11.2-1": ["11.2"], + "T11.2-2": ["11.2"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From 960e5b32f743518c680278493b614431954505db Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 22:48:31 +0000 Subject: [PATCH 097/226] sdg(phase-9): implement T11.2-3's invalid-paths suite (FP-052) Register T11.2-3 in section-11.2.ts: discovered spec sources specs/a#b.mdx and (Linux-staged) a non-UTF-8-named one keep full views with every node identity, root included, explicitly unavailable; the condition-19 finding accompanies exactly the answers whose consulted domain includes the file (view: spec paths only; occurrences: all; at: the named file alone), the non-UTF-8 path presented in the 12.7 marked byte form; the #-pathed code source's text() call and bare marker still record occurrences with source exactly the unavailability marker; root identity defined exactly when the path is valid. Gate build pins the exact 14.19 multiset over condition-free content, modifying nothing. Traceability: T11.2-3 -> ["11.2"]. Red-as-diagnosed at the gate build's form-exact findings decode against the pre-patch product (which fires exactly the staged 14.19s in the old shape); a scratch conforming fake ran the body green end-to-end and five deviation fakes each failed diagnosed. test:self unchanged: 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 46 ++- test/suite/registry/section-11.2.ts | 614 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 653 insertions(+), 8 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 4d5eead..8dcc18a 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2212,8 +2212,52 @@ certify against FP-091's fixtures once those land. map (assertions toothed). `npm run test:self`: unchanged 4 planned mid-loop reds (certification-document x3 → FP-091; S-1's 6 unmapped keys {11.3–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-052 — Implement T11.2-3: invalid paths (Linux leg). [R2 #2; +- [x] FP-052 — Implement T11.2-3: invalid paths (Linux leg). [R2 #2; TEST-SPEC §11.2] + [Done 2026-08-13: registered in section-11.2.ts (SUITE-52) with + traceability ["11.2"] (no TEST-SPEC 14 staging record — the FP-050/051 + precedent; 12.0/12.7/5.7/1.5 are context with home coverage at T12.0-13/ + T12.7-1/T11.3-1). NOT in CONF-AVAIL scope (its staging — `#` paths, + non-UTF-8 path, a code group — lies outside that scope by construction), + so the gate `build --json` rides staging integrity: exit 1 with EXACTLY + the 14.19 multiset (every file's content deliberately condition-free, so + later identity unavailability is attributable to the paths alone), each + finding pinned as {code "invalid-source-path", locations [], concerned + path} in the 12.7 order, wrapped in a whole-root snapshot compare. One + workspace: valid `specs/OK.mdx` (the defined-side contrast and reference + target), `specs/a#b.mdx` (nested pa > pa.kid with a tags attribute), + Linux-staged `specs/b<0xFF>.mdx` (raw-byte filename via workspace.file; + the T1.5-2 arm-gating precedent for the entry's leg note — the `#` arms + run everywhere, every expectation parameterized on the staging, no test + skip, H-9), and `src/co#de.ts` (multi-byte comment prefix; one + text(SPEC.ok) call inside named unit useText, one bare top-level marker). + Answers: bare `view` → exit 1, per-file views [OK, a#b, b<0xFF>] in path- + byte order with the non-UTF-8 `file` member as the marked byte form + composed from the staging bytes, findings EXACTLY the two spec-path 14.19s + (the code source's concerns no domain file — the accompanies-and-no-other + discrimination), trees pinned via T11.2-1's projection (OK defined; every + invalid-path node identity, root included, the marker); bare `occurrences` + → exit 1, all three 14.19s, enumeration exactly the code source's two + records ({file, range, kind, target} present, source exactly the marker); + `at specs/a#b.mdx` at 0 and at the kid offset → exit 1, exactly its own + 14.19, resolution root/innermost with identity the marker. "No identity + over an invalid path is ever emitted" realized as exact-value pinning of + every identity datum in every captured document. Verified: typecheck/ + format clean; suite section-11.2 red-as-diagnosed — T11.2-3 fails at the + gate build's FP-001-class form-exact decode ("expected no member + \"condition\""; the pre-patch product does fire exactly 3x 14.19 in the + old shape on a direct probe, the old order matching the pinned one, and + view/occurrences/at stay unknown commands); scratch probe: a conforming + fake re-deriving every answer from the workspace bytes (own mini-parser, + byte-wise discovery, hex from real dirent bytes) ran the registered body + green end-to-end, and five deviation fakes each failed diagnosed — + null-for-marker at the form decode (VIOL-AVAIL-NULLMARKER's class), + identities-emitted-over-invalid-paths at the a#b tree compare, + all-findings-attached at the view domain compare, lossy-plain-string + non-UTF-8 path at the build projection, source-undefined-records-dropped + at the enumeration compare. `npm run test:self`: unchanged 4 planned + mid-loop reds (certification-document x3 → FP-091; S-1's 6 unmapped keys + {11.3–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] - [ ] FP-053 — Implement T11.2-4 (CONF-AVAIL): resolution and expanded-text poisoning; record observations ride `occurrences`/`view`. [R2 #2, R3 gap 1, VERIFY; TEST-SPEC §11.2] diff --git a/test/suite/registry/section-11.2.ts b/test/suite/registry/section-11.2.ts index 41ad1f1..cfac1b8 100644 --- a/test/suite/registry/section-11.2.ts +++ b/test/suite/registry/section-11.2.ts @@ -1,5 +1,5 @@ -// TEST-SPEC §11.2 (availability on imperfect files) — SUITE-52: T11.2-1, -// T11.2-2. +// TEST-SPEC §11.2 (availability on imperfect files) — SUITE-52: T11.2-1 +// through T11.2-3. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -11,10 +11,12 @@ // lands with the certification-manifest task). CONF-AVAIL's staging // constraint pins every command an in-scope test drives to its enumerated // `view`/`occurrences` surface, so T11.2-2 runs NO gate-reference `build` -// (unlike T11.2-1, which is not in scope): staging integrity rides the -// `view` answer's own exact accompanying-findings multiset instead, and the -// staged conditions are drawn from the scope's stated set (14.1, 14.3, 14.4, -// 14.17). +// (unlike T11.2-1 and T11.2-3, which are not in scope — CONF-AVAIL's +// workspace scope is `#`-free valid-UTF-8 paths with no code groups, so +// T11.2-3's staging lies outside it by construction): staging integrity +// rides the `view` answer's own exact accompanying-findings multiset +// instead, and the staged conditions are drawn from the scope's stated set +// (14.1, 14.3, 14.4, 14.17). // // SPEC 11.2: `occurrences`, `view`, and `at` answer per file, from parsing // alone, never gated on workspace-wide validity — parse-local structure (the @@ -65,6 +67,7 @@ import { Buffer } from "node:buffer"; import type { Finding, OccurrenceRecord, + PathValue, SourceRange, ViewAttributeEntry, ViewNode, @@ -1177,5 +1180,602 @@ const T11_2_2 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T11.2-3 — invalid paths (Linux leg) +// --------------------------------------------------------------------------- +// +// SPEC 11.2: a node identity is formed over the file's path and requires a +// valid one — in a discovered file whose own path is invalid (14.19: `#` in +// the workspace-relative path, or not valid UTF-8), NO graph node has a +// defined identity, whatever the content spells: a spec source's root and +// every section, a code source's whole-file location and every named unit. +// Such a file keeps its parse-local structure and positions; its +// condition-19 finding accompanies every answer whose consulted domain +// includes it; and no identity over an invalid path is ever emitted or +// resolved against (1.5). A non-UTF-8 path has no plain string form: +// wherever an output carries one — a per-file view's `file`, a finding's +// concerned `path` — it is the marked byte form `{"bytes": …}`, the exact +// bytes as lowercase hexadecimal (12.0, 12.7). +// +// Staging: one workspace, spec group + code group. `specs/OK.mdx` is the +// valid-path contrast (root identity is defined EXACTLY when the file's path +// is valid — both directions in one document) and the reference target; +// `specs/a#b.mdx` (the entry's literal name) and, on the Linux leg, +// `specs/b<0xFF>.mdx` are the invalid-path spec sources; `src/co#de.ts` is +// the invalid-path code source, spelling one `text(SPEC.ok)` call inside a +// named function (kind `embeds`, source would be the unit) and one bare +// top-level marker `SPEC.ok;` (kind `references`, source would be the +// whole-file location) — both targets defined, so both spellings resolve +// and record occurrences whose `source` datum is exactly the unavailability +// marker (5.7, T11.3-1). Every file's CONTENT is deliberately +// condition-free: the gate `build --json` reports exactly the 14.19 +// multiset, so the identity unavailability observed later is attributable +// to the paths alone. +// +// Conservative operationalizations (noted per H-3/H-4): +// - The non-UTF-8 arms are staged exactly when the platform's file names are +// byte strings (`process.platform === "linux"`, the T1.5-2/T6.5-5 +// precedent for the entry's "(Linux leg)" note; other filesystems cannot +// hold the path at all), and every expectation is parameterized on that +// staging: the `#` arms run on every platform, so the Linux CI leg runs +// the whole entry and no platform skips the test (H-9). +// - "the condition-19 finding accompanies every answer whose domain includes +// the file" is asserted in BOTH directions via exact per-answer finding +// sets: bare `view` (domain: the discovered spec sources) carries the spec +// paths' findings and never the code source's — a 14.19 is a domain file's +// through its concerned path (SPEC 11.2) — bare `occurrences` (domain: the +// entire discovered set) carries all of them, and `at specs/a#b.mdx` +// (domain: the named file) carries exactly its own. Per finding, the +// projection pins the stable code token, `locations` empty (a path-level +// condition without in-source locations, SPEC 14, 12.7), and the concerned +// path — the non-UTF-8 one in the marked byte form, composed from the same +// bytes that stage the file; messages stay unpinned (deterministic but +// informational, 12.7). +// - "no identity over the invalid path is ever emitted" is realized as +// exact-value pinning of every identity datum in every captured document: +// the three view trees (markers on every invalid-path node, root +// included; plain identities in OK.mdx), each occurrence record's `source` +// (the marker) and `target` (OK's node), and both `at` resolutions (the +// marker). The form-exact decode additionally rejects a marked-byte-form +// path anywhere a plain identity string is required. +// - The non-UTF-8 file is nameable by no argument value (12.0: argument +// values are UTF-8), so the whole-domain `view` reached without operands +// is its one route to position data (11.5) — `at` runs against +// `specs/a#b.mdx`, whose `#`-containing spelling names the discovered file +// (a bare `<file>` operand is a whole path, `#` has no delimiter role; +// 12.0 — T12.0-13 owns the operand-classification matrix). The exit-2 +// side of addressing the non-UTF-8 file is T11.5-3's arm, not staged here. +// - The gate `build` rides a whole-root snapshot compare (a failing build +// modifies nothing, SPEC 12.1), pinning that every later answer runs on +// the staged ground; the per-invocation no-write sweep is T11.2-1's home +// clause and is not repeated here. + +// One spec group plus one code group (SPEC 7.2), so `src/**/*.ts` files are +// discovered code sources and their spec-module usage is analyzed (4.3, 4.5). +const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + } +}) +`; + +/** Whether the non-UTF-8-named file is staged (module-header note). */ +const NON_UTF8_STAGED = process.platform === "linux"; + +// --- specs/OK.mdx — the valid-path contrast and reference target ------------- +const OK_FILE = "specs/OK.mdx"; +const OK = new ByteFixture(); +OK.add("Préambule — valid-path contrast.\n\n"); +const OK_SEC_START = OK.pos; +OK.add("<S "); +const OK_ID = OK.attr("id", 'id="ok"'); +OK.add(">\nOK text.\n</S>"); +const OK_SEC_RANGE: SourceRange = { start: OK_SEC_START, end: OK.pos }; +OK.add("\n"); +const OK_SOURCE = OK.source; +const OK_ROOT_RANGE: SourceRange = { start: 0, end: OK.pos }; +const OK_NODE_ID = `${OK_FILE}#ok`; + +// --- specs/a#b.mdx — `#`-containing spec path (14.19) ------------------------ +// Nested sections with attributes: the tree, ranges, and raw attribute +// entries stay on view while every identity — root included — is +// unavailable. All spelled identities are well-formed, unique, and +// structurally consistent: the path is the file's ONLY defect. +const HP_FILE = "specs/a#b.mdx"; +const HP = new ByteFixture(); +HP.add("Prélude — invalid `#` path.\n\n"); +const HP_PA_START = HP.pos; +HP.add("<S "); +const HP_PA_ID = HP.attr("id", 'id="pa"'); +HP.add(">\nParent text.\n\n"); +const HP_KID_START = HP.pos; +HP.add("<S "); +const HP_KID_ID = HP.attr("id", 'id="pa.kid"'); +HP.add(" "); +const HP_KID_TAGS = HP.attr("tags", 'tags="deep"'); +HP.add(">\nKid text.\n</S>"); +const HP_KID_RANGE: SourceRange = { start: HP_KID_START, end: HP.pos }; +HP.add("\n</S>"); +const HP_PA_RANGE: SourceRange = { start: HP_PA_START, end: HP.pos }; +HP.add("\n"); +const HP_SOURCE = HP.source; +const HP_ROOT_RANGE: SourceRange = { start: 0, end: HP.pos }; + +// --- specs/b<0xFF>.mdx — non-UTF-8-named spec source (14.19, Linux leg) ------ +// 0xFF can occur in no valid UTF-8 sequence, so the workspace-relative path +// is not valid UTF-8; the byte-wise glob rules of SPEC 7 still discover it. +// The marked byte form is composed from the SAME bytes that stage the file +// (never measured from product output). +const NU_PATH_BYTES = Buffer.concat([ + Buffer.from("specs/b", "utf8"), + Buffer.from([0xff]), + Buffer.from(".mdx", "utf8"), +]); +const NU_MARKED_PATH = { bytes: NU_PATH_BYTES.toString("hex") } as const; +const NU = new ByteFixture(); +NU.add("Prólogo — non-UTF-8 path.\n\n"); +const NU_SEC_START = NU.pos; +NU.add("<S "); +const NU_ID = NU.attr("id", 'id="solo"'); +NU.add(">\nSolo text.\n</S>"); +const NU_SEC_RANGE: SourceRange = { start: NU_SEC_START, end: NU.pos }; +NU.add("\n"); +const NU_SOURCE = NU.source; +const NU_ROOT_RANGE: SourceRange = { start: 0, end: NU.pos }; + +// --- src/co#de.ts — `#`-containing code source (14.19) ----------------------- +// One sanctioned spelling per attribution case (SPEC 4.5, 4.6): the +// `text(SPEC.ok)` call inside the named unit `useText` (its occurrence spans +// the entire call expression, callee through closing parenthesis) and the +// bare top-level marker `SPEC.ok` (whole-file attribution; its occurrence +// spans the bare reference chain alone, exclusive of the terminator). The +// multi-byte comment prefix shifts every later offset (SPEC 1.7). +const CS_FILE = "src/co#de.ts"; +const CS = new ByteFixture(); +CS.add("// Präambel — invalid-path code source.\n"); +CS.add('import SPEC, { text } from "../specs/OK.xspec";\n'); +CS.add("\nexport function useText(): string {\n return "); +const CS_CALL_TEXT = "text(SPEC.ok)"; +const CS_CALL_RANGE = CS.add(CS_CALL_TEXT); +CS.add(";\n}\n\n"); +const CS_MARKER_TEXT = "SPEC.ok"; +const CS_MARKER_RANGE = CS.add(CS_MARKER_TEXT); +CS.add(";\n"); +const CS_SOURCE = CS.source; + +// The invalid-path code source's complete occurrence enumeration (SPEC 5.7, +// 11.2): both spellings resolve (the referenced identity `specs/OK.mdx#ok` +// is defined), so both record — `file`, `range`, `kind`, and `target` +// present, `source` exactly the unavailability marker (identity and range +// withheld together as one datum; never a picked identity, never a dropped +// record). No other staged file holds a reference spelling, so this is the +// workspace's whole enumeration, in occurrence order (range start). +const CS_EXPECTED_OCCURRENCES: readonly OccurrenceRecord[] = [ + { + file: CS_FILE, + range: CS_CALL_RANGE, + kind: "embeds", + source: UNAVAILABLE, + target: OK_NODE_ID, + }, + { + file: CS_FILE, + range: CS_MARKER_RANGE, + kind: "references", + source: UNAVAILABLE, + target: OK_NODE_ID, + }, +]; + +// --- expected trees (T11.2-1's projection: identity/range/attributes) -------- + +const OK_TREE: TreeExpectation = { + identity: OK_FILE, + range: OK_ROOT_RANGE, + attributes: [], + children: [ + { + identity: OK_NODE_ID, + range: OK_SEC_RANGE, + attributes: [OK_ID], + children: [], + }, + ], +}; + +const HP_TREE: TreeExpectation = { + identity: UNAVAILABLE, + range: HP_ROOT_RANGE, + attributes: [], + children: [ + { + identity: UNAVAILABLE, + range: HP_PA_RANGE, + attributes: [HP_PA_ID], + children: [ + { + identity: UNAVAILABLE, + range: HP_KID_RANGE, + attributes: [HP_KID_ID, HP_KID_TAGS], + children: [], + }, + ], + }, + ], +}; + +const NU_TREE: TreeExpectation = { + identity: UNAVAILABLE, + range: NU_ROOT_RANGE, + attributes: [], + children: [ + { + identity: UNAVAILABLE, + range: NU_SEC_RANGE, + attributes: [NU_ID], + children: [], + }, + ], +}; + +// --- expected condition-19 findings ------------------------------------------ + +/** + * The asserted projection of a 14.19 finding (module-header note): the + * stable code token, the empty locations of a path-level condition, and the + * concerned path (SPEC 14, 12.7). Message and identities stay unpinned. + */ +interface PathFindingExpectation { + readonly code: string | null; + readonly locations: readonly unknown[]; + readonly path: PathValue | null; +} + +function projectPathFinding(finding: Finding): PathFindingExpectation { + return { + code: finding.code, + locations: finding.locations, + path: finding.path, + }; +} + +const HP_19: PathFindingExpectation = { + code: "invalid-source-path", + locations: [], + path: HP_FILE, +}; +const NU_19: PathFindingExpectation = { + code: "invalid-source-path", + locations: [], + path: NU_MARKED_PATH, +}; +const CS_19: PathFindingExpectation = { + code: "invalid-source-path", + locations: [], + path: CS_FILE, +}; + +// Pinned 12.7 order among equal-code, location-less findings: by concerned +// path bytes — "specs/a#b.mdx" < "specs/b\xFF.mdx" (a marked byte-form path +// and a plain string sort in one byte order) < "src/co#de.ts". +const WORKSPACE_19S: readonly PathFindingExpectation[] = NON_UTF8_STAGED + ? [HP_19, NU_19, CS_19] + : [HP_19, CS_19]; +const VIEW_DOMAIN_19S: readonly PathFindingExpectation[] = NON_UTF8_STAGED + ? [HP_19, NU_19] + : [HP_19]; +const WORKSPACE_19_COUNTS: Readonly<Record<string, number>> = { + "14.19": NON_UTF8_STAGED ? 3 : 2, +}; + +// Per-file views ordered by byte order of workspace-relative path (SPEC +// 11.4): "specs/OK.mdx" ("O" 0x4f) < "specs/a#b.mdx" ("a" 0x61) < +// "specs/b\xFF.mdx" ("b" 0x62). The code source has no structural view and +// never appears (SPEC 11.4: the view's domain is the discovered spec +// sources). +const EXPECTED_VIEW_FILES: readonly PathValue[] = NON_UTF8_STAGED + ? [OK_FILE, HP_FILE, NU_MARKED_PATH] + : [OK_FILE, HP_FILE]; + +const T11_2_3 = defineProductTest({ + id: "T11.2-3", + title: + "(Linux leg) invalid paths: the discovered spec sources `specs/a#b.mdx` and — staged where file names are byte strings — a non-UTF-8-named `specs/b<0xFF>.mdx` keep full views (tree, byte-exact construct ranges, raw attribute entries) with every node identity, root included, explicitly unavailable, while `specs/OK.mdx` beside them keeps defined identities — root identity defined exactly when the file's path is valid; the condition-19 finding (stable code `invalid-source-path`, no locations, the file as concerned path — the non-UTF-8 path in the marked byte form `{\"bytes\": …}`) accompanies every answer whose consulted domain includes the file and no other: bare `view` carries exactly the spec paths' findings (never the code source's), bare `occurrences` every 14.19, `at specs/a#b.mdx` exactly its own; the code source `src/co#de.ts` defines no identity for its whole-file location or any unit, its `text(SPEC.ok)` call and bare marker still recording occurrences with `source` exactly the unavailability marker and `file`, `range`, `kind`, `target` present; no identity over an invalid path is ever emitted (every identity datum in every captured document pinned); the gate `build --json` fails with exactly the staged 14.19 multiset, modifying nothing (SPEC 11.2, 11.3-11.5, 12.0, 12.7, 5.7, 1.5, 14)", + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline): composed ranges sliced back + // out of the staged bytes before any product invocation. + sliceCheck( + OK_SOURCE, + OK_SEC_RANGE, + '<S id="ok">\nOK text.\n</S>', + "OK's section construct", + ); + sliceCheck( + HP_SOURCE, + HP_KID_RANGE, + '<S id="pa.kid" tags="deep">\nKid text.\n</S>', + "the nested kid construct", + ); + sliceCheck(HP_SOURCE, HP_PA_ID.range, HP_PA_ID.text, "pa's id attribute"); + sliceCheck( + NU_SOURCE, + NU_SEC_RANGE, + '<S id="solo">\nSolo text.\n</S>', + "the non-UTF-8-named file's section construct", + ); + sliceCheck( + CS_SOURCE, + CS_CALL_RANGE, + CS_CALL_TEXT, + "the text(...) call expression", + ); + sliceCheck( + CS_SOURCE, + CS_MARKER_RANGE, + CS_MARKER_TEXT, + "the bare marker chain", + ); + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + [OK_FILE]: OK_SOURCE, + [HP_FILE]: HP_SOURCE, + [CS_FILE]: CS_SOURCE, + }, + }); + try { + if (NON_UTF8_STAGED) { + await workspace.file(NU_PATH_BYTES, NU_SOURCE); + } + + // --- The gate reference and staging integrity: `build` fails with + // EXACTLY the 14.19 multiset — the content of every file stages no + // other condition, so later identity unavailability is attributable + // to the paths alone. Each finding pinned: stable code, no locations + // (a path-level condition), the concerned path — the non-UTF-8 one in + // the marked byte form (SPEC 14, 12.0, 12.7). + const buildContext = + "T11.2-3 `build --json` (the gate reference: the workspace fails " + + "`build` on exactly the staged invalid-path conditions)"; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + ["build", "--json"], + 1, + buildContext, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, buildContext), + buildContext, + ).findings; + assertConditionCounts( + findings, + WORKSPACE_19_COUNTS, + `${buildContext} — one 14.19 per invalid-path discovered ` + + `source and nothing else: every file's content is ` + + `condition-free`, + ); + assertSameJson( + findings.map(projectPathFinding), + WORKSPACE_19S, + `${buildContext} — each finding carries the stable code ` + + `"invalid-source-path", no in-source locations, and the ` + + `offending file as its concerned path — the non-UTF-8 path ` + + `presented in the marked byte form (SPEC 14, 12.0, 12.7)`, + ); + }, + `${buildContext} — a failing build modifies nothing (SPEC 12.1)`, + ); + + // --- Bare `view` (whole domain: every discovered spec source, the + // one route to the non-UTF-8 file — nameable by no argument value). + const viewContext = + "T11.2-3 bare `view` (whole domain: every discovered spec source)"; + const viewResult = await runCli(product, workspace, ["view"]); + assertExitCode( + viewResult, + 1, + `${viewContext} — the answer carries findings and ` + + `explicitly-unavailable identities, so the invocation exits 1 ` + + `with the full document still emitted (SPEC 11.2)`, + ); + const viewReport = decodeViewReport( + parseJsonStdout( + viewResult, + `${viewContext} — a single JSON document is the only output ` + + `form, with or without --json (SPEC 11)`, + ), + { text: false }, + viewContext, + ); + assertSameJson( + viewReport.findings.map(projectPathFinding), + VIEW_DOMAIN_19S, + `${viewContext} — the condition-19 finding accompanies every ` + + `answer whose consulted domain includes the file AND NO OTHER ` + + `(SPEC 11.2): the requested spec sources' findings exactly — the ` + + `code source's 14.19 concerns no domain file and must not attach`, + ); + assertSameJson( + viewReport.views.map((view) => view.file), + EXPECTED_VIEW_FILES, + `${viewContext} — per-file views for every discovered spec source ` + + `in path-byte order, the non-UTF-8 file's \`file\` member ` + + `presented in the marked byte form — its exact bytes as ` + + `lowercase hexadecimal, never a plain string (SPEC 11.4, 12.0, ` + + `12.7)`, + ); + const okView = viewReport.views[0]!; + const hpView = viewReport.views[1]!; + assertSameJson( + projectNode(okView.root), + OK_TREE, + `${viewContext} — the valid-path file's identities are DEFINED ` + + `(root: the path; section: path#id): root identity is defined ` + + `exactly when the file's path is valid (SPEC 11.2)`, + ); + assertSameJson( + projectNode(hpView.root), + HP_TREE, + `${viewContext} — specs/a#b.mdx keeps its full positional tree ` + + `with byte-exact construct ranges and raw attribute entries ` + + `while every node identity, root included, is explicitly ` + + `unavailable — no identity over an invalid path is ever emitted ` + + `(SPEC 11.2, 1.5)`, + ); + assertSameJson( + [ + [okView.imports, okView.occurrences, okView.comments], + [hpView.imports, hpView.occurrences, hpView.comments], + ], + [ + [[], [], []], + [[], [], []], + ], + `${viewContext} — the spec files hold no imports, occurrences, or ` + + `comments: empty arrays, never null (SPEC 12.7)`, + ); + if (NON_UTF8_STAGED) { + const nuView = viewReport.views[2]!; + assertSameJson( + projectNode(nuView.root), + NU_TREE, + `${viewContext} — the non-UTF-8-named file keeps its full ` + + `positional tree, every node identity explicitly unavailable, ` + + `root included (SPEC 11.2)`, + ); + assertSameJson( + [nuView.imports, nuView.occurrences, nuView.comments], + [[], [], []], + `${viewContext} — the non-UTF-8-named file holds no imports, ` + + `occurrences, or comments (SPEC 12.7)`, + ); + } + + // --- Bare `occurrences` (the entire discovered set, SPEC 11.3): + // every 14.19 accompanies — the code source's included — and the + // invalid-path code source's spellings still record, `source` + // exactly the unavailability marker (SPEC 5.7, 11.2). + const occContext = "T11.2-3 bare `occurrences`"; + const occResult = await runCli(product, workspace, ["occurrences"]); + assertExitCode( + occResult, + 1, + `${occContext} — the enumeration carries the domain's findings and ` + + `explicitly-unavailable source datums, so exit 1 with the full ` + + `answer (SPEC 11.2, 11.3)`, + ); + const occReport = decodeOccurrencesReport( + parseJsonStdout( + occResult, + `${occContext} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + occContext, + ); + assertSameJson( + occReport.findings.map(projectPathFinding), + WORKSPACE_19S, + `${occContext} — the consulted domain is the entire discovered ` + + `set, so every invalid path's condition-19 finding accompanies, ` + + `the code source's included (SPEC 11.2, 11.3)`, + ); + assertSameJson( + occReport.occurrences, + CS_EXPECTED_OCCURRENCES, + `${occContext} — the invalid-path code source's spellings still ` + + `record occurrences: the text(...) call (embeds, spanning the ` + + `whole call expression) and the bare marker (references, ` + + `spanning the chain alone), each record's source EXACTLY the ` + + `unavailability marker — identity and range withheld together as ` + + `one datum, never a picked identity, never a dropped record — ` + + `while file, range, kind, and target are present (SPEC 5.7, 11.2)`, + ); + + // --- `at specs/a#b.mdx <offset>` (SPEC 11.5): the `#`-containing + // spelling names the discovered file (a bare <file> operand is a + // whole path, 12.0); the consulted domain is the named file alone, so + // exactly its own condition-19 finding accompanies, and the + // resolution's identity is the marker — offset 0 resolves to the + // root (prose before any section), the kid-construct offset to the + // innermost section. + const atCases: readonly { + readonly offset: number; + readonly what: string; + readonly range: SourceRange; + }[] = [ + { + offset: 0, + what: + "offset 0 (prose) resolves to the ROOT, its identity " + + "explicitly unavailable — the root of an invalid-path file " + + "included (SPEC 11.2, 11.5)", + range: HP_ROOT_RANGE, + }, + { + offset: HP_KID_RANGE.start, + what: + "the kid-construct offset resolves to the innermost " + + "section, its identity explicitly unavailable (SPEC 11.2, 11.5)", + range: HP_KID_RANGE, + }, + ]; + for (const atCase of atCases) { + const atContext = `T11.2-3 \`at ${HP_FILE} ${String(atCase.offset)}\``; + const atResult = await runCli(product, workspace, [ + "at", + HP_FILE, + String(atCase.offset), + ]); + assertExitCode( + atResult, + 1, + `${atContext} — the answer carries the file's finding and an ` + + `unavailable identity, so exit 1 with the full answer ` + + `(SPEC 11.2, 11.5)`, + ); + const atReport = decodeAtReport( + parseJsonStdout( + atResult, + `${atContext} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + atContext, + ); + assertSameJson( + atReport.findings.map(projectPathFinding), + [HP_19], + `${atContext} — the consulted domain is the named file alone: ` + + `exactly its condition-19 finding, never the other invalid ` + + `paths' (SPEC 11.2, 11.5)`, + ); + assertSameJson( + atReport.resolution, + { + section: { identity: UNAVAILABLE, range: atCase.range }, + occurrence: null, + }, + `${atContext} — ${atCase.what}`, + ); + } + } finally { + await workspace.dispose(); + } + }, +}); + /** TEST-SPEC §11.2, in canonical ID order (SUITE-52). */ -export const section112Tests: readonly ProductTestEntry[] = [T11_2_1, T11_2_2]; +export const section112Tests: readonly ProductTestEntry[] = [ + T11_2_1, + T11_2_2, + T11_2_3, +]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 84fc7dd..bf2788d 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -300,6 +300,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11-7": ["11.1"], "T11.2-1": ["11.2"], "T11.2-2": ["11.2"], + "T11.2-3": ["11.2"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From 9699c23d08cd760e58b8498895dc79dd688d2b05 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 23:23:51 +0000 Subject: [PATCH 098/226] sdg(phase-9): implement T11.2-4's resolution and expanded-text matrix (FP-053) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 55 +- test/suite/registry/section-11.2.ts | 1241 ++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 1283 insertions(+), 14 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 8dcc18a..9e4d92f 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2258,9 +2258,62 @@ certify against FP-091's fixtures once those land. at the enumeration compare. `npm run test:self`: unchanged 4 planned mid-loop reds (certification-document x3 → FP-091; S-1's 6 unmapped keys {11.3–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-053 — Implement T11.2-4 (CONF-AVAIL): resolution and expanded-text +- [x] FP-053 — Implement T11.2-4 (CONF-AVAIL): resolution and expanded-text poisoning; record observations ride `occurrences`/`view`. [R2 #2, R3 gap 1, VERIFY; TEST-SPEC §11.2] + [Done 2026-08-13: registered in section-11.2.ts (SUITE-52) with + traceability ["11.2"] (no TEST-SPEC 14 staging record — the FP-050..052 + precedent; 11.3/11.4/5.7/1.6/3/12.7 are context with home coverage at + T11.3-1/T11.4-5/T5.7-*/T12.7-1). CONF-AVAIL in scope (VIOL-AVAIL- + NULLMARKER and VIOL-AVAIL-OMIT certify it; the fixture family lands with + FP-091); scope constraints honored: the whole entry drives ONLY bare + `view` (with/without --text) and bare `occurrences` — no gate `build`, no + `at`, no `--file` — staging integrity riding each answer's own exact + findings multiset, conditions drawn from the scope's stated set. Four + stagings: (1) specs/R.mdx — duplicate `a` bearers with unique `a.b` + beneath the first; `d={"a.b"}` on the second bearer and `{text("a.b")}` + in an id-less section each resolve and record with `source` EXACTLY the + marker (file/range/kind/target present) — pinned as the complete + two-record enumeration via BOTH surfaces (never a picked bearer, never a + dropped record, never an unavailable target — the form decode also admits + only plain-string targets) — while `d={"a"}` records none, its 14.5 + located within the opening tag spelling it; findings exactly {14.1, 14.3 + locating BOTH bearers, 14.5}, the view tree positioning every enclosing + construct with identities per 11.2. (2) chain CH-A embeds CH-B embeds + CH-C with `{text("nosuch")}` in C: bare `view --text` — exactly one 14.6, + its location's range EXACTLY the braced container (SPEC 14 pins the + embedding form); top/mid/deep own+subtree text EXACTLY the marker, + sibling side (expansion inserted) and ok byte-exact, every root's own + text defined beside its poisoned subtree text; imports/occurrences/ + comments pinned whole. (3) separately staged self-embedding cycle CY.mdx: + `{text("self")}` resolves and records (occurrence pinned), exactly one + 14.9 located at the participating spelling, self poisoned, calm and the + root's own text defined byte-exact. (4) removal-by-form: IMP.mdx with an + unused-binding import of GONE.xspec plus a stray <div> (14.16, located, + preserved byte-for-byte in the enclosing text, no view node); after + fsp.rm(GONE.mdx) the SAME pinned tree asserts text values byte-identical + (the import removed by form) while the import entry's target flips to the + marker and 14.15 (located at the declaration) joins 14.16 — exact counts + both sides, exit 1 everywhere. Expected text values hand-derived per the + rules of 3 (derivation comments beside each constant), composed from the + staged parts. Verified: typecheck/format clean; suite section-11.2 + red-as-diagnosed — T11.2-4 fails at the first arm's exit assertion + (`view` unknown command, exit 2, the pre-patch product gap); scratch + probe: a conforming fake re-deriving every answer from the workspace + bytes (own mini-parser, line-attribution rules-of-3 renderer with + expansion-stack cycle detection) ran the registered body green + end-to-end, and six deviation fakes each failed diagnosed — + null-for-marker at the form decode (VIOL-AVAIL-NULLMARKER's class), + picked-bearer and dropped-record at the enumeration compare, + partial-expansion at the poisoned-tree compare, remove-imports-by- + resolution at the after-deletion pinned tree, ambiguous-ref-resolves at + the count map; direct `build --json` probes of all four stagings against + the built product confirm the staged conditions fire in a real parser + (14.3+14.1; 14.6; 14.9; 14.16 then 14.15+14.16 — no 14.20 anywhere; the + absent 14.5 is exactly the pre-patch resolution-semantics gap the arm + discriminates). `npm run test:self`: unchanged 4 planned mid-loop reds + (certification-document x3 → FP-091; S-1's 6 unmapped keys {11.3–11.6, + 12.6, 12.7} → stage G), S-5 and certification green.] - [ ] FP-054 — Implement T11.2-5: domain/findings/exit discipline. [R2 #2; TEST-SPEC §11.2] - [ ] FP-055 — Implement T11.2-6: never-stale + gate-findings-never-attach. diff --git a/test/suite/registry/section-11.2.ts b/test/suite/registry/section-11.2.ts index cfac1b8..33bfb19 100644 --- a/test/suite/registry/section-11.2.ts +++ b/test/suite/registry/section-11.2.ts @@ -1,22 +1,24 @@ // TEST-SPEC §11.2 (availability on imperfect files) — SUITE-52: T11.2-1 -// through T11.2-3. +// through T11.2-4. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), // asserts exact exit codes (H-5), and rejects a product only via diagnosed // assertion failures (H-8). // -// Certification (CERTIFICATIONS.md CONF-AVAIL): T11.2-2 is in scope — -// VIOL-AVAIL-NULLMARKER and VIOL-AVAIL-OMIT certify it (the fixture family -// lands with the certification-manifest task). CONF-AVAIL's staging -// constraint pins every command an in-scope test drives to its enumerated -// `view`/`occurrences` surface, so T11.2-2 runs NO gate-reference `build` -// (unlike T11.2-1 and T11.2-3, which are not in scope — CONF-AVAIL's -// workspace scope is `#`-free valid-UTF-8 paths with no code groups, so -// T11.2-3's staging lies outside it by construction): staging integrity -// rides the `view` answer's own exact accompanying-findings multiset -// instead, and the staged conditions are drawn from the scope's stated set -// (14.1, 14.3, 14.4, 14.17). +// Certification (CERTIFICATIONS.md CONF-AVAIL): T11.2-2 and T11.2-4 are in +// scope — VIOL-AVAIL-NULLMARKER and VIOL-AVAIL-OMIT certify both (the +// fixture family lands with the certification-manifest task). CONF-AVAIL's +// staging constraint pins every command an in-scope test drives to its +// enumerated `view`/`occurrences` surface, so T11.2-2 and T11.2-4 run NO +// gate-reference `build`, no `at`, and no `--file` on `occurrences` +// (VIOL-AVAIL-NOFILE's staging constraint) — unlike T11.2-1 and T11.2-3, +// which are not in scope (CONF-AVAIL's workspace scope is `#`-free +// valid-UTF-8 paths with no code groups, so T11.2-3's staging lies outside +// it by construction): staging integrity rides each answer's own exact +// accompanying-findings multiset instead, and the staged conditions are +// drawn from the scope's stated set (T11.2-2: 14.1, 14.3, 14.4, 14.17; +// T11.2-4: 14.1, 14.3, 14.5, 14.6, 14.9, 14.15, 14.16). // // SPEC 11.2: `occurrences`, `view`, and `at` answer per file, from parsing // alone, never gated on workspace-wide validity — parse-local structure (the @@ -64,12 +66,14 @@ // asserted at file granularity only (range precision is T14-8's). import { Buffer } from "node:buffer"; +import * as fsp from "node:fs/promises"; import type { Finding, OccurrenceRecord, PathValue, SourceRange, ViewAttributeEntry, + ViewImportEntry, ViewNode, } from "../../helpers/adapters/index.js"; import { @@ -78,7 +82,11 @@ import { decodeOccurrencesReport, decodeViewReport, } from "../../helpers/adapters/index.js"; -import { assertExitCode, parseJsonStdout } from "../../helpers/assertions.js"; +import { + assertExitCode, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; @@ -1773,9 +1781,1216 @@ const T11_2_3 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T11.2-4 — resolution and expanded text +// --------------------------------------------------------------------------- +// +// SPEC 11.2 resolution: a reference spelling resolves exactly when it names +// exactly one target whose own node identity is DEFINED — so a reference to +// the one section spelling `a.b` resolves and records an occurrence (5.7) +// even while duplicate spellings of `a` leave every bearer of `a` undefined, +// and a reference to `a` itself records no edge and no occurrence — +// ambiguous, every bearer undefined — and never reports an unavailable +// target: its position reaches consumers through its finding's range (14). +// Source-side unavailability (5.7): a resolving spelling inside a section +// whose own identity is undefined still records, the record carrying `file`, +// its own `range`, `kind`, and `target` with `source` exactly the +// unavailability marker — identity and range withheld together as one datum, +// never a picked bearer's identity, never a dropped record. Expanded text +// (11.2, 1.6, 3): an own/subtree text value is defined exactly when every +// embedding its expansion transitively reaches records an occurrence and the +// recursion re-enters no node already being expanded — one unresolved +// spelling or one embedding cycle on the expansion path poisons the WHOLE +// value (partial expansion is fabrication and never occurs) — and removal +// classification is by syntactic form, never by validity or resolution: +// every import declaration is removed by form (target discovery +// notwithstanding), while a construct matching no removal rule's form (a +// stray element, 14.16) is content, preserved byte-for-byte. +// +// CONF-AVAIL scope (module header): the whole entry drives ONLY bare `view` +// (with and without `--text`) and bare `occurrences` — no gate-reference +// `build`, no `at`, no `--file` (the record observations ride `occurrences` +// and `view`, per the scope's staging constraints). Staging integrity rides +// each answer's own exact findings multiset (the T11.2-2 discipline). +// +// Conservative operationalizations (noted per H-3/H-4): +// - The ambiguous reference to `a` is staged in the `d` entry form (14.5) — +// the one staged condition set drawn from CONF-AVAIL's stated scope; the +// unresolved-embedding form (14.6) rides the expansion chain's boundary +// spelling, where SPEC 14 pins the finding range exactly (the full braced +// container, the span its occurrence would occupy), asserted exactly +// there. Every other located finding is asserted as an exact location +// COUNT (one per offending construct — SPEC 14's cardinality rule: both +// bearers for the duplicate-ID finding) with each range inside the +// offending construct's byte window (end-widened by one byte): the +// ambiguous `d` reference's finding inside the opening tag that spells +// the reference, the cycle's inside its participating embedding +// container's line, 14.1/14.15/14.16 inside their constructs — file and +// construct discrimination without pinning T14-8's range precision. +// - Import-declaration view entries pin the declaration's range as exactly +// its own characters (no terminator) — the 1.7 construct convention — +// with `name` the default binding's identifier and `target` the resolved +// path or the marker (SPEC 11.4). +// - Expected own/subtree text values are hand-derived per the rules of 3 +// (line-by-line derivation comments beside each constant; line-drop rule +// included) and composed from the same string parts that stage the files +// wherever an expansion inserts bytes. +// - "Text values byte-identical to before" (the deleted-import arm) is +// realized by pinning the SAME expected tree on both sides of the +// deletion: equality with one pinned constant on each side implies +// before/after byte identity AND pins the by-form import removal on both +// sides (a remove-by-resolution product leaves the import line in the +// compiled text once the target is gone, failing the after-side pin). +// - The `--text` tree projection pins identity, construct range, ownText, +// subtreeText, and tree shape; attribute entries and interpreted +// tags/coverage stay at their home tests (T11.2-1/-2, T11.4-1/-3), their +// forms still decode-validated (H-3). +// - "Never an unavailable target" is enforced twice: the form decode admits +// only a plain identity string as a record's `target` (12.7), and every +// enumeration is pinned as a complete exact set (a phantom record for the +// ambiguous spelling fails the compare — "never a dropped record" rides +// the same exactness for the two resolving spellings). + +/** A window check for one located finding (SPEC 14 location cardinality). */ +interface LocationWindowExpectation { + readonly file: string; + readonly window: { readonly start: number; readonly end: number }; +} + +/** An offending construct's byte window: its range, end-widened by one. */ +function widened(range: SourceRange): { start: number; end: number } { + return { start: range.start, end: range.end + 1 }; +} + +/** + * Assert a located finding's concern exactly: `path` null (a located + * condition, SPEC 12.7), exactly one location per offending construct (SPEC + * 14's cardinality rule), each — in 12.7 location order, which the decode + * has already enforced — lying in its expected file with its range inside + * the offending construct's byte window. + */ +function assertLocatedFinding( + finding: Finding, + expected: readonly LocationWindowExpectation[], + context: string, +): void { + assertSameJson( + finding.path, + null, + `${context} — a located condition's concerned path is null (SPEC 12.7)`, + ); + if (finding.locations.length !== expected.length) { + fail( + `${context}: expected exactly ${String(expected.length)} location(s) — ` + + `one per offending construct (SPEC 14) — got ` + + `${String(finding.locations.length)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + expected.forEach((want, index) => { + const location = finding.locations[index]!; + if (location.file !== want.file) { + fail( + `${context}: location ${String(index)} must lie in ` + + `${JSON.stringify(want.file)}, got ` + + `${JSON.stringify(location.file)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + if ( + location.range.start < want.window.start || + location.range.end > want.window.end + ) { + fail( + `${context}: location ${String(index)} ` + + `[${String(location.range.start)}, ${String(location.range.end)}) ` + + `must fall within the offending construct's byte window ` + + `[${String(want.window.start)}, ${String(want.window.end)}] ` + + `(message: ${JSON.stringify(finding.message)})`, + ); + } + }); +} + +/** The one finding of a condition — counts asserted beforehand. */ +function findingByCondition( + findings: readonly Finding[], + condition: string, + context: string, +): Finding { + const matches = findings.filter((finding) => finding.condition === condition); + if (matches.length !== 1) { + fail( + `${context}: expected exactly one ${condition} finding, got ` + + `${String(matches.length)}`, + ); + } + return matches[0]!; +} + +// --- staging 1: specs/R.mdx — the resolution matrix --------------------------- +// +// Duplicate spellings of `a` (both bearers undefined, one 14.3 locating +// both) with the unique `a.b` beneath the FIRST bearer (defined without +// defined prefixes, SPEC 11.2); the SECOND bearer carries `d={"a.b"}` — a +// resolving spelling inside a duplicate-`id` bearer; an id-less section +// (14.1) holds `{text("a.b")}` — a resolving spelling inside a section +// spelling no identity; and the defined `q` carries `d={"a"}` — the +// ambiguous reference, recording nothing and reporting 14.5. The multi-byte +// prefix shifts every later offset (SPEC 1.7). + +const R_FILE = "specs/R.mdx"; +const R = new ByteFixture(); +R.add("Prélude — resolution turns on the target identity's definedness.\n\n"); +const R_A1_START = R.pos; +R.add("<S "); +const R_A1_ID = R.attr("id", 'id="a"'); +R.add(">\nFirst bearer.\n\n"); +const R_AB_START = R.pos; +R.add("<S "); +const R_AB_ID = R.attr("id", 'id="a.b"'); +R.add(">\nTarget text.\n</S>"); +const R_AB_RANGE: SourceRange = { start: R_AB_START, end: R.pos }; +R.add("\n</S>"); +const R_A1_RANGE: SourceRange = { start: R_A1_START, end: R.pos }; +R.add("\n\n"); +const R_A2_START = R.pos; +R.add("<S "); +const R_A2_ID = R.attr("id", 'id="a"'); +R.add(" "); +const R_A2_D = R.attr("d", 'd={"a.b"}'); +R.add(">\nSecond bearer.\n</S>"); +const R_A2_RANGE: SourceRange = { start: R_A2_START, end: R.pos }; +R.add("\n\n"); +const R_NOID_START = R.pos; +R.add("<S>\nNo identity here.\n\n"); +const R_EMBED_TEXT = '{text("a.b")}'; +const R_EMBED_RANGE = R.add(R_EMBED_TEXT); +R.add("\n</S>"); +const R_NOID_RANGE: SourceRange = { start: R_NOID_START, end: R.pos }; +R.add("\n\n"); +const R_Q_START = R.pos; +R.add("<S "); +const R_Q_ID = R.attr("id", 'id="q"'); +R.add(" "); +const R_Q_D = R.attr("d", 'd={"a"}'); +R.add(">"); +const R_Q_OPEN_END = R.pos; +R.add("\nAmbiguous reference.\n</S>"); +const R_Q_RANGE: SourceRange = { start: R_Q_START, end: R.pos }; +R.add("\n"); +const R_SOURCE = R.source; +const R_ROOT_RANGE: SourceRange = { start: 0, end: R.pos }; + +const R_AB_NODE_ID = `${R_FILE}#a.b`; +const R_A2_D_REF = dLiteralRange(R_A2_D); + +// The view positions each enclosing construct (SPEC 11.4), identities per +// 11.2: both `a` bearers and the id-less section explicitly unavailable +// (no winner picked; `id` absent), `a.b` and `q` defined. +const R_TREE: TreeExpectation = { + identity: R_FILE, + range: R_ROOT_RANGE, + attributes: [], + children: [ + { + identity: UNAVAILABLE, + range: R_A1_RANGE, + attributes: [R_A1_ID], + children: [ + { + identity: R_AB_NODE_ID, + range: R_AB_RANGE, + attributes: [R_AB_ID], + children: [], + }, + ], + }, + { + identity: UNAVAILABLE, + range: R_A2_RANGE, + attributes: [R_A2_ID, R_A2_D], + children: [], + }, + { + identity: UNAVAILABLE, + range: R_NOID_RANGE, + attributes: [], + children: [], + }, + { + identity: `${R_FILE}#q`, + range: R_Q_RANGE, + attributes: [R_Q_ID, R_Q_D], + children: [], + }, + ], +}; + +// The workspace's COMPLETE enumeration (SPEC 5.7, 11.2): the two resolving +// spellings record — each record's `source` exactly the unavailability +// marker (identity and range withheld together as one datum), `file`, +// `range`, `kind`, `target` present — while the ambiguous reference to `a` +// records nothing: no record, no unavailable target (the exact set pins +// both "never a picked bearer's identity" and "never a dropped record"). +const R_EXPECTED_OCCURRENCES: readonly OccurrenceRecord[] = [ + { + file: R_FILE, + range: R_A2_D_REF, + kind: "depends", + source: UNAVAILABLE, + target: R_AB_NODE_ID, + }, + { + file: R_FILE, + range: R_EMBED_RANGE, + kind: "embeds", + source: UNAVAILABLE, + target: R_AB_NODE_ID, + }, +]; + +// Exactly the staged conditions (SPEC 11.2, 14) — staging integrity without +// a `build` gate (CONF-AVAIL surface constraint): one 14.1 (the id-less +// section), one 14.3 (the duplicated `a`, locating both bearers), one 14.5 +// (the ambiguous `d` reference — reported by its finding's range, never as +// a record). No 14.2 anywhere: `a.b` extends its parent's spelling exactly, +// the id-less section's structural check is masked and it has no section +// children, and every other spelled identity is one segment at top level. +const R_CONDITION_COUNTS: Readonly<Record<string, number>> = { + "14.1": 1, + "14.3": 1, + "14.5": 1, +}; + +// --- staging 2: the embedding chain (CH-A embeds CH-B embeds CH-C) ------------ +// +// A#top embeds B#mid (node form via import), B#mid embeds C#deep, and +// C#deep holds the unresolved `{text("nosuch")}` (14.6) — one unresolved +// spelling on the expansion path poisons top's and mid's (and deep's) whole +// own/subtree values; the siblings with resolved or embedding-free +// expansions (A#side embedding B#ok, and B#ok itself) stay defined and +// byte-exact; each root's own text is defined (no embedding in any root's +// own contribution) while each root's subtree text is poisoned through its +// section. Every id is unique and well-formed, every import valid: the +// 14.6 is the workspace's ONLY condition. + +const CH_A_FILE = "specs/CH-A.mdx"; +const CH_B_FILE = "specs/CH-B.mdx"; +const CH_C_FILE = "specs/CH-C.mdx"; + +const CHA = new ByteFixture(); +CHA.add("Rôle — chain head.\n\n"); +const CHA_IMPORT_TEXT = 'import B from "./CH-B.xspec"'; +const CHA_IMPORT_RANGE = CHA.add(CHA_IMPORT_TEXT); +CHA.add("\n\n"); +const CHA_TOP_START = CHA.pos; +CHA.add('<S id="top">\nTop head.\n\n'); +const CHA_EMBED_MID_RANGE = CHA.add("{text(B.mid)}"); +CHA.add("\n</S>"); +const CHA_TOP_RANGE: SourceRange = { start: CHA_TOP_START, end: CHA.pos }; +CHA.add("\n\n"); +const CHA_SIDE_START = CHA.pos; +CHA.add('<S id="side">\nSide head.\n\n'); +const CHA_EMBED_OK_RANGE = CHA.add("{text(B.ok)}"); +CHA.add("\n</S>"); +const CHA_SIDE_RANGE: SourceRange = { start: CHA_SIDE_START, end: CHA.pos }; +CHA.add("\n"); +const CH_A_SOURCE = CHA.source; +const CH_A_ROOT_RANGE: SourceRange = { start: 0, end: CHA.pos }; + +const CHB = new ByteFixture(); +CHB.add("Über — chain middle.\n\n"); +const CHB_IMPORT_TEXT = 'import C from "./CH-C.xspec"'; +const CHB_IMPORT_RANGE = CHB.add(CHB_IMPORT_TEXT); +CHB.add("\n\n"); +const CHB_MID_START = CHB.pos; +CHB.add('<S id="mid">\nMid head.\n\n'); +const CHB_EMBED_DEEP_RANGE = CHB.add("{text(C.deep)}"); +CHB.add("\n</S>"); +const CHB_MID_RANGE: SourceRange = { start: CHB_MID_START, end: CHB.pos }; +CHB.add("\n\n"); +const CHB_OK_START = CHB.pos; +CHB.add('<S id="ok">\nOK line.\n</S>'); +const CHB_OK_RANGE: SourceRange = { start: CHB_OK_START, end: CHB.pos }; +CHB.add("\n"); +const CH_B_SOURCE = CHB.source; +const CH_B_ROOT_RANGE: SourceRange = { start: 0, end: CHB.pos }; + +const CHC = new ByteFixture(); +CHC.add("Café — chain tail.\n\n"); +const CHC_DEEP_START = CHC.pos; +CHC.add('<S id="deep">\nDeep head.\n\n'); +const CHC_NOSUCH_TEXT = '{text("nosuch")}'; +const CHC_NOSUCH_RANGE = CHC.add(CHC_NOSUCH_TEXT); +CHC.add("\n</S>"); +const CHC_DEEP_RANGE: SourceRange = { start: CHC_DEEP_START, end: CHC.pos }; +CHC.add("\n"); +const CH_C_SOURCE = CHC.source; +const CH_C_ROOT_RANGE: SourceRange = { start: 0, end: CHC.pos }; + +// Expected text values, derived per the rules of 3 (SPEC 3, 1.6). Line +// derivations (each file): the import line and every `<S>`/`</S>` line are +// removed and left empty purely by removals, so each is dropped WITH its +// terminator; blank source lines (never non-whitespace) are preserved; a +// replaced `{text(...)}` line keeps its own terminator after the inserted +// expansion. +// +// CH-B#ok's construct contributes only its body line: +const CH_B_OK_TEXT = "OK line.\n"; +// CH-A#side: "Side head.\n" + blank "\n" + (expansion of B.ok inserted in +// place of the container, then the line's own terminator): +const CH_A_SIDE_TEXT = "Side head.\n\n" + CH_B_OK_TEXT + "\n"; +// Each root's own text: title line + the blank line after it + the blank +// line left after the dropped import line (where one exists), then the +// blank line between the two sections joined at the excision points; the +// dropped final `</S>` line leaves nothing after the last section. +const CH_A_ROOT_OWN = "Rôle — chain head.\n\n\n\n"; +const CH_B_ROOT_OWN = "Über — chain middle.\n\n\n\n"; +// CH-C has no import and no second section: title + one blank line. +const CH_C_ROOT_OWN = "Café — chain tail.\n\n"; + +/** + * T11.2-4's `--text` tree projection: identity datum, construct range, and + * the own/subtree text datums (each a byte-exact string or the + * unavailability marker — the matrix under test), plus tree shape. + * Attribute entries and interpreted tags/coverage stay at their home tests + * (module comment); the form-exact decode has validated their forms. + */ +interface TextTreeExpectation { + readonly identity: ViewNode["identity"]; + readonly range: SourceRange; + readonly ownText: string | { readonly unavailable: true }; + readonly subtreeText: string | { readonly unavailable: true }; + readonly children: readonly TextTreeExpectation[]; +} + +function projectTextNode(node: ViewNode): TextTreeExpectation { + return { + identity: node.identity, + range: node.range, + ownText: node.ownText!, + subtreeText: node.subtreeText!, + children: node.children.map(projectTextNode), + }; +} + +const CH_A_TEXT_TREE: TextTreeExpectation = { + identity: CH_A_FILE, + range: CH_A_ROOT_RANGE, + ownText: CH_A_ROOT_OWN, + subtreeText: UNAVAILABLE, + children: [ + { + identity: `${CH_A_FILE}#top`, + range: CHA_TOP_RANGE, + ownText: UNAVAILABLE, + subtreeText: UNAVAILABLE, + children: [], + }, + { + identity: `${CH_A_FILE}#side`, + range: CHA_SIDE_RANGE, + ownText: CH_A_SIDE_TEXT, + subtreeText: CH_A_SIDE_TEXT, + children: [], + }, + ], +}; + +const CH_B_TEXT_TREE: TextTreeExpectation = { + identity: CH_B_FILE, + range: CH_B_ROOT_RANGE, + ownText: CH_B_ROOT_OWN, + subtreeText: UNAVAILABLE, + children: [ + { + identity: `${CH_B_FILE}#mid`, + range: CHB_MID_RANGE, + ownText: UNAVAILABLE, + subtreeText: UNAVAILABLE, + children: [], + }, + { + identity: `${CH_B_FILE}#ok`, + range: CHB_OK_RANGE, + ownText: CH_B_OK_TEXT, + subtreeText: CH_B_OK_TEXT, + children: [], + }, + ], +}; + +const CH_C_TEXT_TREE: TextTreeExpectation = { + identity: CH_C_FILE, + range: CH_C_ROOT_RANGE, + ownText: CH_C_ROOT_OWN, + subtreeText: UNAVAILABLE, + children: [ + { + identity: `${CH_C_FILE}#deep`, + range: CHC_DEEP_RANGE, + ownText: UNAVAILABLE, + subtreeText: UNAVAILABLE, + children: [], + }, + ], +}; + +const CH_A_IMPORTS: readonly ViewImportEntry[] = [ + { range: CHA_IMPORT_RANGE, name: "B", target: CH_B_FILE }, +]; +const CH_B_IMPORTS: readonly ViewImportEntry[] = [ + { range: CHB_IMPORT_RANGE, name: "C", target: CH_C_FILE }, +]; + +// The chain's occurrence records (SPEC 5.7): every resolving embedding — +// sources defined here (each enclosing section spells a unique id) — while +// the unresolved `{text("nosuch")}` records none (CH-C's list is empty, its +// position reaching consumers through the 14.6 finding's range). +const CH_A_OCCURRENCES: readonly OccurrenceRecord[] = [ + { + file: CH_A_FILE, + range: CHA_EMBED_MID_RANGE, + kind: "embeds", + source: { identity: `${CH_A_FILE}#top`, range: CHA_TOP_RANGE }, + target: `${CH_B_FILE}#mid`, + }, + { + file: CH_A_FILE, + range: CHA_EMBED_OK_RANGE, + kind: "embeds", + source: { identity: `${CH_A_FILE}#side`, range: CHA_SIDE_RANGE }, + target: `${CH_B_FILE}#ok`, + }, +]; +const CH_B_OCCURRENCES: readonly OccurrenceRecord[] = [ + { + file: CH_B_FILE, + range: CHB_EMBED_DEEP_RANGE, + kind: "embeds", + source: { identity: `${CH_B_FILE}#mid`, range: CHB_MID_RANGE }, + target: `${CH_C_FILE}#deep`, + }, +]; + +// --- staging 3: the embedding cycle (staged separately) ----------------------- +// +// `{text("self")}` inside the section spelling `self`: the spelling +// RESOLVES (its target's identity is defined) and records an occurrence — +// an embeds edge from `self` to itself, a dependency cycle of length one +// (SPEC 5.3, 14.9) — while the expansion re-enters the node being expanded, +// poisoning self's whole own/subtree value. The sibling `calm` and the +// root's own text stay defined and byte-exact; the root's subtree text is +// poisoned through `self`. + +const CY_FILE = "specs/CY.mdx"; +const CY = new ByteFixture(); +CY.add("Célula — self-embedding cycle.\n\n"); +const CY_SELF_START = CY.pos; +CY.add('<S id="self">\nSelf head.\n\n'); +const CY_SELF_EMBED_TEXT = '{text("self")}'; +const CY_SELF_EMBED_RANGE = CY.add(CY_SELF_EMBED_TEXT); +CY.add("\n</S>"); +const CY_SELF_RANGE: SourceRange = { start: CY_SELF_START, end: CY.pos }; +CY.add("\n\n"); +const CY_CALM_START = CY.pos; +CY.add('<S id="calm">\nCalm line.\n</S>'); +const CY_CALM_RANGE: SourceRange = { start: CY_CALM_START, end: CY.pos }; +CY.add("\n"); +const CY_SOURCE = CY.source; +const CY_ROOT_RANGE: SourceRange = { start: 0, end: CY.pos }; + +const CY_CALM_TEXT = "Calm line.\n"; +// Root own text: title + its blank line, then the blank line between the +// sections (no import line in this file). +const CY_ROOT_OWN = "Célula — self-embedding cycle.\n\n\n"; + +const CY_TEXT_TREE: TextTreeExpectation = { + identity: CY_FILE, + range: CY_ROOT_RANGE, + ownText: CY_ROOT_OWN, + subtreeText: UNAVAILABLE, + children: [ + { + identity: `${CY_FILE}#self`, + range: CY_SELF_RANGE, + ownText: UNAVAILABLE, + subtreeText: UNAVAILABLE, + children: [], + }, + { + identity: `${CY_FILE}#calm`, + range: CY_CALM_RANGE, + ownText: CY_CALM_TEXT, + subtreeText: CY_CALM_TEXT, + children: [], + }, + ], +}; + +const CY_OCCURRENCES: readonly OccurrenceRecord[] = [ + { + file: CY_FILE, + range: CY_SELF_EMBED_RANGE, + kind: "embeds", + source: { identity: `${CY_FILE}#self`, range: CY_SELF_RANGE }, + target: `${CY_FILE}#self`, + }, +]; + +// --- staging 4: removal classification is by syntactic form ------------------- +// +// specs/IMP.mdx imports specs/GONE.xspec with an UNUSED binding (2.1: valid, +// records no edges — so no expansion depends on the target and the text +// values stay defined on both sides of its deletion) and holds a stray +// `<div>` (14.16) inside its one section: content, preserved byte-for-byte +// in the enclosing text, located by its finding, with no view entry (SPEC +// 11.2, 11.4). Deleting GONE.mdx flips the import's `target` datum to the +// unavailability marker and adds the 14.15 finding — while every text value +// is byte-identical to before: the import is removed by FORM, target +// discovery notwithstanding. + +const IMP_FILE = "specs/IMP.mdx"; +const GONE_FILE = "specs/GONE.mdx"; + +const IMP = new ByteFixture(); +IMP.add("Süd — removal classification.\n\n"); +const IMP_IMPORT_TEXT = 'import GONE from "./GONE.xspec"'; +const IMP_IMPORT_RANGE = IMP.add(IMP_IMPORT_TEXT); +IMP.add("\n\n"); +const IMP_KEEP_START = IMP.pos; +IMP.add('<S id="keep">\nKeep head.\n\n'); +const IMP_DIV_TEXT = "<div>stray</div>"; +const IMP_DIV_RANGE = IMP.add(IMP_DIV_TEXT); +IMP.add("\n\nTail line.\n</S>"); +const IMP_KEEP_RANGE: SourceRange = { start: IMP_KEEP_START, end: IMP.pos }; +IMP.add("\n"); +const IMP_SOURCE = IMP.source; +const IMP_ROOT_RANGE: SourceRange = { start: 0, end: IMP.pos }; + +const GONE_FIX = new ByteFixture(); +GONE_FIX.add("Œuvre — deletable import target.\n\n"); +const GONE_G_START = GONE_FIX.pos; +GONE_FIX.add('<S id="g">\nGone text.\n</S>'); +const GONE_G_RANGE: SourceRange = { start: GONE_G_START, end: GONE_FIX.pos }; +GONE_FIX.add("\n"); +const GONE_SOURCE = GONE_FIX.source; +const GONE_ROOT_RANGE: SourceRange = { start: 0, end: GONE_FIX.pos }; + +// keep's contribution: body lines with the stray element's own characters +// preserved byte-for-byte (it matches no removal rule's form) and both +// blank lines intact; the tag lines drop. +const IMP_KEEP_TEXT = "Keep head.\n\n" + IMP_DIV_TEXT + "\n\nTail line.\n"; +// Root own text: title + its blank line + the blank line left after the +// dropped import line; nothing after keep (the final `</S>` line drops). +const IMP_ROOT_OWN = "Süd — removal classification.\n\n\n"; +const IMP_ROOT_SUBTREE = IMP_ROOT_OWN + IMP_KEEP_TEXT; +const GONE_G_TEXT = "Gone text.\n"; +const GONE_ROOT_OWN = "Œuvre — deletable import target.\n\n"; +const GONE_ROOT_SUBTREE = GONE_ROOT_OWN + GONE_G_TEXT; + +// One pinned tree serves BOTH sides of the deletion (module comment: equal +// pinned values realize "byte-identical to before" and the by-form rule). +const IMP_TEXT_TREE: TextTreeExpectation = { + identity: IMP_FILE, + range: IMP_ROOT_RANGE, + ownText: IMP_ROOT_OWN, + subtreeText: IMP_ROOT_SUBTREE, + children: [ + { + identity: `${IMP_FILE}#keep`, + range: IMP_KEEP_RANGE, + ownText: IMP_KEEP_TEXT, + subtreeText: IMP_KEEP_TEXT, + children: [], + }, + ], +}; + +const GONE_TEXT_TREE: TextTreeExpectation = { + identity: GONE_FILE, + range: GONE_ROOT_RANGE, + ownText: GONE_ROOT_OWN, + subtreeText: GONE_ROOT_SUBTREE, + children: [ + { + identity: `${GONE_FILE}#g`, + range: GONE_G_RANGE, + ownText: GONE_G_TEXT, + subtreeText: GONE_G_TEXT, + children: [], + }, + ], +}; + +const IMP_IMPORTS_BEFORE: readonly ViewImportEntry[] = [ + { range: IMP_IMPORT_RANGE, name: "GONE", target: GONE_FILE }, +]; +const IMP_IMPORTS_AFTER: readonly ViewImportEntry[] = [ + { range: IMP_IMPORT_RANGE, name: "GONE", target: UNAVAILABLE }, +]; + +const T11_2_4 = defineProductTest({ + id: "T11.2-4", + title: + "resolution turns on the referenced identity's own definedness: with duplicate spellings of `a` and the unique `a.b` beneath one bearer, the `d` entry naming `a.b` on the other bearer and the `{text(\"a.b\")}` embedding inside an id-less section each resolve and record occurrences whose `source` is exactly the unavailability marker (`file`, `range`, `kind`, `target` present — never a picked bearer, never a dropped record; observed via bare `occurrences` AND `view`), while the `d` reference to `a` records none — ambiguous, every bearer undefined — reported by its 14.5 finding's range, never as a record or an unavailable target, the view still positioning each enclosing construct with identity unavailable, the file's findings (14.1, 14.3, 14.5) accompanying, exit 1; `view --text`: CH-A embeds CH-B embeds CH-C with an unresolved embedding in CH-C (14.6, its finding's range exactly the braced container) → top's and mid's own/subtree text exactly the unavailability marker — one unresolved spelling, or (staged separately) one self-embedding cycle (14.9), poisons the whole value, partial expansion never occurring — while siblings with resolved expansions stay defined and byte-exact and each root's own text stays defined beside its poisoned subtree text; removal classification is by syntactic form: after deleting the imported (unused-binding) GONE.mdx, IMP.mdx's text values are byte-identical to before — the import removed by form, its 14.15 finding notwithstanding, the import entry's `target` flipping to the marker — and the stray `<div>` (14.16) is content, preserved byte-for-byte in the enclosing text and located by its finding (SPEC 11.2, 11.3, 11.4, 5.7, 1.6, 2.1, 3, 12.7, 14; CERTIFICATIONS.md CONF-AVAIL in scope)", + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline): composed ranges sliced back + // out of the staged bytes before any product invocation. + sliceCheck( + R_SOURCE, + R_A2_D_REF, + '"a.b"', + "the resolving d reference on the second bearer", + ); + sliceCheck( + R_SOURCE, + dLiteralRange(R_Q_D), + '"a"', + "the ambiguous d reference", + ); + sliceCheck( + R_SOURCE, + R_EMBED_RANGE, + R_EMBED_TEXT, + "the id-less section's embedding container", + ); + sliceCheck( + R_SOURCE, + { start: R_Q_START, end: R_Q_OPEN_END }, + '<S id="q" d={"a"}>', + "q's opening tag", + ); + sliceCheck( + R_SOURCE, + R_AB_RANGE, + '<S id="a.b">\nTarget text.\n</S>', + "the unique a.b construct", + ); + sliceCheck( + CH_A_SOURCE, + CHA_IMPORT_RANGE, + CHA_IMPORT_TEXT, + "CH-A's import declaration", + ); + sliceCheck( + CH_A_SOURCE, + CHA_EMBED_OK_RANGE, + "{text(B.ok)}", + "the resolved sibling embedding", + ); + sliceCheck( + CH_B_SOURCE, + CHB_OK_RANGE, + '<S id="ok">\nOK line.\n</S>', + "CH-B's ok construct", + ); + sliceCheck( + CH_C_SOURCE, + CHC_NOSUCH_RANGE, + CHC_NOSUCH_TEXT, + "the unresolved embedding container", + ); + sliceCheck( + CY_SOURCE, + CY_SELF_EMBED_RANGE, + CY_SELF_EMBED_TEXT, + "the self-embedding container", + ); + sliceCheck( + IMP_SOURCE, + IMP_IMPORT_RANGE, + IMP_IMPORT_TEXT, + "IMP's import declaration", + ); + sliceCheck(IMP_SOURCE, IMP_DIV_RANGE, IMP_DIV_TEXT, "the stray element"); + sliceCheck( + GONE_SOURCE, + GONE_G_RANGE, + '<S id="g">\nGone text.\n</S>', + "GONE's section construct", + ); + + // Shared: exactly the R stagings' findings, keyed and located (the + // identical multiset must accompany both surfaces' answers). + const assertRFindings = ( + findings: readonly Finding[], + context: string, + ): void => { + assertConditionCounts( + findings, + R_CONDITION_COUNTS, + `${context} — exactly the staged conditions accompany (SPEC 11.2, ` + + `14): one 14.1, one 14.3, one 14.5 — and no 14.2 (masked or ` + + `satisfied everywhere) and no phantom condition`, + ); + assertLocatedFinding( + findingByCondition(findings, "14.1", context), + [{ file: R_FILE, window: widened(R_NOID_RANGE) }], + `${context} — the missing-id finding locates the id-less section`, + ); + assertLocatedFinding( + findingByCondition(findings, "14.3", context), + [ + { file: R_FILE, window: widened(R_A1_RANGE) }, + { file: R_FILE, window: widened(R_A2_RANGE) }, + ], + `${context} — the duplicate-id finding locates EVERY bearer of ` + + `\`a\`, one location each in 12.7 location order (SPEC 14)`, + ); + assertLocatedFinding( + findingByCondition(findings, "14.5", context), + [{ file: R_FILE, window: { start: R_Q_START, end: R_Q_OPEN_END + 1 } }], + `${context} — the ambiguous reference to \`a\` is reported by its ` + + `finding's range (within the opening tag spelling the reference), ` + + `never as a record or an unavailable target (SPEC 11.2, 14)`, + ); + }; + + // --- Staging 1: resolution and source-side unavailability. + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [R_FILE]: R_SOURCE, + }, + }); + try { + const viewContext = "T11.2-4 bare `view` (the resolution matrix)"; + const viewResult = await expectExit( + product, + workspace, + ["view"], + 1, + `${viewContext} — findings and explicitly-unavailable datums ` + + `accompany, so exit 1 with the full answer emitted (SPEC 11.2)`, + ); + const viewReport = decodeViewReport( + parseJsonStdout( + viewResult, + `${viewContext} — a single JSON document is the only output ` + + `form, with or without --json (SPEC 11)`, + ), + { text: false }, + viewContext, + ); + assertRFindings(viewReport.findings, viewContext); + assertSameJson( + viewReport.views.map((view) => view.file), + [R_FILE], + `${viewContext} — one per-file view: the matrix file (SPEC 11.4)`, + ); + const rView = viewReport.views[0]!; + assertSameJson( + projectNode(rView.root), + R_TREE, + `${viewContext} — the view still positions each enclosing ` + + `construct (SPEC 11.4): both duplicate bearers and the id-less ` + + `section with byte-exact ranges and raw attribute entries, ` + + `identities explicitly unavailable, while a.b (defined without ` + + `defined prefixes) and q stay defined (SPEC 11.2)`, + ); + assertSameJson( + rView.occurrences, + R_EXPECTED_OCCURRENCES, + `${viewContext} — the file's occurrence records: the two ` + + `resolving spellings record with source EXACTLY the ` + + `unavailability marker (identity and range withheld together ` + + `as one datum) and file/range/kind/target present; the ` + + `ambiguous reference to a records NONE (SPEC 5.7, 11.2)`, + ); + assertSameJson( + [rView.imports, rView.comments], + [[], []], + `${viewContext} — the matrix file holds no imports or comments: ` + + `empty arrays, never null (SPEC 12.7)`, + ); + + const occContext = + "T11.2-4 bare `occurrences` (no --file: the entire discovered set)"; + const occResult = await expectExit( + product, + workspace, + ["occurrences"], + 1, + `${occContext} — the enumeration carries the domain's findings ` + + `and explicitly-unavailable source datums, so exit 1 with the ` + + `full answer (SPEC 11.2, 11.3)`, + ); + const occReport = decodeOccurrencesReport( + parseJsonStdout( + occResult, + `${occContext} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + occContext, + ); + assertRFindings(occReport.findings, occContext); + assertSameJson( + occReport.occurrences, + R_EXPECTED_OCCURRENCES, + `${occContext} — the workspace's COMPLETE enumeration: exactly ` + + `the two resolving spellings' records (never a dropped ` + + `record), each source exactly the marker (never a picked ` + + `bearer's identity), and no record — with no unavailable ` + + `target — for the ambiguous reference (SPEC 5.7, 11.2, 11.3)`, + ); + } finally { + await workspace.dispose(); + } + } + + // --- Staging 2: whole-value poisoning through an unresolved spelling. + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [CH_A_FILE]: CH_A_SOURCE, + [CH_B_FILE]: CH_B_SOURCE, + [CH_C_FILE]: CH_C_SOURCE, + }, + }); + try { + const context = "T11.2-4 bare `view --text` (the embedding chain)"; + const result = await expectExit( + product, + workspace, + ["view", "--text"], + 1, + `${context} — a finding and explicitly-unavailable text values ` + + `accompany, so exit 1 with the full answer (SPEC 11.2)`, + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + { text: true }, + context, + ); + assertConditionCounts( + report.findings, + { "14.6": 1 }, + `${context} — the unresolved embedding is the workspace's ONLY ` + + `condition (every id unique and well-formed, every import ` + + `valid), so exactly one 14.6 accompanies (SPEC 11.2, 14)`, + ); + const unresolved = findingByCondition(report.findings, "14.6", context); + assertSameJson( + { + code: unresolved.code, + locations: unresolved.locations, + path: unresolved.path, + }, + { + code: "unknown-text-target", + locations: [{ file: CH_C_FILE, range: CHC_NOSUCH_RANGE }], + path: null, + }, + `${context} — the non-recording spelling is located by its ` + + `finding: stable code unknown-text-target, its one location's ` + + `range EXACTLY the full braced container — the span its ` + + `occurrence would occupy (SPEC 14, 5.7, 12.7)`, + ); + assertSameJson( + report.views.map((view) => view.file), + [CH_A_FILE, CH_B_FILE, CH_C_FILE], + `${context} — per-file views in path-byte order (SPEC 11.4)`, + ); + const aView = report.views[0]!; + const bView = report.views[1]!; + const cView = report.views[2]!; + assertSameJson( + projectTextNode(aView.root), + CH_A_TEXT_TREE, + `${context} — CH-A: top's own/subtree text EXACTLY the ` + + `unavailability marker (one unresolved spelling on the ` + + `expansion path poisons the whole value — partial expansion ` + + `never occurs), the sibling side defined and byte-exact with ` + + `its resolved expansion inserted, the root's own text defined ` + + `beside its poisoned subtree text (SPEC 11.2, 1.6, 3)`, + ); + assertSameJson( + projectTextNode(bView.root), + CH_B_TEXT_TREE, + `${context} — CH-B: mid poisoned (the unresolved spelling lies ` + + `two hops down), ok defined and byte-exact, root own text ` + + `defined (SPEC 11.2, 1.6, 3)`, + ); + assertSameJson( + projectTextNode(cView.root), + CH_C_TEXT_TREE, + `${context} — CH-C: deep (holding the unresolved spelling) ` + + `poisoned, root own text defined (SPEC 11.2, 1.6, 3)`, + ); + assertSameJson( + [aView.imports, bView.imports, cView.imports], + [CH_A_IMPORTS, CH_B_IMPORTS, []], + `${context} — each import declaration with its range, default ` + + `binding, and resolved target file (SPEC 11.4)`, + ); + assertSameJson( + [aView.occurrences, bView.occurrences, cView.occurrences], + [CH_A_OCCURRENCES, CH_B_OCCURRENCES, []], + `${context} — the resolving embeddings record (defined sources ` + + `here); the unresolved spelling records NONE, so CH-C's list ` + + `is empty (SPEC 5.7, 11.2)`, + ); + assertSameJson( + [aView.comments, bView.comments, cView.comments], + [[], [], []], + `${context} — no comments staged: empty arrays (SPEC 12.7)`, + ); + } finally { + await workspace.dispose(); + } + } + + // --- Staging 3: whole-value poisoning through an embedding cycle. + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [CY_FILE]: CY_SOURCE, + }, + }); + try { + const context = "T11.2-4 bare `view --text` (the self-embedding cycle)"; + const result = await expectExit( + product, + workspace, + ["view", "--text"], + 1, + `${context} — the cycle finding and poisoned text values ` + + `accompany, so exit 1 with the full answer (SPEC 11.2)`, + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + { text: true }, + context, + ); + assertConditionCounts( + report.findings, + { "14.9": 1 }, + `${context} — the length-one embedding cycle is the workspace's ` + + `ONLY condition: exactly one 14.9 (SPEC 5.3, 14)`, + ); + assertLocatedFinding( + findingByCondition(report.findings, "14.9", context), + [{ file: CY_FILE, window: widened(CY_SELF_EMBED_RANGE) }], + `${context} — the cycle locates its full path in source: the one ` + + `participating reference spelling, the self-embedding ` + + `container (SPEC 14)`, + ); + assertSameJson( + report.views.map((view) => view.file), + [CY_FILE], + `${context} — one per-file view (SPEC 11.4)`, + ); + const cyView = report.views[0]!; + assertSameJson( + projectTextNode(cyView.root), + CY_TEXT_TREE, + `${context} — one embedding cycle poisons the whole value: ` + + `self's own/subtree text EXACTLY the unavailability marker ` + + `(the recursion re-enters a node being expanded; partial ` + + `expansion never occurs), the sibling calm defined and ` + + `byte-exact, the root's own text defined beside its poisoned ` + + `subtree text (SPEC 11.2, 1.6)`, + ); + assertSameJson( + cyView.occurrences, + CY_OCCURRENCES, + `${context} — the cycle-participating spelling RESOLVES and ` + + `records its occurrence (cycle participation never erases ` + + `records; its source is the defined self node): exactly one ` + + `embeds record, self to self (SPEC 5.7, 11.2)`, + ); + assertSameJson( + [cyView.imports, cyView.comments], + [[], []], + `${context} — no imports or comments staged (SPEC 12.7)`, + ); + } finally { + await workspace.dispose(); + } + } + + // --- Staging 4: removal classification is by syntactic form. + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [IMP_FILE]: IMP_SOURCE, + [GONE_FILE]: GONE_SOURCE, + }, + }); + try { + // Before the deletion: the import resolves; the stray element is + // the only condition; every text value is defined and pinned. + const beforeContext = + "T11.2-4 bare `view --text` (before deleting the imported file)"; + const beforeResult = await expectExit( + product, + workspace, + ["view", "--text"], + 1, + `${beforeContext} — the stray-element finding accompanies, so ` + + `exit 1 with the full answer (SPEC 11.2)`, + ); + const beforeReport = decodeViewReport( + parseJsonStdout( + beforeResult, + `${beforeContext} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + { text: true }, + beforeContext, + ); + assertConditionCounts( + beforeReport.findings, + { "14.16": 1 }, + `${beforeContext} — the stray element is the workspace's ONLY ` + + `condition before the deletion (the unused-binding import is ` + + `valid, SPEC 2.1, 14)`, + ); + assertLocatedFinding( + findingByCondition(beforeReport.findings, "14.16", beforeContext), + [{ file: IMP_FILE, window: widened(IMP_DIV_RANGE) }], + `${beforeContext} — the stray element is located by its finding ` + + `(SPEC 11.2, 14)`, + ); + assertSameJson( + beforeReport.views.map((view) => view.file), + [GONE_FILE, IMP_FILE], + `${beforeContext} — per-file views in path-byte order (SPEC 11.4)`, + ); + const goneView = beforeReport.views[0]!; + const impBeforeView = beforeReport.views[1]!; + assertSameJson( + projectTextNode(goneView.root), + GONE_TEXT_TREE, + `${beforeContext} — the import target's own view, text values ` + + `defined and byte-exact (SPEC 11.4, 1.6, 3)`, + ); + assertSameJson( + projectTextNode(impBeforeView.root), + IMP_TEXT_TREE, + `${beforeContext} — IMP's text values: the import line removed ` + + `by form, the stray <div> preserved byte-for-byte as content ` + + `in the enclosing text (it matches no removal rule's form, ` + + `14.16 notwithstanding), the section tag lines dropped (SPEC ` + + `11.2, 1.6, 3)`, + ); + assertSameJson( + impBeforeView.imports, + IMP_IMPORTS_BEFORE, + `${beforeContext} — the import entry: range, default binding ` + + `GONE, resolved target specs/GONE.mdx (SPEC 11.4, 2.1)`, + ); + assertSameJson( + [ + goneView.imports, + goneView.occurrences, + goneView.comments, + impBeforeView.occurrences, + impBeforeView.comments, + ], + [[], [], [], [], []], + `${beforeContext} — the unused binding records no occurrence ` + + `(SPEC 2.1, 5.7); no comments staged (SPEC 12.7)`, + ); + + // Delete the imported file: removal classification is by syntactic + // form, so IMP's text values MUST NOT move. + await fsp.rm(workspace.path(GONE_FILE)); + + const afterContext = + "T11.2-4 bare `view --text` (after deleting the imported file)"; + const afterResult = await expectExit( + product, + workspace, + ["view", "--text"], + 1, + `${afterContext} — the 14.15 and 14.16 findings accompany, so ` + + `exit 1 with the full answer (SPEC 11.2)`, + ); + const afterReport = decodeViewReport( + parseJsonStdout( + afterResult, + `${afterContext} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + { text: true }, + afterContext, + ); + assertConditionCounts( + afterReport.findings, + { "14.15": 1, "14.16": 1 }, + `${afterContext} — the import no longer designates a discovered ` + + `spec source (14.15) beside the unchanged stray-element ` + + `finding — and nothing else (SPEC 2.1, 14)`, + ); + assertLocatedFinding( + findingByCondition(afterReport.findings, "14.15", afterContext), + [{ file: IMP_FILE, window: widened(IMP_IMPORT_RANGE) }], + `${afterContext} — the invalid import is located at its ` + + `declaration (SPEC 14)`, + ); + assertLocatedFinding( + findingByCondition(afterReport.findings, "14.16", afterContext), + [{ file: IMP_FILE, window: widened(IMP_DIV_RANGE) }], + `${afterContext} — the stray element's finding is unchanged ` + + `(SPEC 14)`, + ); + assertSameJson( + afterReport.views.map((view) => view.file), + [IMP_FILE], + `${afterContext} — the deleted file is no longer discovered: ` + + `IMP's view alone (SPEC 11.4)`, + ); + const impAfterView = afterReport.views[0]!; + assertSameJson( + projectTextNode(impAfterView.root), + IMP_TEXT_TREE, + `${afterContext} — the importing file's text values are ` + + `BYTE-IDENTICAL to before (the same pinned tree): every import ` + + `declaration is removed by FORM — binding shape, specifier ` + + `validity, and target discovery notwithstanding — so the ` + + `deletion perturbs no text value, its 14.15 finding ` + + `notwithstanding (SPEC 11.2, 3)`, + ); + assertSameJson( + impAfterView.imports, + IMP_IMPORTS_AFTER, + `${afterContext} — the import entry stays on view with its ` + + `range and binding, its resolved target now EXACTLY the ` + + `unavailability marker: discovery defines none (SPEC 11.4, ` + + `11.2)`, + ); + assertSameJson( + [impAfterView.occurrences, impAfterView.comments], + [[], []], + `${afterContext} — still no occurrences (the binding stays ` + + `unused) and no comments (SPEC 5.7, 12.7)`, + ); + } finally { + await workspace.dispose(); + } + } + }, +}); + /** TEST-SPEC §11.2, in canonical ID order (SUITE-52). */ export const section112Tests: readonly ProductTestEntry[] = [ T11_2_1, T11_2_2, T11_2_3, + T11_2_4, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index bf2788d..8bf8226 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -301,6 +301,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.2-1": ["11.2"], "T11.2-2": ["11.2"], "T11.2-3": ["11.2"], + "T11.2-4": ["11.2"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From d3d75186057bed8f59aea52b47ed3278256f9769 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 13 Aug 2026 23:57:43 +0000 Subject: [PATCH 099/226] sdg(phase-9): implement T11.2-5's domain/findings/exit discipline (FP-054) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 56 ++- test/suite/registry/section-11.2.ts | 710 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 756 insertions(+), 11 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 9e4d92f..62e62a8 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2314,8 +2314,62 @@ certify against FP-091's fixtures once those land. discriminates). `npm run test:self`: unchanged 4 planned mid-loop reds (certification-document x3 → FP-091; S-1's 6 unmapped keys {11.3–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-054 — Implement T11.2-5: domain/findings/exit discipline. [R2 #2; +- [x] FP-054 — Implement T11.2-5: domain/findings/exit discipline. [R2 #2; TEST-SPEC §11.2] + [Done 2026-08-13: registered in section-11.2.ts (SUITE-52) with + traceability ["11.2"] (no TEST-SPEC 14 staging record — the FP-050..053 + precedent; T14-4's and T11.5-2's citations are cross-references TO it; + 11.3–11.5/12.0/12.7 context with home coverage at T11.3-*/T11.4-*/ + T11.5-*/T12.0-13/T12.7-3). NOT in CONF-AVAIL scope — CERTIFICATIONS.md + lists T11.2-5 expressly under Exclusions — so gate `build`, `at`, and + `--file` all free. Two workspaces. (1) T11.2-1's A/B/C reused beside a + discovered reference-free src/app.ts under a spec+code config (the + wrong-kind operand; adds no finding): gate `build --json` pins the exact + T11.2-1 multiset; `view C` → exit 0, findings [], C's complete view (the + domain is the requested files while A/B stay invalid); `view A` → exit 1, + findings EXACTLY A's six of both levels all located in A (B's 14.20 + excluded by exactness), full answer still emitted (views [A]: tree, + comments, occurrences, imports pinned); then the five + argument-checks-precede-answering arms on the failing workspace — unknown + `view` operand, wrong-kind `view src/app.ts`, outside-root `occurrences + --file ../…`, malformed `occurrences --to specs/A.mdx#a..b` (empty + segment), out-of-range `at specs/A.mdx <len+1>` — each exit 2 via a new + expectAvailabilityUsageError (JSON-only surfaces: bare invocation, single + 12.7 error document as entire stdout, stderr message present; per-surface + matrices stay at T11.3-2/3, T11.4-2, T11.5-2). (2) cycle pair D/E (mutual + external `d` references — the external form forces mutual imports, so the + staged set is EXACTLY two 14.9s: dependency cycle + spec import cycle, + each jointly violated) beside finding-free C: gate pins {14.9: 2} with + each finding's full path — two locations, one per file, windows the + import declarations resp. the opening tags (disjoint ordered windows pin + the 12.7 finding order import-first); `view D` and `view E` each assert + both findings WHOLE (the out-of-domain participant's location included) + plus the participant's complete view (identities defined — cycle never + undefines; import entry resolved; the resolving `d` occurrence recorded); + `view C` → exit 0, findings [] (no participant in domain). Both + workspaces wrapped in whole-root snapshot compares (hygiene; no-write + contract stays T11.2-1/-6's). Verified: typecheck/format clean; suite + section-11.2 red-as-diagnosed — T11.2-5 fails at the ws1 gate build's + FP-001-class form-exact decode ("expected no member \"condition\""); + scratch probe: a conforming fake re-deriving every answer from the + workspace bytes (own config parse, glob discovery, MDX-lite parse with + byte offsets, 11.2 definedness, resolution/occurrences, Tarjan SCC over + contains+depends+embeds and over the import graph, 12.7 forms and order, + per-domain attachment, argument checks) ran the registered body green + end-to-end, and five deviation fakes each failed diagnosed — attach-all + at `view C`'s exit-0 (domain discipline), truncate-joint at the + whole-cycle location count (accompanies-whole), answer-past-usage at the + unknown-file exit-2 (precedence), withhold-answer at the views-[A] pin + (full answer), exit-zero at `view A`'s exit-1; direct `build --json` + probes against the built product confirm both stagings fire in a real + parser — ws1 exactly the staged multiset per home file, ws2 exactly two + 14.9s whose old-shape messages name the import cycle and the dependency + cycle paths (and whose import-declaration location 45..70 independently + corroborates the ByteFixture arithmetic); view/occurrences/at stay + unknown commands (exit 2) pre-patch. `npm run test:self`: unchanged 4 + planned mid-loop reds (certification-document x3 → FP-091; S-1's 6 + unmapped keys {11.3–11.6, 12.6, 12.7} → stage G), S-5 and certification + green.] - [ ] FP-055 — Implement T11.2-6: never-stale + gate-findings-never-attach. [R2 #2; TEST-SPEC §11.2] diff --git a/test/suite/registry/section-11.2.ts b/test/suite/registry/section-11.2.ts index 33bfb19..8acbaf4 100644 --- a/test/suite/registry/section-11.2.ts +++ b/test/suite/registry/section-11.2.ts @@ -1,5 +1,5 @@ // TEST-SPEC §11.2 (availability on imperfect files) — SUITE-52: T11.2-1 -// through T11.2-4. +// through T11.2-5. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -12,11 +12,13 @@ // staging constraint pins every command an in-scope test drives to its // enumerated `view`/`occurrences` surface, so T11.2-2 and T11.2-4 run NO // gate-reference `build`, no `at`, and no `--file` on `occurrences` -// (VIOL-AVAIL-NOFILE's staging constraint) — unlike T11.2-1 and T11.2-3, -// which are not in scope (CONF-AVAIL's workspace scope is `#`-free +// (VIOL-AVAIL-NOFILE's staging constraint) — unlike T11.2-1, T11.2-3, and +// T11.2-5, which are not in scope (CONF-AVAIL's workspace scope is `#`-free // valid-UTF-8 paths with no code groups, so T11.2-3's staging lies outside -// it by construction): staging integrity rides each answer's own exact -// accompanying-findings multiset instead, and the staged conditions are +// it by construction, and T11.2-5 — its argument and domain-and-exit +// matrix — is expressly an Exclusions entry): staging integrity rides each +// answer's own exact accompanying-findings multiset instead, and the +// staged conditions are // drawn from the scope's stated set (T11.2-2: 14.1, 14.3, 14.4, 14.17; // T11.2-4: 14.1, 14.3, 14.5, 14.6, 14.9, 14.15, 14.16). // @@ -90,11 +92,13 @@ import { import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { assertConditionCounts, assertFindingLocated, assertSameJson, + expectErrorDocument, expectExit, runCli, } from "./support.js"; @@ -224,11 +228,12 @@ const A_SOURCE = A.source; const A_ROOT_RANGE: SourceRange = { start: 0, end: A.pos }; /** - * The string-literal reference inside a `d={"…"}` attribute: `d={` and the - * closing `}` excluded — a `d` occurrence spans that one reference's own - * expression, the string literal's characters quotes included (SPEC 5.7, - * 2.2; the T5.7-2 local-form convention). ASCII segment, so character - * arithmetic is byte arithmetic. + * The reference expression inside a single-reference `d={…}` attribute: + * `d={` and the closing `}` excluded — a `d` occurrence spans that one + * reference's own expression, for the local form the string literal's + * characters quotes included (the T5.7-2 convention) and for the external + * form the property chain's characters (SPEC 5.7, 2.2). ASCII segment, so + * character arithmetic is byte arithmetic. */ function dLiteralRange(attribute: ViewAttributeEntry): SourceRange { return { @@ -2987,10 +2992,695 @@ const T11_2_4 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T11.2-5 — domain, findings, exits +// --------------------------------------------------------------------------- +// +// SPEC 11.2 "Consulted domain, findings, exits": every answer of 11.3–11.5 +// has a consulted domain of files, and the findings of every domain file — +// and those alone — accompany the answer; a condition several files jointly +// violate (a cross-file cycle, 14.9) accompanies the answer WHOLE whenever +// any participating file lies in the domain. Any finding or explicitly- +// unavailable datum → exit 1 with the full answer document still emitted +// (exit 1 signals imperfection and never withholds the answer); a complete, +// finding-free answer → exit 0. The argument checks of 11.3–11.5 precede +// answering: a malformed `--to` or invalid glob, a `<file>` operand outside +// the domain or of the wrong kind, and an out-of-range offset each exit 2, +// whatever findings the workspace or the named files carry (12.0). The +// per-surface spelling matrices stay at their home tests (T11.3-2/3, +// T11.4-2, T11.5-2); this test pins the precedence discipline itself, every +// arm run on the finding-laden workspace. +// +// Conservative operationalizations (noted per H-3/H-4): +// - Workspace 1 is T11.2-1's staging (the entry's own reference: A parseable +// with findings of both levels, B unparseable, C finding-free) beside a +// discovered, reference-free code source under a spec+code configuration — +// the wrong-kind `<file>` operand (11.4) needs a discovered code source, +// and a valid, reference-free TypeScript file adds no finding, no node, +// and no occurrence (staging integrity rides the gate reference's exact +// multiset). T11.2-5 is in no certification scope (CERTIFICATIONS.md +// lists it under Exclusions), so the gate `build --json` and `at` are +// free to ride. +// - "A's findings of both levels accompany" is the exact multiset of A's six +// staged conditions (resolution-level 14.5/14.9; per-file structural +// 14.3/14.4/14.16/14.17), every finding located in A — B's 14.20 excluded +// by the same exactness: the domain is the requested files, never the +// workspace. +// - The two-file cycle is D#x --depends--> E#y --depends--> D#x via mutual +// EXTERNAL `d` references (SPEC 2.2's cross-file form), which forces the +// mutual imports the external form requires (2.1) — themselves a spec +// import cycle. The staged condition set is therefore exactly two 14.9 +// findings (SPEC 5.3, 2.1, 14.9), each a condition the two files JOINTLY +// violate, each locating its full path per SPEC 14's cardinality rule — +// one location per participating construct, one in each file: the two +// import declarations; the two reference spellings. "Accompanies whole" +// is realized as each finding carrying BOTH files' locations — asserted +// with exactly two locations per finding, each within its participating +// construct's byte window (the T11.2-4 window discipline: the import +// declaration; the opening tag spelling the reference) — in the domain +// [D] and again in the domain [E]; message equality across the two +// invocations is deliberately not asserted (informational content, +// SPEC 12.7). The finding-free C staged beside the pair pins the +// contrapositive: with no participant in the domain, neither cycle +// finding attaches — findings [], exit 0. +// - "Explicitly-unavailable datum → exit 1" rides the same arms: SPEC 11.2 +// derives every unavailable datum from a condition that is a domain +// file's finding (or, for 14.19, its concerned path), so no +// unavailable-datum-without-finding staging exists to build; view A's +// answer carries both (unavailable identities beside findings), view C's +// neither. +// - Exit-2 protocol: the three surfaces are JSON-only (SPEC 11), so JSON +// output is in effect on every invocation and an exit-2 usage error emits +// the single 12.7 error document as its entire stdout (12.0) — decoded +// form-exactly ({"error": …} with no findings member beside it) — with +// the usage message on stderr; `code`/`path` value assertions stay at +// T12.7-3's home. +// - Every invocation of both workspaces rides one whole-root snapshot +// compare per workspace (H-4): the never-built workspaces make any write +// surface in the diff (the no-write CONTRACT clauses stay at their +// T11.2-1/T11.2-6 homes; the compare is staging hygiene here). + +// --- workspace 1's added code source (the wrong-kind operand) ---------------- +const WRONG_KIND_CODE_FILE = "src/app.ts"; +const WRONG_KIND_CODE_SOURCE = "export function noop(): void {}\n"; + +/** + * `view specs/A.mdx`'s accompanying findings: exactly A's six staged + * conditions — findings of both levels — and never B's 14.20 (SPEC 11.2: + * the consulted domain is the requested files). + */ +const A_DOMAIN_CONDITION_COUNTS: Readonly<Record<string, number>> = { + "14.3": 1, + "14.4": 1, + "14.5": 1, + "14.9": 1, + "14.16": 1, + "14.17": 1, +}; + +// --- specs/D.mdx / specs/E.mdx — the two-file cycle pair --------------------- +// +// Each file: one import of the other (the external form's requirement, +// SPEC 2.2, 2.1) and one uniquely identified section whose `d` references +// the other file's section. Everything else is deliberately clean — every +// id spelled, well-formed, structural, and unique; both imports valid as +// declarations (form, target, binding) — so the two cycles are the +// workspace's ONLY conditions. Both `d` spellings RESOLVE (each target's +// identity is defined; cycle participation never undefines an identity, +// SPEC 11.2) and record their `depends` occurrences — positions survive the +// findings, the T11.2-1 clause — pinned here as each view's exact +// enumeration. The multi-byte prefixes (é) shift every later offset +// (SPEC 1.7). + +const D_FILE = "specs/D.mdx"; +const E_FILE = "specs/E.mdx"; + +const D = new ByteFixture(); +D.add("Début — two-file cycle: participant one.\n\n"); +const D_IMPORT_TEXT = 'import E from "./E.xspec"'; +const D_IMPORT_RANGE = D.add(D_IMPORT_TEXT); +D.add("\n\n"); +const D_X_START = D.pos; +D.add("<S "); +const D_X_ID = D.attr("id", 'id="x"'); +D.add(" "); +const D_X_D = D.attr("d", "d={E.y}"); +D.add(">"); +const D_X_OPEN: SourceRange = { start: D_X_START, end: D.pos }; +D.add("\nParticipant one text.\n</S>"); +const D_X_RANGE: SourceRange = { start: D_X_START, end: D.pos }; +D.add("\n"); +const D_SOURCE = D.source; +const D_ROOT_RANGE: SourceRange = { start: 0, end: D.pos }; +const D_X_D_REF = dLiteralRange(D_X_D); + +const E = new ByteFixture(); +E.add("Étape — two-file cycle: participant two.\n\n"); +const E_IMPORT_TEXT = 'import D from "./D.xspec"'; +const E_IMPORT_RANGE = E.add(E_IMPORT_TEXT); +E.add("\n\n"); +const E_Y_START = E.pos; +E.add("<S "); +const E_Y_ID = E.attr("id", 'id="y"'); +E.add(" "); +const E_Y_D = E.attr("d", "d={D.x}"); +E.add(">"); +const E_Y_OPEN: SourceRange = { start: E_Y_START, end: E.pos }; +E.add("\nParticipant two text.\n</S>"); +const E_Y_RANGE: SourceRange = { start: E_Y_START, end: E.pos }; +E.add("\n"); +const E_SOURCE = E.source; +const E_ROOT_RANGE: SourceRange = { start: 0, end: E.pos }; +const E_Y_D_REF = dLiteralRange(E_Y_D); + +const D_X_NODE_ID = `${D_FILE}#x`; +const E_Y_NODE_ID = `${E_FILE}#y`; + +const D_TREE: TreeExpectation = { + identity: D_FILE, + range: D_ROOT_RANGE, + attributes: [], + children: [ + { + identity: D_X_NODE_ID, + range: D_X_RANGE, + attributes: [D_X_ID, D_X_D], + children: [], + }, + ], +}; + +const E_TREE: TreeExpectation = { + identity: E_FILE, + range: E_ROOT_RANGE, + attributes: [], + children: [ + { + identity: E_Y_NODE_ID, + range: E_Y_RANGE, + attributes: [E_Y_ID, E_Y_D], + children: [], + }, + ], +}; + +/** D's view: the one import entry, resolved (SPEC 11.4, 2.1). */ +const D_IMPORTS: readonly ViewImportEntry[] = [ + { range: D_IMPORT_RANGE, name: "E", target: E_FILE }, +]; +const E_IMPORTS: readonly ViewImportEntry[] = [ + { range: E_IMPORT_RANGE, name: "D", target: D_FILE }, +]; + +// Each file's complete occurrence enumeration (SPEC 5.7): the resolving +// external `d` reference — its span the reference's own expression — with +// its source graph node defined (SPEC 11.2). +const D_OCCURRENCES: readonly OccurrenceRecord[] = [ + { + file: D_FILE, + range: D_X_D_REF, + kind: "depends", + source: { identity: D_X_NODE_ID, range: D_X_RANGE }, + target: E_Y_NODE_ID, + }, +]; +const E_OCCURRENCES: readonly OccurrenceRecord[] = [ + { + file: E_FILE, + range: E_Y_D_REF, + kind: "depends", + source: { identity: E_Y_NODE_ID, range: E_Y_RANGE }, + target: D_X_NODE_ID, + }, +]; + +/** The cycle workspace's exact condition multiset (staging integrity). */ +const CYCLE_CONDITION_COUNTS: Readonly<Record<string, number>> = { + "14.9": 2, +}; + +// The two joint findings' full paths (SPEC 14's cardinality rule): one +// location per participating construct, one in each file, in 12.7 location +// order (file path bytes: D before E). The spec import cycle locates each +// participating import declaration; the dependency cycle locates each +// participating reference spelling — its window the opening tag that spells +// it (the T11.2-4 tolerance; the two windows are disjoint within each file, +// so the 12.7 findings order pins the import-cycle finding first). +const CYCLE_IMPORT_LOCATIONS: readonly LocationWindowExpectation[] = [ + { file: D_FILE, window: widened(D_IMPORT_RANGE) }, + { file: E_FILE, window: widened(E_IMPORT_RANGE) }, +]; +const CYCLE_DEPENDENCY_LOCATIONS: readonly LocationWindowExpectation[] = [ + { file: D_FILE, window: widened(D_X_OPEN) }, + { file: E_FILE, window: widened(E_Y_OPEN) }, +]; + +/** + * Assert the two-file cycle findings accompany WHOLE (SPEC 11.2, 14): + * exactly two 14.9 findings — the spec import cycle, then the dependency + * cycle (the 12.7 findings order over their disjoint, ordered windows) — + * each carrying exactly its two participating locations, one per file, + * whatever the invocation's domain was. + */ +function assertCycleFindingsWhole( + findings: readonly Finding[], + context: string, +): void { + assertConditionCounts( + findings, + CYCLE_CONDITION_COUNTS, + `${context} — exactly the two staged 14.9 conditions: the dependency ` + + `cycle over the mutual d references and the spec import cycle over ` + + `the mutual imports the external form forces (SPEC 5.3, 2.1, 14.9)`, + ); + const cycles = findings.filter((finding) => finding.condition === "14.9"); + assertLocatedFinding( + cycles[0]!, + CYCLE_IMPORT_LOCATIONS, + `${context} — the spec import cycle accompanies WHOLE: one location ` + + `per participating import declaration, BOTH files' included ` + + `(SPEC 11.2: a condition several files jointly violate accompanies ` + + `the answer whole whenever any participating file lies in the ` + + `domain; SPEC 14's cardinality rule)`, + ); + assertLocatedFinding( + cycles[1]!, + CYCLE_DEPENDENCY_LOCATIONS, + `${context} — the dependency cycle accompanies WHOLE: one location per ` + + `participating reference spelling, BOTH files' included (SPEC 11.2, ` + + `14)`, + ); +} + +/** + * Run one availability-surface invocation expected to fail its argument + * checks: exit 2 exactly (the checks precede answering — SPEC 11.2, 12.0 — + * whatever findings the workspace or the named files carry), stdout exactly + * the single 12.7 error document (the surfaces are JSON-only, SPEC 11, so + * JSON output is always in effect; the form-exact decode admits no findings + * report and no answer beside it), and the usage message on stderr (12.0). + */ +async function expectAvailabilityUsageError( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + context: string, +): Promise<void> { + const command = `xspec ${argv.join(" ")}`; + const result = await expectExit( + product, + workspace, + argv, + 2, + `${context}: \`${command}\` — the argument checks of 11.3–11.5 precede ` + + `answering, so the usage error exits 2 whatever findings the ` + + `workspace or the named files carry (SPEC 11.2, 12.0)`, + ); + expectErrorDocument( + result, + `${context}: \`${command}\` — the surface is JSON-only, so JSON output ` + + `is in effect and the exit-2 error document is the entire stdout: no ` + + `findings report, no answer beside it (SPEC 11, 12.0, 12.7, H-5)`, + ); + if (result.stderrBytes.length === 0) { + fail( + `${context}: \`${command}\` — usage error messages are ` + + `standard-error content (SPEC 12.0), but stderr is empty`, + ); + } +} + +const T11_2_5 = defineProductTest({ + id: "T11.2-5", + title: + "`view` naming only C — T11.2-1's finding-free file, A and B staying invalid beside it — answers finding-free with exit 0: the domain is the requested files; naming A attaches exactly A's findings of both levels (never B's 14.20), exit 1, the full answer still emitted (the document complete and parseable, H-5); the two-file cycle pair D/E (mutual external `d` references and the mutual imports they force: a dependency cycle and a spec import cycle, 14.9 ×2) accompanies WHOLE — both files' participating locations — when either participant is the domain, and not at all when only the finding-free file is; any finding → exit 1 with the full answer, complete and finding-free → exit 0; argument checks precede answering: unknown `<file>`, wrong-kind `<file>` (a discovered code source), an outside-root `--file` glob, a malformed `--to` (empty segment), and an out-of-range offset each exit 2 with the single 12.7 error document as the entire stdout, whatever findings the workspace or the named files carry (SPEC 11.2, 11.3–11.5, 12.0, 12.7, 14)", + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline): composed-range arithmetic + // proven against the staged bytes before any product invocation. + sliceCheck(D_SOURCE, D_IMPORT_RANGE, D_IMPORT_TEXT, "D's import"); + sliceCheck(D_SOURCE, D_X_D_REF, "E.y", "D's reference expression"); + sliceCheck(D_SOURCE, D_X_OPEN, '<S id="x" d={E.y}>', "D's opening tag"); + sliceCheck(E_SOURCE, E_IMPORT_RANGE, E_IMPORT_TEXT, "E's import"); + sliceCheck(E_SOURCE, E_Y_D_REF, "D.x", "E's reference expression"); + sliceCheck(E_SOURCE, E_Y_OPEN, '<S id="y" d={D.x}>', "E's opening tag"); + + // --- Workspace 1: T11.2-1's A/B/C beside a discovered code source ------ + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + [A_FILE]: A_SOURCE, + [B_FILE]: B_SOURCE, + [C_FILE]: C_SOURCE, + [WRONG_KIND_CODE_FILE]: WRONG_KIND_CODE_SOURCE, + }, + }); + try { + await assertLeavesUnchanged( + workspace.root, + async () => { + // Gate reference and staging integrity: A and B stay invalid — + // exactly T11.2-1's condition multiset, so the reference-free + // code source adds no finding (and C none), and every later + // domain assertion stands on pinned ground (SPEC 12.1, 14). + const buildContext = + "T11.2-5 `build --json` (staging integrity: A and B stay " + + "invalid; the reference-free code source and C contribute " + + "nothing)"; + const buildResult = await expectExit( + product, + workspace, + ["build", "--json"], + 1, + buildContext, + ); + const buildFindings = decodeFindingsReport( + parseJsonStdout(buildResult, buildContext), + buildContext, + ).findings; + assertConditionCounts( + buildFindings, + WORKSPACE_CONDITION_COUNTS, + `${buildContext} — exactly the staged conditions (SPEC 14)`, + ); + assertFindingHomes(buildFindings, buildContext); + + // --- `view` naming only C: the domain is the requested files, + // so nothing of A's or B's attaches — a complete, finding-free + // answer, exit 0, while the workspace stays failing (SPEC 11.2, + // 11.4). + const viewCContext = + "T11.2-5 `view specs/C.mdx` (the finding-free file alone, on " + + "the failing workspace)"; + const viewCResult = await expectExit( + product, + workspace, + ["view", C_FILE], + 0, + `${viewCContext} — a complete, finding-free answer exits 0: ` + + `the consulted domain is the requested files, and A's and ` + + `B's findings are no domain file's (SPEC 11.2)`, + ); + const viewCReport = decodeViewReport( + parseJsonStdout( + viewCResult, + `${viewCContext} — a single JSON document is the only ` + + `output form (SPEC 11)`, + ), + { text: false }, + viewCContext, + ); + assertSameJson( + viewCReport.findings, + [], + `${viewCContext} — the domain's findings alone accompany: ` + + `none — never A's six, never B's 14.20 (SPEC 11.2)`, + ); + assertSameJson( + viewCReport.views.map((view) => view.file), + [C_FILE], + `${viewCContext} — exactly the requested file's view (SPEC 11.4)`, + ); + const viewC = viewCReport.views[0]!; + assertSameJson( + projectNode(viewC.root), + C_TREE, + `${viewCContext} — C's complete view: byte-exact ranges, ` + + `defined identities (SPEC 11.2, 11.4)`, + ); + assertSameJson( + [viewC.imports, viewC.occurrences, viewC.comments], + [[], [], []], + `${viewCContext} — C holds no imports, occurrences, or ` + + `comments: empty arrays, never null (SPEC 12.7)`, + ); + + // --- `view` naming A: A's findings of BOTH levels accompany — + // and only A's — exit 1 with the full answer still emitted: + // exit 1 signals imperfection and never withholds the answer + // (SPEC 11.2, H-5). + const viewAContext = + "T11.2-5 `view specs/A.mdx` (the finding-laden file alone)"; + const viewAResult = await expectExit( + product, + workspace, + ["view", A_FILE], + 1, + `${viewAContext} — the answer carries findings and ` + + `explicitly-unavailable identities, so exit 1 (SPEC 11.2)`, + ); + const viewAReport = decodeViewReport( + parseJsonStdout( + viewAResult, + `${viewAContext} — the full answer document is still ` + + `emitted, complete and parseable (SPEC 11.2, H-5)`, + ), + { text: false }, + viewAContext, + ); + assertConditionCounts( + viewAReport.findings, + A_DOMAIN_CONDITION_COUNTS, + `${viewAContext} — exactly A's findings of both levels ` + + `(resolution-level 14.5/14.9; per-file structural ` + + `14.3/14.4/14.16/14.17) accompany; B's 14.20 is no domain ` + + `file's finding and never attaches (SPEC 11.2)`, + ); + assertFindingHomes(viewAReport.findings, viewAContext); + assertSameJson( + viewAReport.views.map((view) => view.file), + [A_FILE], + `${viewAContext} — the full answer: exactly A's view, never ` + + `withheld for the findings (SPEC 11.2, 11.4)`, + ); + const viewA = viewAReport.views[0]!; + assertSameJson( + projectNode(viewA.root), + A_TREE, + `${viewAContext} — A's full positional tree, byte-exact, ` + + `identities per 11.2 (SPEC 11.2, 11.4)`, + ); + assertSameJson( + viewA.comments, + [A_COMMENT_RANGE], + `${viewAContext} — A's comment ranges served (SPEC 11.4)`, + ); + assertSameJson( + viewA.occurrences, + A_EXPECTED_OCCURRENCES, + `${viewAContext} — A's complete occurrence enumeration ` + + `(SPEC 5.7, 11.2)`, + ); + assertSameJson( + viewA.imports, + [], + `${viewAContext} — A declares no imports (SPEC 12.7)`, + ); + + // --- Argument checks precede answering (SPEC 11.2, 12.0): each + // usage error exits 2 with the single 12.7 error document, + // whatever findings the workspace or the named files carry — + // never exit 1 with the domain's findings. The per-surface + // spelling matrices live at T11.3-2/3, T11.4-2, T11.5-2. + await expectAvailabilityUsageError( + product, + workspace, + ["view", "specs/Nope.mdx"], + "T11.2-5 unknown `<file>` operand (11.4: a file outside the " + + "discovered set is unknown) on the failing workspace", + ); + await expectAvailabilityUsageError( + product, + workspace, + ["view", WRONG_KIND_CODE_FILE], + "T11.2-5 wrong-kind `<file>` operand (11.4: a discovered " + + "code source has no structural view) on the failing " + + "workspace", + ); + await expectAvailabilityUsageError( + product, + workspace, + ["occurrences", "--file", "../outside/*.mdx"], + "T11.2-5 invalid glob (11.3, 11.1: a `--file` pattern " + + "resolving outside the workspace root is an invalid flag " + + "value) on the failing workspace", + ); + await expectAvailabilityUsageError( + product, + workspace, + ["occurrences", "--to", `${A_FILE}#a..b`], + "T11.2-5 malformed `--to` (11.3: an empty segment is not a " + + "well-formed identity spelling) naming the finding-laden A", + ); + await expectAvailabilityUsageError( + product, + workspace, + ["at", A_FILE, String(A_ROOT_RANGE.end + 1)], + "T11.2-5 out-of-range offset (11.5: only the offsets 0 " + + "through the file's byte length resolve) on the " + + "finding-laden A", + ); + }, + "T11.2-5 workspace 1 — no invocation of the sweep modifies " + + "anything: no graph data, no derived files (SPEC 11.2, 12.1, " + + "13.3; staging hygiene — the no-write contract clauses live at " + + "T11.2-1/T11.2-6)", + ); + } finally { + await workspace.dispose(); + } + } + + // --- Workspace 2: the two-file cycle pair beside the finding-free C ---- + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [C_FILE]: C_SOURCE, + [D_FILE]: D_SOURCE, + [E_FILE]: E_SOURCE, + }, + }); + try { + await assertLeavesUnchanged( + workspace.root, + async () => { + // Gate reference and staging integrity: the two cycles are the + // workspace's ONLY conditions, each located whole (SPEC 5.3, + // 2.1, 14.9, 14). + const buildContext = + "T11.2-5 cycle workspace `build --json` (staging integrity: " + + "the dependency cycle and the forced spec import cycle are " + + "the only conditions; C contributes nothing)"; + const buildResult = await expectExit( + product, + workspace, + ["build", "--json"], + 1, + buildContext, + ); + assertCycleFindingsWhole( + decodeFindingsReport( + parseJsonStdout(buildResult, buildContext), + buildContext, + ).findings, + buildContext, + ); + + // --- `view` naming each participant: both joint findings + // accompany WHOLE — the other file's locations included, that + // file lying outside the domain (SPEC 11.2) — with the full + // answer (the participant's complete view) still emitted, + // exit 1. + const participants = [ + { + file: D_FILE, + tree: D_TREE, + imports: D_IMPORTS, + occurrences: D_OCCURRENCES, + what: "D", + }, + { + file: E_FILE, + tree: E_TREE, + imports: E_IMPORTS, + occurrences: E_OCCURRENCES, + what: "E", + }, + ] as const; + for (const participant of participants) { + const context = + `T11.2-5 \`view ${participant.file}\` (one cycle ` + + `participant as the whole domain)`; + const result = await expectExit( + product, + workspace, + ["view", participant.file], + 1, + `${context} — the answer carries the cycle findings, so ` + + `exit 1 with the full answer (SPEC 11.2)`, + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + { text: false }, + context, + ); + assertCycleFindingsWhole(report.findings, context); + assertSameJson( + report.views.map((view) => view.file), + [participant.file], + `${context} — exactly the requested file's view (SPEC 11.4)`, + ); + const view = report.views[0]!; + assertSameJson( + projectNode(view.root), + participant.tree, + `${context} — ${participant.what}'s complete positional ` + + `tree, identities defined: cycle participation never ` + + `undefines an identity (SPEC 11.2)`, + ); + assertSameJson( + view.imports, + participant.imports, + `${context} — the import entry stays on view, resolved: ` + + `the cycle is a finding, never a view omission ` + + `(SPEC 11.4, 2.1)`, + ); + assertSameJson( + view.occurrences, + participant.occurrences, + `${context} — the resolving reference records its ` + + `occurrence, cycle notwithstanding (SPEC 5.7, 11.2)`, + ); + assertSameJson( + view.comments, + [], + `${context} — no comments staged (SPEC 12.7)`, + ); + } + + // --- `view` naming only C: no participant in the domain, so + // neither joint finding attaches — complete and finding-free, + // exit 0 (SPEC 11.2: whole attachment turns on a participating + // file lying in the domain, and only on that). + const calmContext = + "T11.2-5 cycle workspace `view specs/C.mdx` (no cycle " + + "participant in the domain)"; + const calmResult = await expectExit( + product, + workspace, + ["view", C_FILE], + 0, + `${calmContext} — a complete, finding-free answer exits 0: ` + + `the cycle findings belong to D and E, neither in the ` + + `domain (SPEC 11.2)`, + ); + const calmReport = decodeViewReport( + parseJsonStdout( + calmResult, + `${calmContext} — a single JSON document is the only ` + + `output form (SPEC 11)`, + ), + { text: false }, + calmContext, + ); + assertSameJson( + calmReport.findings, + [], + `${calmContext} — neither 14.9 attaches: a joint condition ` + + `accompanies exactly the answers whose domain holds a ` + + `participant (SPEC 11.2)`, + ); + assertSameJson( + calmReport.views.map((view) => view.file), + [C_FILE], + `${calmContext} — exactly C's view (SPEC 11.4)`, + ); + assertSameJson( + projectNode(calmReport.views[0]!.root), + C_TREE, + `${calmContext} — C's complete view (SPEC 11.2, 11.4)`, + ); + }, + "T11.2-5 workspace 2 — no invocation of the sweep modifies " + + "anything (SPEC 11.2, 12.1, 13.3; staging hygiene)", + ); + } finally { + await workspace.dispose(); + } + } + }, +}); + /** TEST-SPEC §11.2, in canonical ID order (SUITE-52). */ export const section112Tests: readonly ProductTestEntry[] = [ T11_2_1, T11_2_2, T11_2_3, T11_2_4, + T11_2_5, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 8bf8226..9a63911 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -302,6 +302,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.2-2": ["11.2"], "T11.2-3": ["11.2"], "T11.2-4": ["11.2"], + "T11.2-5": ["11.2"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From 50638f8661828730aad54b229d87449111be711f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 00:24:59 +0000 Subject: [PATCH 100/226] sdg(phase-9): implement T11.2-6's never-stale gate-findings-never-attach arms (FP-055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register T11.2-6 in section-11.2.ts: two otherwise-valid pre-built fixtures — a garbage journal line (14.13) and the markdown.outDir directory replaced by a plain file (14.22) — where `view` of the finding-free file answers complete and finding-free at exit 0 under whole-root no-write compares, while the state surfaces through `build` (exact multisets, concerned paths pinned) and `check` (non-14.10-exact beside the journal; exactly {14.10,14.22} with both paths pinned on the obstruction fixture, the swap-deleted emission being definite per-file staleness). Traceability ["11.2"]; not in CONF-AVAIL scope (expressly an Exclusions entry). Red-as-diagnosed at the fixture-1 gate build's form-exact decode; conforming-fake scratch run green end-to-end with five deviation fakes failing diagnosed; test:self unchanged at the 4 planned mid-loop reds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 50 +++- test/suite/registry/section-11.2.ts | 411 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 455 insertions(+), 7 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 62e62a8..83707ae 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2370,8 +2370,56 @@ certify against FP-091's fixtures once those land. planned mid-loop reds (certification-document x3 → FP-091; S-1's 6 unmapped keys {11.3–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-055 — Implement T11.2-6: never-stale + gate-findings-never-attach. +- [x] FP-055 — Implement T11.2-6: never-stale + gate-findings-never-attach. [R2 #2; TEST-SPEC §11.2] + [Done 2026-08-14: registered in section-11.2.ts (SUITE-52) with + traceability ["11.2"] (not in TEST-SPEC 14's staging record — 14.13's + home is T6.1-3/T13.4-6, 14.22's T13.4-6; T14-4's and T13.3-3's citations + are cross-references TO it). NOT in CONF-AVAIL scope — expressly an + Exclusions entry (its answer-side no-write compares lean on the + compare-around machinery certified via VIOL-CORE-CHATTYREADS). The + entry's delegations honored: passing-side refresh participation stays + T13.3-2's sweep, failing-side answering T11.2-1's, gated-read breadth + over these fixtures T13.3-3's whole-gate arms (already landed pre-plan), + and the occurrences/at finding-free contrast T13.3-3's never-gated sweep + + T14-4's availability rows — this test owns the two fixtures and the + view never-attach arm with the build/check surfacing. Fixture 1: passing + build, then one garbage line at .xspec/journal (T12.2-2-family-7/T14-4 + staging) — `build --json` exactly {14.13: 1} concerning .xspec/journal, + failing build modifies nothing; `check --json` the same counted over + non-14.10 findings (the T12.2-2 set-aside: the journal feeds canonical + identities, 5.4, so graph-data verifiability beside an unreadable + journal is underdetermined); `view specs/C.mdx` (module C fixture + reused) → findings [], views exactly [C] with C_TREE pinned, + imports/occurrences/comments [], exit 0. Fixture 2: passing build under + `markdown.outDir: "mdout"` (premise-checked: mdout/ dir, emitted + mdout/specs/C.md — T13.3-3's arm-2 discipline), then the outDir + directory replaced by a plain file — `build --json` exactly {14.22: 1} + concerning `mdout`, refusal before any write; `check --json` exactly + {14.10: 1, 14.22: 1} (valid sources make the swap-deleted emission + DEFINITE per-file staleness — the T12.2-2 exactness position — pinning + the swap's whole fallout; paths pinned: mdout, mdout/specs/C.md); `view` + of the very file whose emission path is obstructed → finding-free + complete exit 0. Every invocation under whole-root snapshot compares. + Verified: typecheck/format clean; suite section-11.2 red-as-diagnosed — + T11.2-6 fails at the fixture-1 gate build's FP-001-class form-exact + decode ("expected no member \"condition\""); direct probes against the + built product confirm the stagings fire in the real product (fixture 1: + build/check exit 1 with exactly one old-shape 14.13 naming + .xspec/journal and NOTHING stale beside on the pre-built workspace, + everything unmodified; fixture 2: premise build emits mdout/specs/C.md, + the obstructed build/check exit 70 — the pre-patch vets-no-components + gap FP-018 also observed — and `view` stays an unknown command, exit 2); + scratch run of the registered body against a conforming fake + (re-deriving journal/obstruction/staleness findings and the C view from + workspace bytes) green end-to-end, five deviation fakes each failing + diagnosed — attach-gate at the view arm's exit-0, drop-path at the + 14.22 concerned-path pin, answer-stale at the C_TREE compare (current + sources, not cache), write-on-view at the whole-root compare, + miss-stale at check's exact count map. `npm run test:self`: unchanged 4 + planned mid-loop reds (certification-document x3 → FP-091; S-1's 6 + unmapped keys {11.3–11.6, 12.6, 12.7} → stage G), S-5 and certification + green.] - [ ] FP-056 — Implement T11.3-1: `occurrences` enumeration in the form-exact 12.7 record form. [R2 #3; TEST-SPEC §11.3] New §11.3 registry diff --git a/test/suite/registry/section-11.2.ts b/test/suite/registry/section-11.2.ts index 8acbaf4..7721ca5 100644 --- a/test/suite/registry/section-11.2.ts +++ b/test/suite/registry/section-11.2.ts @@ -1,5 +1,5 @@ // TEST-SPEC §11.2 (availability on imperfect files) — SUITE-52: T11.2-1 -// through T11.2-5. +// through T11.2-6. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -12,11 +12,14 @@ // staging constraint pins every command an in-scope test drives to its // enumerated `view`/`occurrences` surface, so T11.2-2 and T11.2-4 run NO // gate-reference `build`, no `at`, and no `--file` on `occurrences` -// (VIOL-AVAIL-NOFILE's staging constraint) — unlike T11.2-1, T11.2-3, and -// T11.2-5, which are not in scope (CONF-AVAIL's workspace scope is `#`-free -// valid-UTF-8 paths with no code groups, so T11.2-3's staging lies outside -// it by construction, and T11.2-5 — its argument and domain-and-exit -// matrix — is expressly an Exclusions entry): staging integrity rides each +// (VIOL-AVAIL-NOFILE's staging constraint) — unlike T11.2-1, T11.2-3, +// T11.2-5, and T11.2-6, which are not in scope (CONF-AVAIL's workspace scope +// is `#`-free valid-UTF-8 paths with no code groups, so T11.2-3's staging +// lies outside it by construction, and T11.2-5 — its argument and +// domain-and-exit matrix — and T11.2-6 — its answer-side no-write compares +// lean on the compare-around machinery certified through +// VIOL-CORE-CHATTYREADS — are expressly Exclusions entries): staging +// integrity rides each // answer's own exact accompanying-findings multiset instead, and the // staged conditions are // drawn from the scope's stated set (T11.2-2: 14.1, 14.3, 14.4, 14.17; @@ -96,11 +99,14 @@ import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { assertConditionCounts, + assertFindingConcernsPath, assertFindingLocated, assertSameJson, + buildOk, expectErrorDocument, expectExit, runCli, + runJson, } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. @@ -3676,6 +3682,398 @@ const T11_2_5 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T11.2-6 — never stale, gate findings never attach +// --------------------------------------------------------------------------- +// +// SPEC 11.2's closing paragraph, with TEST-SPEC's stated delegations: the +// passing-workspace half — these surfaces participate in read-time refresh +// exactly as 13.3's reads — rides T13.3-2's sweep; the failing-side +// answer-from-current-sources-and-write-nothing discipline is T11.2-1's; +// the gated-read breadth over these two fixtures (each of `ids`, `show`, +// `coverage`, `impact`, `review status`, `query` reporting the gate finding +// without answering) is T13.3-3's whole-gate arms; and the +// `occurrences`/`at` finding-free contrast on the same states rides +// T13.3-3's never-gated sweep and T14-4's availability rows. This test owns +// the two fixtures and the entry's own arms: a gate condition that is NO +// domain file's finding — the journal's 14.13, a write-path component's +// 14.22, each carrying a concerned path that is never a requested file and +// no in-source location — accompanies no answer of these surfaces, while +// the state surfaces through `build` and `check`. +// +// Fixture 1 (garbage journal, 14.13): a passing `build` first — derived +// files and graph data then exist and match, so the later `check` stands on +// pinned ground — then one garbage line written at `.xspec/journal` (the +// journal is written only by `rename`/`move`, SPEC 6.1, so the build left +// it absent; the T12.2-2 family-7 and T14-4 staging). `build --json` and +// `check --json` each report the journal error — build's multiset exact +// ({14.13: 1}: build cannot observe staleness, SPEC 12.1), check's exact +// over the non-14.10 findings (the T12.2-2 set-aside: the journal feeds +// canonical identities, SPEC 5.4, so whether graph data is verifiable +// beside an unreadable journal is underdetermined; no phantom +// non-staleness condition is accepted) — each finding concerning the +// journal path (SPEC 14: a journal condition carries the file it +// concerns). Then `view specs/C.mdx`: the finding-free file's complete +// view, findings [], exit 0 — the workspace fails `build`'s validations +// (journal errors alike, SPEC 13.3), so the surface answers from current +// sources, consults no journal, and the gate finding never attaches. +// +// Fixture 2 (obstructed write path, 14.22): a passing `build` with +// emission under `markdown.outDir` (premise-checked: `mdout/` exists and +// holds the emitted `mdout/specs/C.md`, SPEC 7.3, 13.2), then the outDir +// directory replaced by a plain file — the emit write path's +// workspace-relative component `mdout` is now occupied by a non-directory, +// the one offending component (SPEC 13.4, 14.22; T13.3-3's arm-2 staging). +// `build --json` reports exactly {14.22: 1} concerning `mdout` and +// modifies nothing — the refusal precedes every write (byte-level, H-4: an +// identical regeneration would be invisible, which is exactly the +// contract's grain). `check --json` reports exactly {14.10: 1, 14.22: 1}: +// the swap deleted the emitted Markdown, and on this valid-source +// workspace what the current sources generate is defined, so the missing +// emitted file is definite per-file staleness (the T12.2-2 exactness +// position) — pinning the swap's entire fallout rather than setting it +// aside — the 14.22 concerning `mdout`, the 14.10 concerning the deleted +// `mdout/specs/C.md`. Then `view specs/C.mdx`: finding-free, complete, +// exit 0 — the viewed file is the very file whose emission path is +// obstructed, and the write-path condition is still no domain file's +// finding (its concerned path is the component, never the source). +// +// Every invocation runs under a whole-root snapshot compare (the +// CERTIFICATIONS.md Exclusions note's answer-side no-write compares): the +// view answers write nothing — the garbage journal not repaired or +// deleted, no graph data or derived files touched — and the failing +// build/check modify nothing (SPEC 12.1, 12.2, 14.22). + +const JOURNAL_PATH = ".xspec/journal"; +const T11_2_6_GARBAGE_LINE = + "?? harness-injected garbage: not a journal entry ??\n"; + +const T11_2_6_OUTDIR_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + markdown: { emit: true, outDir: "mdout" } +}) +`; +const T11_2_6_OUTDIR = "mdout"; +const T11_2_6_EMITTED = "mdout/specs/C.md"; + +/** + * The T11.2-6 never-attach arm: `view` naming the finding-free C answers + * complete and finding-free at exit 0 — whatever journal or write-path + * state the workspace holds (SPEC 11.2) — modifying nothing. + */ +async function assertViewOfCFindingFree( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<void> { + await assertLeavesUnchanged( + workspace.root, + async () => { + const report = decodeViewReport( + await runJson( + product, + workspace, + ["view", C_FILE], + `${context} — a complete, finding-free answer exits 0 whatever ` + + `journal or write-path state the workspace holds (SPEC 11.2)`, + ), + { text: false }, + context, + ); + assertSameJson( + report.findings, + [], + `${context} — the gate condition is the finding of no domain file ` + + `(no in-source location, its concerned path never a requested ` + + `file), so it accompanies no answer of this surface (SPEC 11.2, ` + + `14; the gated reads report it instead, T13.3-3)`, + ); + assertSameJson( + report.views.map((view) => view.file), + [C_FILE], + `${context} — exactly the requested file's view (SPEC 11.4)`, + ); + const cView = report.views[0]!; + assertSameJson( + projectNode(cView.root), + C_TREE, + `${context} — C's complete view: the answer is served whole, from ` + + `the current sources (SPEC 11.2, 11.4)`, + ); + assertSameJson( + [cView.imports, cView.occurrences, cView.comments], + [[], [], []], + `${context} — C holds no imports, occurrences, or comments: empty ` + + `arrays, never null (SPEC 12.7)`, + ); + }, + `${context} — the answer consults no journal and no record and writes ` + + `nothing: journal, graph data, and derived files byte-identical ` + + `around the invocation (SPEC 11.2, 13.3)`, + ); +} + +const T11_2_6 = defineProductTest({ + id: "T11.2-6", + title: + "gate findings never attach: on an otherwise-valid pre-built workspace with a garbage journal line staged (14.13), and separately with the `markdown.outDir` directory replaced by a plain file (14.22, the obstructed emit write path's one offending component), `view` of the finding-free file answers complete and finding-free at exit 0, writing nothing — the state surfaces through `build` (exactly the gate condition; a failing build modifies nothing) and `check` (the gate condition beside the obstruction fixture's one definite per-file staleness, each concerned path pinned: the journal path, the offending component, the deleted emitted file), and through the gated reads (T13.3-3), never these answers; the passing-workspace refresh participation is T13.3-2's sweep and the failing-side answering discipline T11.2-1's (SPEC 11.2, 13.3, 12.1, 12.2, 14.13, 14.22, 14.10)", + run: async (product) => { + // --- Fixture 1: garbage journal line (14.13) -------------------------- + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [C_FILE]: C_SOURCE, + }, + }); + try { + const context = "T11.2-6 (garbage journal)"; + await buildOk( + product, + workspace, + `${context} staging \`build\` — a passing build, so derived ` + + `files and graph data exist and match before the journal is ` + + `garbaged (SPEC 12.1)`, + ); + await workspace.file(JOURNAL_PATH, T11_2_6_GARBAGE_LINE); + + // The state surfaces through `build`: exactly the staged gate + // condition, concerning the journal path (SPEC 14.13, 14, 12.1). + const buildContext = `${context} \`build --json\``; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + ["build", "--json"], + 1, + `${buildContext} — journal errors are among \`build\`'s ` + + `validations (SPEC 12.1, 13.3, 14.13)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, buildContext), + buildContext, + ).findings; + assertConditionCounts( + findings, + { "14.13": 1 }, + `${buildContext} — exactly the staged gate condition: the ` + + `pre-built otherwise-valid workspace stages nothing else, ` + + `and \`build\` cannot observe staleness (SPEC 14.13, 12.1)`, + ); + assertFindingConcernsPath( + findings[0]!, + JOURNAL_PATH, + `${buildContext} — a journal condition carries the journal ` + + `path it concerns (SPEC 14, 12.7)`, + ); + }, + `${buildContext} — a failing build modifies nothing, the garbage ` + + `journal included (SPEC 12.1, 6.1)`, + ); + + // ...and through `check` (SPEC 12.2, 14.13): the gate condition + // counted exactly over the non-14.10 findings (the T12.2-2 + // set-aside — the journal feeds canonical identities, SPEC 5.4, so + // whether graph data is verifiable beside an unreadable journal is + // underdetermined; no phantom non-staleness condition is accepted). + const checkContext = `${context} \`check --json\``; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${checkContext} — \`check\` performs all build validations, ` + + `journal errors included (SPEC 12.2, 14.13)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, checkContext), + checkContext, + ).findings; + const nonStale = findings.filter( + (finding) => finding.condition !== "14.10", + ); + assertConditionCounts( + nonStale, + { "14.13": 1 }, + `${checkContext} — the journal error is reported, and no ` + + `condition beside it save 14.10 (SPEC 12.2, 14.13)`, + ); + assertFindingConcernsPath( + nonStale[0]!, + JOURNAL_PATH, + `${checkContext} — the journal condition's concerned path ` + + `(SPEC 14, 12.7)`, + ); + }, + `${checkContext} — \`check\` writes nothing (SPEC 12.2, 13.3)`, + ); + + // ...never this answer: `view` of the finding-free file (SPEC 11.2). + await assertViewOfCFindingFree( + product, + workspace, + `${context} \`view ${C_FILE}\``, + ); + } finally { + await workspace.dispose(); + } + } + + // --- Fixture 2: obstructed write path (14.22) ------------------------- + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": T11_2_6_OUTDIR_CONFIG, + [C_FILE]: C_SOURCE, + }, + }); + try { + const context = "T11.2-6 (obstructed write path)"; + await buildOk( + product, + workspace, + `${context} staging \`build\` — emits under markdown.outDir ` + + `(SPEC 7.3, 13.2, 12.1)`, + ); + + // Staging premises (T13.3-3's arm-2 discipline): emission landed + // under mdout/ preserving workspace-relative paths (SPEC 7.3, + // 13.2), so mdout is a component of a path `build` writes. + const mdoutKind = await workspace.kind(T11_2_6_OUTDIR); + if (mdoutKind !== "dir") { + fail( + `${context}: staging premise — \`build\` with emission enabled ` + + `under markdown.outDir creates the mdout/ directory (SPEC ` + + `7.3, 13.2, 13.4); found ${mdoutKind}`, + ); + } + const emittedKind = await workspace.kind(T11_2_6_EMITTED); + if (emittedKind !== "file") { + fail( + `${context}: staging premise — emission under outDir preserves ` + + `workspace-relative paths, so ${C_FILE} emits ` + + `${T11_2_6_EMITTED} (SPEC 7.3, 13.2); found ${emittedKind}`, + ); + } + + // Obstruct: replace the directory with a plain file. The emitted + // Markdown goes with it — definite per-file staleness for `check` + // on this valid-source workspace, invisible to `build`, which + // refuses at the obstruction (SPEC 13.4, 14.22, 14.10). + await fsp.rm(workspace.path(T11_2_6_OUTDIR), { + recursive: true, + force: true, + }); + await workspace.file(T11_2_6_OUTDIR, "not a directory\n"); + + // The state surfaces through `build`: exactly the one condition-22 + // finding — one finding per distinct offending component — + // concerning the component's workspace-relative path, and the + // refusal precedes every write (SPEC 14.22, 13.4, 12.1). + const buildContext = `${context} \`build --json\``; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + ["build", "--json"], + 1, + `${buildContext} — a command refuses the obstructed write ` + + `and reports it (SPEC 14.22, 13.4)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, buildContext), + buildContext, + ).findings; + assertConditionCounts( + findings, + { "14.22": 1 }, + `${buildContext} — exactly the one offending component, and ` + + `\`build\` cannot observe the deleted emission's staleness ` + + `(SPEC 14.22, 12.1)`, + ); + assertFindingConcernsPath( + findings[0]!, + T11_2_6_OUTDIR, + `${buildContext} — the refused write's concerned path is the ` + + `offending component's workspace-relative path (SPEC ` + + `14.22, 13.4)`, + ); + }, + `${buildContext} — the write is refused before anything is ` + + `modified (SPEC 14.22, 12.1)`, + ); + + // ...and through `check`: the obstruction beside the swap's one + // definite per-file staleness — exact counts, each concerned path + // pinned (SPEC 12.2, 14.22, 14.10; SPEC 14: when several error + // conditions are present, each is reported). + const checkContext = `${context} \`check --json\``; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${checkContext} — \`check\` reports the obstruction without ` + + `writing (SPEC 12.2, 14.22)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, checkContext), + checkContext, + ).findings; + assertConditionCounts( + findings, + { "14.10": 1, "14.22": 1 }, + `${checkContext} — the obstructed component and the deleted ` + + `emitted file, nothing else: sources are valid, so what ` + + `the current sources generate is defined and the missing ` + + `${T11_2_6_EMITTED} is definite per-file staleness (SPEC ` + + `14.22, 14.10, 12.2, 14)`, + ); + assertFindingConcernsPath( + findings.find((finding) => finding.condition === "14.22")!, + T11_2_6_OUTDIR, + `${checkContext} — the refused write's concerned path (SPEC ` + + `14.22, 13.4)`, + ); + assertFindingConcernsPath( + findings.find((finding) => finding.condition === "14.10")!, + T11_2_6_EMITTED, + `${checkContext} — the per-file staleness finding names the ` + + `stale file as its concerned path (SPEC 14.10, 12.7)`, + ); + }, + `${checkContext} — \`check\` writes nothing (SPEC 12.2, 13.3)`, + ); + + // ...never this answer: `view` of the very file whose emission + // path is obstructed (SPEC 11.2 — the condition's concerned path + // is the component, never the source file). + await assertViewOfCFindingFree( + product, + workspace, + `${context} \`view ${C_FILE}\``, + ); + } finally { + await workspace.dispose(); + } + } + }, +}); + /** TEST-SPEC §11.2, in canonical ID order (SUITE-52). */ export const section112Tests: readonly ProductTestEntry[] = [ T11_2_1, @@ -3683,4 +4081,5 @@ export const section112Tests: readonly ProductTestEntry[] = [ T11_2_3, T11_2_4, T11_2_5, + T11_2_6, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 9a63911..c86f45c 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -303,6 +303,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.2-3": ["11.2"], "T11.2-4": ["11.2"], "T11.2-5": ["11.2"], + "T11.2-6": ["11.2"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From 8cfceb2505d764da7149b1614daefe211951bac7 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 16:51:06 +0000 Subject: [PATCH 101/226] sdg(phase-9): implement T11.3-1's occurrences enumeration over shared fixtures (FP-056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New section-11.3.ts (SUITE-53) registers T11.3-1: the complete record sequence per index in occurrence order over the four T5.7-* fixtures (identity-level tuples for T5.7-1/-4, byte-precise own ranges for T5.7-2, full-record equality for T5.7-3), plus the source-unavailable arms over T11.2-3's invalid-path code source and T11.2-4's resolution matrix — every fixture imported from its owning module (section-5.7.ts and section-11.2.ts gain export-only edits with stated order contracts), the body re-earning every imported claim via slice and sortedness self-checks before any product invocation. Traceability maps "11.3"; S-1's unmapped set narrows 6 -> 5 keys; red-as-diagnosed at the pre-patch product's unknown `occurrences` command. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 41 +- test/suite/registry/index.ts | 2 + test/suite/registry/section-11.2.ts | 25 +- test/suite/registry/section-11.3.ts | 661 ++++++++++++++++++++++++++++ test/suite/registry/section-5.7.ts | 87 ++-- test/suite/registry/traceability.ts | 1 + test/suite/section-11.3.test.ts | 9 + 7 files changed, 785 insertions(+), 41 deletions(-) create mode 100644 test/suite/registry/section-11.3.ts create mode 100644 test/suite/section-11.3.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 83707ae..e4c6bcc 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2421,9 +2421,48 @@ certify against FP-091's fixtures once those land. unmapped keys {11.3–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-056 — Implement T11.3-1: `occurrences` enumeration in the +- [x] FP-056 — Implement T11.3-1: `occurrences` enumeration in the form-exact 12.7 record form. [R2 #3; TEST-SPEC §11.3] New §11.3 registry module; map `"11.3"`. Uses FP-001's literal decode. + [Done 2026-08-14: registered in new section-11.3.ts (SUITE-53; wrapper + section-11.3.test.ts; spread into registry/index.ts) with traceability + ["11.3"] (no TEST-SPEC 14 staging record — the FP-050..055 precedent; + 5.7/11.2/12.7 are context with home coverage at T5.7-*/T11.2-3/-4/ + T12.7-1); no certification scope. The entry's fixtures are imported, + never copied: section-5.7.ts and section-11.2.ts now export their staging + constants and expectation tables (export-only edits, bodies untouched; + the T5.7-1/T5.7-4 unit tables carry a stated order contract — listed in + occurrence order — that this test expands by position). Per fixture, the + COMPLETE record sequence is asserted PER INDEX in occurrence order + through bare `occurrences` (JSON-only; decodeOccurrencesReport enforces + the exact five-member 12.7 record form, the never-null source datum, and + the 5.7 comparator): T5.7-1's eleven records as identity-level tuples + (both duplicate pairs adjacent — T5.7-1 pins the same multiset + order-free, this test adds the order), T5.7-2's six with byte-precise + own ranges, T5.7-3's six via full-record equality (every 5.7 datum + byte-precise), T5.7-4's three resolving spellings (exit 1, staging + integrity as the exact {14.5,14.6,14.7,14.8} count map); the + unavailability arms restage T11.2-3's code source (OK.mdx + src/co#de.ts: + findings exactly one 14.19 pinned {code invalid-source-path, locations + [], path src/co#de.ts}, enumeration exactly CS_EXPECTED_OCCURRENCES) and + T11.2-4's resolution matrix (R.mdx: counts {14.1,14.3,14.5}, enumeration + exactly R_EXPECTED_OCCURRENCES) — `source` exactly the marker, + file/range/kind/target present, never a picked bearer, never a dropped + record. The body re-earns every imported claim before any product + invocation: slice self-checks over all claimed ranges (SPAN/ORD/CS/R) + plus claimed-sequence sortedness checks under the pinned comparator. + Verified: typecheck/format clean; scratch probes (deleted): the + T5.7-1/T5.7-4 tuple orders proven against mechanically derived spelling + byte positions, and against the built product the six stagings fire as + diagnosed (arms 1–3 build exit 0; arm 4 exactly {14.5,14.6,14.7,14.8} + old-shape; arm 5 exactly one 14.19 naming co#de.ts; arm 6 {14.1,14.3} — + the absent 14.5 is FP-053's diagnosed pre-patch resolution gap); suite + section-11.3 red-as-diagnosed at the first arm (`occurrences` unknown + command, exit 2, the pre-patch product gap; every self-check passes + first); sections 5.7/11.2 unchanged (4 resp. 6 diagnosed reds). `npm run + test:self`: 4 planned mid-loop reds with S-1's unmapped set narrowed + 6 → 5 keys, exactly {11.4, 11.5, 11.6, 12.6, 12.7} ("11.3" now mapped; + certification-document ×3 → FP-091), S-5 and certification green.] - [ ] FP-057 — Implement T11.3-2: `--file` set restriction. [R2 #3; TEST-SPEC §11.3] - [ ] FP-058 — Implement T11.3-3: `--to` syntactic acceptance / malformed diff --git a/test/suite/registry/index.ts b/test/suite/registry/index.ts index 7c5ebb0..a04a229 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -57,6 +57,7 @@ import { section107iTests } from "./section-10.7-i.js"; import { section107iiTests } from "./section-10.7-ii.js"; import { section11Tests } from "./section-11.js"; import { section112Tests } from "./section-11.2.js"; +import { section113Tests } from "./section-11.3.js"; import { section120iTests } from "./section-12.0-i.js"; import { section120iiTests } from "./section-12.0-ii.js"; import { section121to122Tests } from "./section-12.1-12.2.js"; @@ -122,6 +123,7 @@ export const productTestSuite = new ProductTestSuite([ ...section107iiTests, ...section11Tests, ...section112Tests, + ...section113Tests, ...section120iTests, ...section120iiTests, ...section121to122Tests, diff --git a/test/suite/registry/section-11.2.ts b/test/suite/registry/section-11.2.ts index 7721ca5..3796caf 100644 --- a/test/suite/registry/section-11.2.ts +++ b/test/suite/registry/section-11.2.ts @@ -110,7 +110,10 @@ import { } from "./support.js"; // Minimal declarative configuration (SPEC 7): exactly one spec group. -const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" +// Exported (with the T11.2-3 code-source and T11.2-4 resolution-matrix +// staging constants below): T11.3-1 asserts the same stagings' enumerations +// through `occurrences` (registry/section-11.3.ts imports, never copies). +export const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -1271,7 +1274,7 @@ const T11_2_2 = defineProductTest({ // One spec group plus one code group (SPEC 7.2), so `src/**/*.ts` files are // discovered code sources and their spec-module usage is analyzed (4.3, 4.5). -const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" +export const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -1287,7 +1290,7 @@ export default defineConfig({ const NON_UTF8_STAGED = process.platform === "linux"; // --- specs/OK.mdx — the valid-path contrast and reference target ------------- -const OK_FILE = "specs/OK.mdx"; +export const OK_FILE = "specs/OK.mdx"; const OK = new ByteFixture(); OK.add("Préambule — valid-path contrast.\n\n"); const OK_SEC_START = OK.pos; @@ -1296,7 +1299,7 @@ const OK_ID = OK.attr("id", 'id="ok"'); OK.add(">\nOK text.\n</S>"); const OK_SEC_RANGE: SourceRange = { start: OK_SEC_START, end: OK.pos }; OK.add("\n"); -const OK_SOURCE = OK.source; +export const OK_SOURCE = OK.source; const OK_ROOT_RANGE: SourceRange = { start: 0, end: OK.pos }; const OK_NODE_ID = `${OK_FILE}#ok`; @@ -1354,7 +1357,7 @@ const NU_ROOT_RANGE: SourceRange = { start: 0, end: NU.pos }; // bare top-level marker `SPEC.ok` (whole-file attribution; its occurrence // spans the bare reference chain alone, exclusive of the terminator). The // multi-byte comment prefix shifts every later offset (SPEC 1.7). -const CS_FILE = "src/co#de.ts"; +export const CS_FILE = "src/co#de.ts"; const CS = new ByteFixture(); CS.add("// Präambel — invalid-path code source.\n"); CS.add('import SPEC, { text } from "../specs/OK.xspec";\n'); @@ -1365,7 +1368,7 @@ CS.add(";\n}\n\n"); const CS_MARKER_TEXT = "SPEC.ok"; const CS_MARKER_RANGE = CS.add(CS_MARKER_TEXT); CS.add(";\n"); -const CS_SOURCE = CS.source; +export const CS_SOURCE = CS.source; // The invalid-path code source's complete occurrence enumeration (SPEC 5.7, // 11.2): both spellings resolve (the referenced identity `specs/OK.mdx#ok` @@ -1374,7 +1377,7 @@ const CS_SOURCE = CS.source; // withheld together as one datum; never a picked identity, never a dropped // record). No other staged file holds a reference spelling, so this is the // workspace's whole enumeration, in occurrence order (range start). -const CS_EXPECTED_OCCURRENCES: readonly OccurrenceRecord[] = [ +export const CS_EXPECTED_OCCURRENCES: readonly OccurrenceRecord[] = [ { file: CS_FILE, range: CS_CALL_RANGE, @@ -1950,7 +1953,7 @@ function findingByCondition( // ambiguous reference, recording nothing and reporting 14.5. The multi-byte // prefix shifts every later offset (SPEC 1.7). -const R_FILE = "specs/R.mdx"; +export const R_FILE = "specs/R.mdx"; const R = new ByteFixture(); R.add("Prélude — resolution turns on the target identity's definedness.\n\n"); const R_A1_START = R.pos; @@ -1990,7 +1993,7 @@ const R_Q_OPEN_END = R.pos; R.add("\nAmbiguous reference.\n</S>"); const R_Q_RANGE: SourceRange = { start: R_Q_START, end: R.pos }; R.add("\n"); -const R_SOURCE = R.source; +export const R_SOURCE = R.source; const R_ROOT_RANGE: SourceRange = { start: 0, end: R.pos }; const R_AB_NODE_ID = `${R_FILE}#a.b`; @@ -2044,7 +2047,7 @@ const R_TREE: TreeExpectation = { // `range`, `kind`, `target` present — while the ambiguous reference to `a` // records nothing: no record, no unavailable target (the exact set pins // both "never a picked bearer's identity" and "never a dropped record"). -const R_EXPECTED_OCCURRENCES: readonly OccurrenceRecord[] = [ +export const R_EXPECTED_OCCURRENCES: readonly OccurrenceRecord[] = [ { file: R_FILE, range: R_A2_D_REF, @@ -2068,7 +2071,7 @@ const R_EXPECTED_OCCURRENCES: readonly OccurrenceRecord[] = [ // a record). No 14.2 anywhere: `a.b` extends its parent's spelling exactly, // the id-less section's structural check is masked and it has no section // children, and every other spelled identity is one segment at top level. -const R_CONDITION_COUNTS: Readonly<Record<string, number>> = { +export const R_CONDITION_COUNTS: Readonly<Record<string, number>> = { "14.1": 1, "14.3": 1, "14.5": 1, diff --git a/test/suite/registry/section-11.3.ts b/test/suite/registry/section-11.3.ts new file mode 100644 index 0000000..eacf5e1 --- /dev/null +++ b/test/suite/registry/section-11.3.ts @@ -0,0 +1,661 @@ +// TEST-SPEC §11.3 (`xspec occurrences`) — SUITE-53: T11.3-1. +// +// Registered product-facing bodies (C-2 "one code path"): each builds its own +// fresh workspace (H-1), drives the product strictly as a subprocess (H-2), +// asserts exact exit codes (H-5), and rejects a product only via diagnosed +// assertion failures (H-8). SPEC 11: `occurrences` is JSON-only — a single +// JSON document is its only output form, with or without `--json` — in the +// form-exact 12.7 document form (H-3), so every invocation below runs bare +// and its entire stdout decodes through `decodeOccurrencesReport`, which +// enforces the record form (exactly `{"file", "range", "kind", "source", +// "target"}`, the source datum `{"identity", "range"}` or the unavailability +// marker, never `null`) and the occurrence order (SPEC 5.7: file path bytes, +// then range start, then range end; identical spans rejected) over whatever +// the product emits. +// +// T11.3-1 runs over fixtures OWNED ELSEWHERE and imported, never copied, so +// the stagings cannot drift: the four T5.7-* workspaces +// (registry/section-5.7.ts — TEST-SPEC §11.3's "over the T5.7-* fixtures") +// and the two source-side unavailability stagings, T11.2-3's invalid-path +// code source and T11.2-4's resolution-matrix spec source +// (registry/section-11.2.ts). What this test adds over those homes is the +// §11.3 enumeration contract per fixture: the COMPLETE record sequence +// asserted PER INDEX in occurrence order — T5.7-1 and T5.7-4 pin their +// records as order-free multisets; here the same records are order-pinned — +// with each datum's value pinned at the precision the owning fixture +// composes: identity-level tuples for T5.7-1's eleven and T5.7-4's three +// records (their two ranges enforced as present well-formed 12.7 range +// forms by the decode; byte-precision for spans and source constructs is +// T5.7-2's and T5.7-3's subject), byte-precise own ranges for T5.7-2's six +// arms, and every 5.7 datum byte-precise for T5.7-3's six records and both +// unavailability stagings. Exits follow 11.2 (asserted per arm: 0 for the +// complete finding-free enumerations, 1 wherever findings or unavailable +// datums accompany); the imperfect stagings' finding detail (windows, +// identities) stays at its homes — here each answer's findings are pinned +// as exact condition-count multisets (staging integrity riding the answer +// itself), plus the code/path projection for the code-source arm's single +// path-level finding. + +import { Buffer } from "node:buffer"; +import type { + OccurrenceRecord, + PathValue, + SourceRange, +} from "../../helpers/adapters/index.js"; +import { decodeOccurrencesReport } from "../../helpers/adapters/index.js"; +import { fail, parseJsonStdout } from "../../helpers/assertions.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import type { OccurrenceUnit } from "./section-5.7.js"; +import { + APP_FILE, + BASE_FILE, + MAIN_FILE, + NO_OCC_APP_SOURCE, + NO_OCC_BASE_SOURCE, + NO_OCC_EXPECTED_CONDITIONS, + NO_OCC_MAIN_SOURCE, + NO_OCC_SPARE_FILE, + NO_OCC_SPARE_SOURCE, + NO_OCC_UNITS, + ORD_ALPHA_FILE, + ORD_ALPHA_SOURCE, + ORD_APP_FILE, + ORD_APP_SOURCE, + ORD_EXPECTED, + ORD_ZED_FILE, + ORD_ZED_SOURCE, + SPAN_ARMS, + SPAN_APP_SOURCE, + SPAN_BASE_SOURCE, + SPAN_MAIN_SOURCE, + SPEC_AND_CODE_CONFIG, + T5_7_1_APP_SOURCE, + T5_7_1_BASE_SOURCE, + T5_7_1_MAIN_SOURCE, + T5_7_1_UNITS, +} from "./section-5.7.js"; +import { + CS_EXPECTED_OCCURRENCES, + CS_FILE, + CS_SOURCE, + OK_FILE, + OK_SOURCE, + R_CONDITION_COUNTS, + R_EXPECTED_OCCURRENCES, + R_FILE, + R_SOURCE, + SPEC_AND_CODE_CONFIG as AVAILABILITY_SPEC_AND_CODE_CONFIG, + SPECS_ONLY_CONFIG, +} from "./section-11.2.js"; +import { + assertConditionCounts, + assertSameJson, + buildOk, + expectExit, + runJson, +} from "./support.js"; + +/** The 12.7 unavailability marker, as decoded (one-datum state). */ +const UNAVAILABLE = { unavailable: true } as const; + +/** + * A record's identity-level projection: every 5.7 datum except the two byte + * ranges (the occurrence's own and the source node's), whose presence and + * form the decode has already enforced on every record and whose byte-exact + * values are pinned by the arms whose fixtures compose them. The `source` + * member projects to the source node's identity — or the unavailability + * marker, exactly as served. + */ +interface RecordTuple { + readonly file: PathValue; + readonly kind: OccurrenceRecord["kind"]; + readonly source: string | typeof UNAVAILABLE; + readonly target: string; +} + +function projectTuple(record: OccurrenceRecord): RecordTuple { + return { + file: record.file, + kind: record.kind, + source: + "unavailable" in record.source ? UNAVAILABLE : record.source.identity, + target: record.target, + }; +} + +/** + * A unit table's expected tuple sequence, each unit expanded to its record + * count IN TABLE POSITION — the tables are exported in occurrence order + * (their stated contract in section-5.7.ts), so the expansion is the + * complete per-index expectation. A same-tuple duplicate pair (T5.7-1's + * `dup` entries and its twice-spelled marker) expands to adjacent equal + * tuples — exactly where the pinned comparator places the pair's two + * distinct spans within one file. + */ +function expandUnits(units: readonly OccurrenceUnit[]): RecordTuple[] { + return units.flatMap((unit) => + Array.from({ length: unit.count }, () => ({ + file: unit.file, + kind: unit.kind, + source: unit.source, + target: unit.target, + })), + ); +} + +/** + * Fixture self-check (harness-side, before any product invocation): a + * claimed byte range must slice the staged file's bytes to exactly the span + * it claims (the T5.7-2/T1.7-2 discipline). A failure here is a + * staging-arithmetic defect of the harness, never a product failure. + */ +function sliceCheck( + source: string, + range: SourceRange, + span: string, + what: string, +): void { + const actual = Buffer.from(source, "utf8") + .subarray(range.start, range.end) + .toString("utf8"); + if (actual !== span) { + fail( + `T11.3-1 fixture self-check — ${what}: the claimed byte range ` + + `[${String(range.start)}, ${String(range.end)}) slices the staged ` + + `bytes to ${JSON.stringify(actual)}, expected ` + + `${JSON.stringify(span)} (a harness-side staging error, not a ` + + `product failure)`, + ); + } +} + +/** + * Fixture self-check: a claimed expected sequence must be strictly + * increasing under the pinned occurrence comparator — file path bytes, then + * range start, then range end (SPEC 5.7) — so a mis-ordered expectation + * fails harness-side, never as a wrong-but-satisfiable one. Every staged + * fixture here uses plain-string (valid-UTF-8) paths; a non-string claimed + * file is itself a staging defect. + */ +function assertClaimedOrder( + claimed: readonly { readonly file: PathValue; readonly range: SourceRange }[], + what: string, +): void { + const fileBytes = (file: PathValue, index: number): Buffer => { + if (typeof file !== "string") { + fail( + `T11.3-1 fixture self-check — ${what}: claimed record ` + + `${String(index)} carries a non-string file; the shared fixtures ` + + `stage plain valid-UTF-8 paths only (a harness-side staging error)`, + ); + } + return Buffer.from(file, "utf8"); + }; + for (let i = 1; i < claimed.length; i += 1) { + const a = claimed[i - 1]!; + const b = claimed[i]!; + const byFile = Buffer.compare( + fileBytes(a.file, i - 1), + fileBytes(b.file, i), + ); + const order = + byFile !== 0 + ? byFile + : a.range.start !== b.range.start + ? a.range.start - b.range.start + : a.range.end - b.range.end; + if (order >= 0) { + fail( + `T11.3-1 fixture self-check — ${what}: the claimed sequence is not ` + + `strictly increasing under the pinned occurrence comparator at ` + + `index ${String(i)} (SPEC 5.7; a harness-side staging error, not ` + + `a product failure)`, + ); + } + } +} + +const T11_3_1 = defineProductTest({ + id: "T11.3-1", + title: + 'enumeration over the T5.7-* fixtures (imported from section-5.7.ts, never copied): bare `occurrences` — JSON-only, a single 12.7 document — reports every occurrence in occurrence order, the complete record sequence asserted per index against each staged workspace (T5.7-1\'s eleven records with both duplicate pairs, T5.7-2\'s six with byte-precise own ranges, T5.7-3\'s six with every 5.7 datum byte-precise, T5.7-4\'s three resolving spellings with the domain\'s findings accompanying, exit 1), each record in the form-exact 12.7 record form {"file", "range", "kind", "source", "target"} (T12.7-1\'s form, decode-enforced with the 5.7 comparator); in T11.2-3\'s invalid-path code source, and equally at T11.2-4\'s spec-source arm (resolving spellings inside a duplicate-`id` bearer and an id-less section), records are served with `source` exactly the unavailability marker while `file`, `range`, `kind`, and `target` are present — never a picked identity, never a dropped record (SPEC 11.3, 5.7, 11.2, 12.7)', + run: async (product) => { + // Fixture self-checks over every claimed byte range and every claimed + // order (harness-side, before any product invocation): the imported + // expectation tables re-earn their claims in this body, so a restage in + // the owning module that breaks a claim fails here as a harness + // diagnosis, never as a wrong-but-satisfiable expectation. + for (const arm of SPAN_ARMS) { + sliceCheck( + arm.fileSource, + arm.range, + arm.span, + `T5.7-2 fixture, ${arm.what}`, + ); + } + assertClaimedOrder(SPAN_ARMS, "the T5.7-2 fixture's claimed sequence"); + for (const arm of ORD_EXPECTED) { + sliceCheck( + arm.fileSource, + arm.record.range, + arm.occurrenceSpan, + `T5.7-3 fixture, ${arm.what} — the occurrence's own span`, + ); + sliceCheck( + arm.fileSource, + arm.record.source.range, + arm.sourceSpan, + `T5.7-3 fixture, ${arm.what} — the source node's construct range`, + ); + } + assertClaimedOrder( + ORD_EXPECTED.map((arm) => arm.record), + "the T5.7-3 fixture's claimed sequence", + ); + sliceCheck( + CS_SOURCE, + CS_EXPECTED_OCCURRENCES[0]!.range, + "text(SPEC.ok)", + "T11.2-3's code source — the call expression's span", + ); + sliceCheck( + CS_SOURCE, + CS_EXPECTED_OCCURRENCES[1]!.range, + "SPEC.ok", + "T11.2-3's code source — the bare marker chain's span", + ); + sliceCheck( + R_SOURCE, + R_EXPECTED_OCCURRENCES[0]!.range, + '"a.b"', + "T11.2-4's spec source — the second bearer's `d` reference expression", + ); + sliceCheck( + R_SOURCE, + R_EXPECTED_OCCURRENCES[1]!.range, + '{text("a.b")}', + "T11.2-4's spec source — the id-less section's embedding container", + ); + + // --- The T5.7-1 fixture (units and duplicates): eleven records. ----------- + // Expected order (SPEC 5.7), realized by expanding the exported unit + // table in position: `specs/MAIN.mdx` ("sp" 0x70) sorts before + // `src/app.ts` ("sr" 0x72) by path bytes; within MAIN the spellings in + // source order — `tri`'s three array entries left to right, `solo`'s + // single reference, `emb`'s container, `dup`'s two entries — and within + // the TS file the `useText` call, the `once` marker, then `twice`'s two + // markers. `specs/BASE.mdx` spells no reference and contributes none. + { + const context = "T11.3-1 over the T5.7-1 fixture (units and duplicates)"; + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + [BASE_FILE]: T5_7_1_BASE_SOURCE, + [MAIN_FILE]: T5_7_1_MAIN_SOURCE, + [APP_FILE]: T5_7_1_APP_SOURCE, + }, + }); + try { + await buildOk( + product, + workspace, + `${context} — \`build\` (premise: the workspace is valid, so the ` + + `enumeration is complete and finding-free, SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences"], + `${context} — bare \`occurrences\`: a complete, finding-free ` + + `answer exits 0 (SPEC 11.2, 11.3)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the consulted domain (the entire discovered set, no ` + + `\`--file\`) carries no finding (SPEC 11.2, 11.3)`, + ); + assertSameJson( + report.occurrences.map(projectTuple), + expandUnits(T5_7_1_UNITS), + `${context}: the COMPLETE eleven-record sequence per index in ` + + `occurrence order — one record per \`d\` array entry (never one ` + + `for the array or the prop, SPEC 2.2), one per embedding, call, ` + + `and marker, two per duplicate pair, each carrying its edge ` + + `kind, source identity, and target — T5.7-1 pins this multiset ` + + `order-free; the §11.3 contract adds the per-index order (SPEC ` + + `5.7, 11.3)`, + ); + } finally { + await workspace.dispose(); + } + } + + // --- The T5.7-2 fixture (spans): six records, own ranges byte-precise. ---- + { + const context = "T11.3-1 over the T5.7-2 fixture (byte-precise spans)"; + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + "specs/BASE.mdx": SPAN_BASE_SOURCE, + "specs/MAIN.mdx": SPAN_MAIN_SOURCE, + "src/app.ts": SPAN_APP_SOURCE, + }, + }); + try { + await buildOk( + product, + workspace, + `${context} — \`build\` (premise: every staged reference is a ` + + `sanctioned spelling that resolves, SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences"], + `${context} — bare \`occurrences\`: a complete, finding-free ` + + `answer exits 0 (SPEC 11.2, 11.3)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the consulted domain carries no finding (SPEC 11.2, ` + + `11.3)`, + ); + if (report.occurrences.length !== SPAN_ARMS.length) { + fail( + `${context}: expected exactly ${String(SPAN_ARMS.length)} ` + + `records — one per staged reference, in occurrence order ` + + `(SPEC 5.7) — got ${String(report.occurrences.length)}: ` + + JSON.stringify(report.occurrences), + ); + } + SPAN_ARMS.forEach((arm, index) => { + assertSameJson( + projectTuple(report.occurrences[index]!), + { + file: arm.file, + kind: arm.kind, + source: arm.source, + target: arm.target, + }, + `${context} record [${String(index)}] — ${arm.what}: the ` + + `record's identity-level data at its pinned position (SPEC ` + + `5.7, 11.3)`, + ); + assertSameJson( + report.occurrences[index]!.range, + arm.range, + `${context} record [${String(index)}] — ${arm.what}: the ` + + `occurrence's own range against precomputed byte offsets — ` + + `zero-based, start-inclusive end-exclusive (SPEC 1.7, 5.7)`, + ); + }); + } finally { + await workspace.dispose(); + } + } + + // --- The T5.7-3 fixture (record data and order): every datum pinned. ------ + { + const context = "T11.3-1 over the T5.7-3 fixture (full record data)"; + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + [ORD_ZED_FILE]: ORD_ZED_SOURCE, + [ORD_ALPHA_FILE]: ORD_ALPHA_SOURCE, + [ORD_APP_FILE]: ORD_APP_SOURCE, + }, + }); + try { + await buildOk( + product, + workspace, + `${context} — \`build\` (premise: every staged reference is ` + + `sanctioned and resolves, SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences"], + `${context} — bare \`occurrences\`: a complete, finding-free ` + + `answer exits 0 (SPEC 11.2, 11.3)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the consulted domain carries no finding (SPEC 11.2, ` + + `11.3)`, + ); + if (report.occurrences.length !== ORD_EXPECTED.length) { + fail( + `${context}: expected exactly ${String(ORD_EXPECTED.length)} ` + + `records — one per staged reference (SPEC 5.7) — got ` + + `${String(report.occurrences.length)}: ` + + JSON.stringify(report.occurrences), + ); + } + // Per-index equality over the length-checked enumeration: every + // record member — referencing file, own range, edge kind, the + // source graph node's identity-plus-range datum, target identity — + // byte-precise at its pinned position ("each record carrying every + // 5.7 datum", the file-path-bytes leg included: a case-folding + // collation surfaces alpha.mdx's record before Zed.mdx's and fails + // at index 0). + ORD_EXPECTED.forEach((arm, index) => { + assertSameJson( + report.occurrences[index], + arm.record, + `${context} record [${String(index)}] — ${arm.what}; zero-based ` + + `byte offsets, start-inclusive end-exclusive (SPEC 1.7, 5.7, ` + + `11.3)`, + ); + }); + } finally { + await workspace.dispose(); + } + } + + // --- The T5.7-4 fixture (no-occurrence constructs): findings accompany. --- + // Expected order: `specs/MAIN.mdx` before `src/app.ts`; within MAIN the + // `use` reference precedes the `emb` container in source order (the + // exported table's stated contract). The staged defects mean the answer + // carries the domain's findings and exits 1, the full answer still + // emitted; their located detail is T5.7-4's subject — here the exact + // condition-count multiset is the staging-integrity pin. + { + const context = + "T11.3-1 over the T5.7-4 fixture (no-occurrence constructs)"; + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + [BASE_FILE]: NO_OCC_BASE_SOURCE, + [NO_OCC_SPARE_FILE]: NO_OCC_SPARE_SOURCE, + [MAIN_FILE]: NO_OCC_MAIN_SOURCE, + [APP_FILE]: NO_OCC_APP_SOURCE, + }, + }); + try { + const result = await expectExit( + product, + workspace, + ["occurrences"], + 1, + `${context} — an answer carrying any finding exits 1, the full ` + + `answer document still emitted (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + context, + ); + assertConditionCounts( + report.findings, + NO_OCC_EXPECTED_CONDITIONS, + `${context}: staging integrity — exactly the four staged defects ` + + `accompany the answer (one 14.5, one 14.6, one 14.7, one 14.8) ` + + `and nothing for the import declarations, type-only uses, or ` + + `shadowed chains; located detail is T5.7-4's subject (SPEC ` + + `11.2, 14)`, + ); + assertSameJson( + report.occurrences.map(projectTuple), + expandUnits(NO_OCC_UNITS), + `${context}: the complete three-record sequence per index in ` + + `occurrence order — records for exactly the resolving ` + + `spellings: no record for an import declaration, a type-only ` + + `use, a shadowed chain, the dynamic spelling, or an unresolved ` + + `one (the decode already rejects any record with an ` + + `unavailable target — an unresolved spelling is never a ` + + `record, SPEC 5.7, 11.2, 11.3)`, + ); + } finally { + await workspace.dispose(); + } + } + + // --- T11.2-3's invalid-path code source: `source` served unavailable. ----- + // The staging is the owning module's: `src/co#de.ts` (14.19 — the path + // is the file's only defect) whose `text(SPEC.ok)` call and bare marker + // both resolve against the valid `specs/OK.mdx`, so both record — the + // records' `source` exactly the unavailability marker (identity and + // range withheld together as one datum, SPEC 11.2) while `file`, + // `range`, `kind`, and `target` are present, byte-precise. + { + const context = "T11.3-1 over T11.2-3's invalid-path code source"; + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": AVAILABILITY_SPEC_AND_CODE_CONFIG, + [OK_FILE]: OK_SOURCE, + [CS_FILE]: CS_SOURCE, + }, + }); + try { + const result = await expectExit( + product, + workspace, + ["occurrences"], + 1, + `${context} — the answer carries a finding and ` + + `explicitly-unavailable source datums, so exit 1 with the full ` + + `answer (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + context, + ); + assertConditionCounts( + report.findings, + { "14.19": 1 }, + `${context}: exactly the code source's condition-19 finding ` + + `accompanies — the consulted domain is the entire discovered ` + + `set, OK.mdx is finding-free, and the path is the code ` + + `source's only defect (SPEC 11.2, 11.3, 14)`, + ); + const finding = report.findings[0]!; + assertSameJson( + { + code: finding.code, + locations: finding.locations, + path: finding.path, + }, + { code: "invalid-source-path", locations: [], path: CS_FILE }, + `${context}: the 14.19 finding carries the stable code, no ` + + `in-source locations (a path-level condition), and the code ` + + `source as its concerned path (SPEC 14, 12.7)`, + ); + assertSameJson( + report.occurrences, + CS_EXPECTED_OCCURRENCES, + `${context}: the complete enumeration — the call (embeds, ` + + `spanning the whole call expression) and the marker ` + + `(references, spanning the bare chain), each record's source ` + + `EXACTLY the unavailability marker while file, range, kind, ` + + `and target are present — never a picked identity, never a ` + + `dropped record (SPEC 5.7, 11.2, 11.3)`, + ); + } finally { + await workspace.dispose(); + } + } + + // --- T11.2-4's spec-source arm: resolving spellings inside a -------------- + // duplicate-`id` bearer and an id-less section. The staging is the + // owning module's resolution matrix `specs/R.mdx`: duplicate bearers of + // `a` with the unique `a.b` beneath the first; the SECOND bearer's + // `d={"a.b"}` and the id-less section's `{text("a.b")}` each resolve + // and record with `source` exactly the marker; `q`'s ambiguous + // `d={"a"}` records nothing (its 14.5 reports it instead). + { + const context = + "T11.3-1 at T11.2-4's spec-source arm (the resolution matrix)"; + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [R_FILE]: R_SOURCE, + }, + }); + try { + const result = await expectExit( + product, + workspace, + ["occurrences"], + 1, + `${context} — the answer carries findings and ` + + `explicitly-unavailable source datums, so exit 1 with the full ` + + `answer (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + context, + ); + assertConditionCounts( + report.findings, + R_CONDITION_COUNTS, + `${context}: staging integrity — exactly one 14.1 (the id-less ` + + `section), one 14.3 (the duplicated \`a\`), one 14.5 (the ` + + `ambiguous reference, reported by its finding and never as a ` + + `record); located detail is T11.2-4's subject (SPEC 11.2, 14)`, + ); + assertSameJson( + report.occurrences, + R_EXPECTED_OCCURRENCES, + `${context}: the complete enumeration — the \`d\` entry on the ` + + `OTHER duplicate bearer of \`a\` and the embedding inside the ` + + `id-less section each record with source EXACTLY the ` + + `unavailability marker (never a picked bearer's identity, ` + + `never a dropped record) while file, range, kind, and target ` + + `are present, and the ambiguous reference to \`a\` yields no ` + + `record and no unavailable target (SPEC 5.7, 11.2, 11.3)`, + ); + } finally { + await workspace.dispose(); + } + } + }, +}); + +/** TEST-SPEC §11.3, in canonical ID order (SUITE-53). */ +export const section113Tests: readonly ProductTestEntry[] = [T11_3_1]; diff --git a/test/suite/registry/section-5.7.ts b/test/suite/registry/section-5.7.ts index e512a5f..8897c11 100644 --- a/test/suite/registry/section-5.7.ts +++ b/test/suite/registry/section-5.7.ts @@ -22,6 +22,12 @@ // occurrence-record assertions compare complete (file, kind, source, target) // multisets, order-free, with ranges consulted only for the duplicates' // distinctness. +// +// Fixture sharing: the four workspaces staged here are ALSO T11.3-1's ground +// (TEST-SPEC §11.3 "over the T5.7-* fixtures"; registry/section-11.3.ts +// imports the exported staging constants and expectation tables, never +// copies them, so the two sections cannot drift apart). The exported unit +// tables carry an order contract stated at each table. import { Buffer } from "node:buffer"; import type { @@ -60,7 +66,7 @@ import { // One spec group plus one code group (SPEC 7.2): TypeScript files under // `src/` are discovered code sources, so `build` analyzes their spec-module // usage (4.3, 4.5) — the TS half of the occurrence kinds. -const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" +export const SPEC_AND_CODE_CONFIG = `import { defineConfig } from "xspec" export default defineConfig({ specs: { @@ -79,7 +85,7 @@ export default defineConfig({ // The imported spec source: `a` with child `a.b` (the duplicate pair's // target, TEST-SPEC's literal `d={[BASE.a.b, BASE.a.b]}` spelling) and // `other`, so the three-entry array has distinct external targets. -const T5_7_1_BASE_SOURCE = [ +export const T5_7_1_BASE_SOURCE = [ '<S id="a">', "Alpha text.", "", @@ -104,7 +110,7 @@ const T5_7_1_BASE_SOURCE = [ // `embeds`; // - `dup`: TEST-SPEC's duplicate pair `d={[BASE.a.b, BASE.a.b]}` — one // edge, two occurrences at distinct ranges. -const T5_7_1_MAIN_SOURCE = [ +export const T5_7_1_MAIN_SOURCE = [ 'import BASE from "./BASE.xspec"', "", '<S id="peer">', @@ -134,7 +140,7 @@ const T5_7_1_MAIN_SOURCE = [ // a single marker (kind `references`), and the twice-spelled marker — one // edge, two occurrences at distinct ranges. The import declaration records // no edge and no occurrence (SPEC 2.1, 5.7). -const T5_7_1_APP_SOURCE = [ +export const T5_7_1_APP_SOURCE = [ 'import SPEC, { text } from "../specs/MAIN.xspec";', "", "export function useText(): string {", @@ -152,9 +158,9 @@ const T5_7_1_APP_SOURCE = [ "", ].join("\n"); -const BASE_FILE = "specs/BASE.mdx"; -const MAIN_FILE = "specs/MAIN.mdx"; -const APP_FILE = "src/app.ts"; +export const BASE_FILE = "specs/BASE.mdx"; +export const MAIN_FILE = "specs/MAIN.mdx"; +export const APP_FILE = "src/app.ts"; const A_ID = "specs/BASE.mdx#a"; const AB_ID = "specs/BASE.mdx#a.b"; const OTHER_ID = "specs/BASE.mdx#other"; @@ -168,7 +174,7 @@ const ONCE_LOCATION = "src/app.ts#once"; const TWICE_LOCATION = "src/app.ts#twice"; /** One expected occurrence unit: its identifying data and record count. */ -interface OccurrenceUnit { +export interface OccurrenceUnit { readonly what: string; readonly file: string; readonly kind: DependencyEdgeKind; @@ -184,7 +190,15 @@ interface OccurrenceUnit { // order-free multiset comparison individuates every unit: a missing, // phantom, per-array, per-prop, uncollapsed-edge-shaped, or mis-kinded // record fails with the offending tuple named. -const T5_7_1_UNITS: readonly OccurrenceUnit[] = [ +// +// ORDER CONTRACT (exported; T11.3-1 relies on it): the table lists the units +// in occurrence order (SPEC 5.7 — file path bytes, `specs/MAIN.mdx` before +// `src/app.ts`, then range start, i.e. each file's spellings in source +// order), each duplicate pair's records adjacent. T5.7-1 itself compares +// order-free; section-11.3.ts expands the table BY POSITION into its +// per-index expected sequence, so keep the table position-sorted when +// restaging. +export const T5_7_1_UNITS: readonly OccurrenceUnit[] = [ { what: "three-entry `d` array, entry 1 (external chain `BASE.a`)", file: MAIN_FILE, @@ -468,7 +482,7 @@ function rangeAfter(prefix: string, span: string): SourceRange { // The referenced spec source: three top-level targets plus a nested child, so // the marker's chain is multi-segment (`SPEC.y.leaf`) and every staged // occurrence resolves to its own distinct target. -const SPAN_BASE_SOURCE = [ +export const SPAN_BASE_SOURCE = [ '<S id="x">', "X text.", "</S>", @@ -503,7 +517,7 @@ const SPAN_ARR_TAG_POST = "]}>\nArr text.\n</S>\n\n"; const SPAN_EMB_PRE = '<S id="emb">\nEmb: '; const SPAN_EMB_CONTAINER = "{text(BASE.y)}"; const SPAN_EMB_POST = "\n</S>\n"; -const SPAN_MAIN_SOURCE = +export const SPAN_MAIN_SOURCE = SPAN_MAIN_HEAD + SPAN_ARR_TAG_PRE + SPAN_ARR_ENTRY_1 + @@ -528,7 +542,7 @@ const SPAN_CALL_POST = ";\n}\n\n"; const SPAN_MARK_PRE = "export function mark(): void {\n "; const SPAN_MARK_CHAIN = "SPEC.y.leaf"; const SPAN_MARK_POST = "; // trailing trivia\n}\n"; -const SPAN_APP_SOURCE = +export const SPAN_APP_SOURCE = SPAN_APP_HEAD + SPAN_CALL_PRE + SPAN_CALL_EXPR + @@ -554,7 +568,7 @@ const SPAN_MARK_LOCATION = "src/app.ts#mark"; * subject, decode-enforced as 12.7 form meanwhile); the source node's own * range datum is likewise T5.7-3's subject, consulted here only as identity. */ -interface SpanArm { +export interface SpanArm { readonly what: string; /** The staged file's full content (fixture self-check ground). */ readonly fileSource: string; @@ -571,7 +585,13 @@ interface SpanArm { // The complete expected enumeration — the staged references are the // workspace's only occurrences (import declarations record none, SPEC 5.7), // one record each, every span byte-precise. -const SPAN_ARMS: readonly SpanArm[] = [ +// +// ORDER CONTRACT (exported; T11.3-1 relies on it): the arms are listed in +// occurrence order (file path bytes, then range start — section-11.3.ts +// additionally self-checks this sortedness against the claimed ranges +// before any product invocation), so keep the list position-sorted when +// restaging. +export const SPAN_ARMS: readonly SpanArm[] = [ { what: "`d` array entry 1 (`BASE.x`) — the reference's own expression, the " + @@ -811,9 +831,9 @@ const T5_7_2 = defineProductTest({ // comparator, range-end leg included, as 12.7 form over whatever a product // emits). -const ORD_ZED_FILE = "specs/Zed.mdx"; -const ORD_ALPHA_FILE = "specs/alpha.mdx"; -const ORD_APP_FILE = "src/app.ts"; +export const ORD_ZED_FILE = "specs/Zed.mdx"; +export const ORD_ALPHA_FILE = "specs/alpha.mdx"; +export const ORD_APP_FILE = "src/app.ts"; const ORD_ZIN_ID = "specs/Zed.mdx#zout.zin"; const ORD_ZLOC_ID = "specs/Zed.mdx#zloc"; const ORD_T_ID = "specs/alpha.mdx#t"; @@ -845,7 +865,7 @@ const ORD_ZED_ZOUT_CLOSE = "\n</S>\n\n"; const ORD_ZED_ZLOC = '<S id="zloc">\nLocal target text.\n</S>\n\n'; const ORD_ZED_TAIL_PRE = "Tail text.\n\n"; const ORD_ZED_TAIL_EMB = "{text(ALPHA.u)}"; -const ORD_ZED_SOURCE = +export const ORD_ZED_SOURCE = ORD_ZED_IMPORT + ORD_ZED_PRELUDE + ORD_ZED_ZOUT_OPEN + @@ -871,7 +891,7 @@ const ORD_ALPHA_MID_CONSTRUCT = ORD_ALPHA_MID_DEP + ORD_ALPHA_MID_TAG_POST + ORD_ALPHA_MID_CLOSE; -const ORD_ALPHA_SOURCE = +export const ORD_ALPHA_SOURCE = ORD_ALPHA_PRELUDE + ORD_ALPHA_TARGETS + ORD_ALPHA_MID_CONSTRUCT + "\n"; // src/app.ts — byte-LAST (`src/` after `specs/`): a top-level marker (no @@ -891,7 +911,7 @@ const ORD_APP_DEEP_POST = ";\n }"; const ORD_APP_DEEP_CONSTRUCT = ORD_APP_DEEP_PRE + ORD_APP_DEEP_MARKER + ORD_APP_DEEP_POST; const ORD_APP_WRAP_POST = "\n deep();\n}\n"; -const ORD_APP_SOURCE = +export const ORD_APP_SOURCE = ORD_APP_HEAD + ORD_APP_TOP_MARKER + ORD_APP_TOP_POST + @@ -900,7 +920,7 @@ const ORD_APP_SOURCE = ORD_APP_WRAP_POST; /** One staged occurrence: its complete expected record plus self-check data. */ -interface OrderArm { +export interface OrderArm { readonly what: string; /** The staged file's full content (self-check ground). */ readonly fileSource: string; @@ -916,8 +936,10 @@ interface OrderArm { // The complete expected document, in occurrence order (SPEC 5.7): file path // bytes — Zed.mdx, then alpha.mdx, then src/app.ts — then range start. The // staged references are the workspace's only occurrences (plain sections, -// prose, and import declarations record none). -const ORD_EXPECTED: readonly OrderArm[] = [ +// prose, and import declarations record none). Exported: T11.3-1 asserts the +// identical full-record sequence through the same surface (section-11.3.ts), +// re-running the slice and sortedness self-checks below in its own body. +export const ORD_EXPECTED: readonly OrderArm[] = [ { what: "`d={ALPHA.t}` on the NESTED section `zout.zin` — the source datum is " + @@ -1277,8 +1299,9 @@ const T5_7_3 = defineProductTest({ // of classifying it dynamic both drops the 14.8 finding and emits a phantom // resolved record — failing twice, visibly. -const NO_OCC_BASE_SOURCE = '<S id="a">\nA text.\n</S>\n'; -const NO_OCC_SPARE_SOURCE = '<S id="sp">\nSpare text.\n</S>\n'; +export const NO_OCC_BASE_SOURCE = '<S id="a">\nA text.\n</S>\n'; +export const NO_OCC_SPARE_SOURCE = '<S id="sp">\nSpare text.\n</S>\n'; +export const NO_OCC_SPARE_FILE = "specs/SPARE.mdx"; // specs/MAIN.mdx, composed from the exact parts the expected offsets cite // (the T5.7-2/T5.7-3 discipline): the used import (BASE — its references @@ -1302,7 +1325,7 @@ const NO_OCC_BAD_POST = "\n</S>\n\n"; const NO_OCC_EMB_PRE = '<S id="emb">\nEmb: '; const NO_OCC_EMB_CONTAINER = "{text(BASE.a)}"; const NO_OCC_EMB_POST = "\n</S>\n"; -const NO_OCC_MAIN_SOURCE = +export const NO_OCC_MAIN_SOURCE = NO_OCC_MAIN_HEAD + NO_OCC_MAIN_USE + NO_OCC_DYN_CONSTRUCT + @@ -1341,7 +1364,7 @@ const NO_OCC_APP_TAIL = " return SPEC.ok;\n" + "}\n\n" + "TSPEC.a;\ntt(TSPEC.a);\n"; -const NO_OCC_APP_SOURCE = +export const NO_OCC_APP_SOURCE = NO_OCC_APP_HEAD + NO_OCC_APP_KEEPER + NO_OCC_STRAY_PRE + @@ -1353,7 +1376,13 @@ const NO_OCC_APP_SOURCE = // nothing else — no record for any import declaration (binding used or // unused), type-only use, shadowed chain, dynamic spelling, or unresolved // spelling. -const NO_OCC_UNITS: readonly OccurrenceUnit[] = [ +// +// ORDER CONTRACT (exported; T11.3-1 relies on it): listed in occurrence +// order — `specs/MAIN.mdx` before `src/app.ts`, and within MAIN the `use` +// reference precedes the `emb` container in source order — so keep the +// table position-sorted when restaging (T5.7-4 itself compares order-free; +// section-11.3.ts expands the table by position). +export const NO_OCC_UNITS: readonly OccurrenceUnit[] = [ { what: "resolving `d={BASE.a}` on `use`", file: "specs/MAIN.mdx", @@ -1383,7 +1412,7 @@ const NO_OCC_UNITS: readonly OccurrenceUnit[] = [ // The staged defects, exactly one finding each (SPEC 14: every condition // reported, and nothing else — so the type-only uses, the shadowed chains, // and the unused import provably trigger NO finding beside these four). -const NO_OCC_EXPECTED_CONDITIONS = { +export const NO_OCC_EXPECTED_CONDITIONS = { "14.5": 1, "14.6": 1, "14.7": 1, diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index c86f45c..e6bc8ae 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -304,6 +304,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.2-4": ["11.2"], "T11.2-5": ["11.2"], "T11.2-6": ["11.2"], + "T11.3-1": ["11.3"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], diff --git a/test/suite/section-11.3.test.ts b/test/suite/section-11.3.test.ts new file mode 100644 index 0000000..444d550 --- /dev/null +++ b/test/suite/section-11.3.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §11.3 (SUITE-53): thin Vitest wrapper over the registered +// bodies — the identical bodies the certification runner executes against +// fixture products (C-2 "one code path"). Expected to fail as diagnosed +// assertion failures until the product exists (H-8). + +import { declareProductTests } from "./declare.js"; +import { section113Tests } from "./registry/section-11.3.js"; + +declareProductTests(section113Tests); From 55d6e9ca076513fffefb03f19fd3be41f81bd5ea Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 17:12:53 +0000 Subject: [PATCH 102/226] sdg(phase-9): implement T11.3-2's --file set restriction (FP-057) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 50 ++- test/suite/registry/section-11.2.ts | 5 +- test/suite/registry/section-11.3.ts | 575 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 4 files changed, 627 insertions(+), 4 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index e4c6bcc..37cb021 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2463,8 +2463,56 @@ certify against FP-091's fixtures once those land. test:self`: 4 planned mid-loop reds with S-1's unmapped set narrowed 6 → 5 keys, exactly {11.4, 11.5, 11.6, 12.6, 12.7} ("11.3" now mapped; certification-document ×3 → FP-091), S-5 and certification green.] -- [ ] FP-057 — Implement T11.3-2: `--file` set restriction. [R2 #3; +- [x] FP-057 — Implement T11.3-2: `--file` set restriction. [R2 #3; TEST-SPEC §11.3] + [Done 2026-08-14: registered in section-11.3.ts (SUITE-53) with + traceability ["11.3"] (no TEST-SPEC 14 staging record — the FP-056 + precedent; 11.1/11.2/12.0 are context with home coverage at + T11-*/T11.2-*/T12.0-*); no certification scope (CERTIFICATIONS.md + Exclusions name T11.3-2's matrix explicitly; CONF-AVAIL's in-scope set + excludes it). Two self-owned fixtures. (1) A failing spec+code workspace — + one 14.5 in specs/apple.mdx (beside a resolving reference INTO the + excluded file and a local embedding), one 14.3 in specs/beta.mdx (beside + a resolving local `d`), one 14.8 in src/app.ts (string-form `text` beside + a resolving marker), plus an UNDISCOVERED unparseable decoy docs/note.mdx + in no configured group — gate `build --json` pins the exact multiset and + per-file homes first; then, inside one whole-root modifies-nothing + compare: `--file "**/ap*"` (one glob admitting spec and code alike) → + exit 1, findings exactly {14.5,14.8} located in the admitted files (never + beta's 14.3, never a phantom 14.5 for the cross-boundary reference — + resolution is workspace-wide, the domain restricts consultation), records + exactly the admitted files' three tuples per index; complementary literal + glob `specs/beta.mdx` flips the domain (exactly {14.3}, exactly beta's + record); empty-set arms `docs/*.mdx` (matches the on-disk decoy, no + DISCOVERED file — a filesystem-globbing product surfaces the decoy's + 14.20) and `nosuch/**/*.mdx` → each `{"findings":[],"occurrences":[]}` + exit 0, no unknown-file usage error, whatever findings the workspace + carries; outside-root arms `../elsewhere/**/*.mdx` and + `specs/../../evil/*.mdx` → exit 2 via T11.2-5's usage-error protocol + (single 12.7 error document, stderr message), newly exported from + section-11.2.ts (export-only edit) for the per-surface matrices. (2) A + valid conjunction workspace (P→x, P→y, Q→x): `--file specs/P.mdx` alone → + P's two records; `--to specs/T.mdx#x` alone → the two x-targeting + records; both → exactly the one-record intersection — each filter alone + admits more, so union or either-alone fails the exact per-index compares; + all exit 0 finding-free after the buildOk premise. Verified: + typecheck/format clean; suite section-11.3 red-as-diagnosed — T11.3-2 + fails at the gate build's FP-001-class form-exact decode ("expected no + member \"condition\"", the 14.5 leading), T11.3-1's known red unchanged; + direct probes against the built product prove the stagings (ws1 build + exit 1 with exactly the three old-shape findings in the right files, + BETA.far resolving, decoy contributing nothing; ws2 build exit 0 with + exactly the three depends edges; `occurrences` still unknown, exit 2 — + the pre-patch gap); scratch run (deleted) of the registered body against + a conforming fake deriving ranges from workspace bytes green end-to-end, + six deviation fakes each failing at exactly the targeted arm — + ignore-`--file` and whole-workspace-findings at the subset-domain + assertions, unknown-file-error and filesystem-globbing at the empty-set + arm, union at the conjunction's intersection compare, no-escape-check at + the outside-root exit-2 arm. `npm run test:self`: unchanged 4 planned + mid-loop reds (certification-document ×3 → FP-091; S-1's 5 unmapped keys + {11.4, 11.5, 11.6, 12.6, 12.7} → stage G), S-5 and certification green; + sections 5.7/11.2 unchanged (4 resp. 6 diagnosed reds).] - [ ] FP-058 — Implement T11.3-3: `--to` syntactic acceptance / malformed spellings. [R2 #3; TEST-SPEC §11.3] - [ ] FP-059 — Implement T11.3-4 (CONF-AVAIL): definitive emptiness. diff --git a/test/suite/registry/section-11.2.ts b/test/suite/registry/section-11.2.ts index 3796caf..d9ca027 100644 --- a/test/suite/registry/section-11.2.ts +++ b/test/suite/registry/section-11.2.ts @@ -3268,8 +3268,11 @@ function assertCycleFindingsWhole( * the single 12.7 error document (the surfaces are JSON-only, SPEC 11, so * JSON output is always in effect; the form-exact decode admits no findings * report and no answer beside it), and the usage message on stderr (12.0). + * Exported: the per-surface spelling matrices (T11.3-2/3, T11.4-2, T11.5-2) + * assert their exit-2 arms through this same protocol + * (registry/section-11.3.ts imports, never copies). */ -async function expectAvailabilityUsageError( +export async function expectAvailabilityUsageError( product: ProductBinding, workspace: TestWorkspace, argv: readonly string[], diff --git a/test/suite/registry/section-11.3.ts b/test/suite/registry/section-11.3.ts index eacf5e1..2a98ab8 100644 --- a/test/suite/registry/section-11.3.ts +++ b/test/suite/registry/section-11.3.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §11.3 (`xspec occurrences`) — SUITE-53: T11.3-1. +// TEST-SPEC §11.3 (`xspec occurrences`) — SUITE-53: T11.3-1 and T11.3-2. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -35,9 +35,24 @@ // as exact condition-count multisets (staging integrity riding the answer // itself), plus the code/path projection for the code-source arm's single // path-level finding. +// +// T11.3-2 owns its two fixtures (nothing imports them): a failing +// three-source workspace whose per-file findings are pairwise distinct +// conditions (one 14.5 in specs/apple.mdx, one 14.3 in specs/beta.mdx, one +// 14.8 in src/app.ts — the `build --json` gate pins the multiset and homes +// before any `--file` arm, so every domain assertion stands on staged +// ground), each file also holding occurrences, plus an UNDISCOVERED +// on-disk decoy (docs/note.mdx, deliberately unparseable, in no configured +// group); and a valid three-spec-file workspace for the `--file`/`--to` +// conjunction. Domain membership is the subject, so records are pinned as +// per-index identity-level tuples (each staged (file, kind, source, +// target) tuple unique; ranges and order enforced by the decode); the +// exit-2 arms ride T11.2-5's exported usage-error protocol +// (registry/section-11.2.ts). import { Buffer } from "node:buffer"; import type { + Finding, OccurrenceRecord, PathValue, SourceRange, @@ -46,6 +61,7 @@ import { decodeOccurrencesReport } from "../../helpers/adapters/index.js"; import { fail, parseJsonStdout } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import type { OccurrenceUnit } from "./section-5.7.js"; import { @@ -80,6 +96,7 @@ import { CS_EXPECTED_OCCURRENCES, CS_FILE, CS_SOURCE, + expectAvailabilityUsageError, OK_FILE, OK_SOURCE, R_CONDITION_COUNTS, @@ -91,7 +108,9 @@ import { } from "./section-11.2.js"; import { assertConditionCounts, + assertFindingLocated, assertSameJson, + buildFindings, buildOk, expectExit, runJson, @@ -657,5 +676,557 @@ const T11_3_1 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T11.3-2 — `--file`: a set restriction over discovered files +// --------------------------------------------------------------------------- + +// The restriction workspace (failing on purpose): three discovered sources, +// each holding at least one occurrence and exactly one finding of a condition +// no other file stages — so every domain assertion individuates by condition +// AND by located file — plus an on-disk decoy no configured group discovers. +// +// - specs/apple.mdx: one 14.5 (the unresolved local `"nosuch"` entry) beside +// TWO resolving spellings — the external `BETA.far` (its target lying in +// the file the subset glob EXCLUDES: resolution is workspace-wide, the +// domain restricts consultation, not the reference ground, SPEC 11.2/11.3 +// — a product resolving only within the admitted set reports a phantom +// 14.5 and drops the record) and the local embedding `{text("apple")}`. +// - specs/beta.mdx: one 14.3 (the duplicate `twin` pair) beside the +// resolving local `d={"far"}`. +// - src/app.ts: one 14.8 (the string-form `text("apple")`, invalid in +// TypeScript by form, SPEC 4.3 — no occurrence) beside the resolving +// marker `SPEC.apple`. +// - docs/note.mdx: deliberately unparseable, in NO configured group — a +// pattern matching it on disk still matches no DISCOVERED file (SPEC 7: +// discovery is controlled exclusively by configuration), so a product +// globbing the filesystem instead of the discovered set consults it and +// surfaces a phantom 14.20 (or any nonempty answer) where the empty, +// finding-free answer is required. +const FILTER_APPLE_FILE = "specs/apple.mdx"; +const FILTER_BETA_FILE = "specs/beta.mdx"; +const FILTER_APP_FILE = "src/app.ts"; +const FILTER_TRAP_FILE = "docs/note.mdx"; + +const FILTER_APPLE_SOURCE = [ + 'import BETA from "./beta.xspec"', + "", + '<S id="apple">', + "Apple text.", + "</S>", + "", + '<S id="pick" d={[BETA.far, "nosuch"]}>', + 'Pick: {text("apple")}', + "</S>", + "", +].join("\n"); + +const FILTER_BETA_SOURCE = [ + '<S id="far">', + "Far text.", + "</S>", + "", + '<S id="near" d={"far"}>', + "Near text.", + "</S>", + "", + '<S id="twin">', + "Twin one.", + "</S>", + "", + '<S id="twin">', + "Twin two.", + "</S>", + "", +].join("\n"); + +const FILTER_APP_SOURCE = [ + 'import SPEC, { text } from "../specs/apple.xspec";', + "", + "export function grab(): void {", + " SPEC.apple;", + "}", + "", + "export function bad(): string {", + ' return text("apple");', + "}", + "", +].join("\n"); + +const FILTER_TRAP_SOURCE = '<S id="trap">\nUnclosed on purpose.\n'; + +/** The workspace's complete finding multiset (the `build --json` gate). */ +const FILTER_WORKSPACE_CONDITIONS: Readonly<Record<string, number>> = { + "14.3": 1, + "14.5": 1, + "14.8": 1, +}; + +// Expected record tuples per file, each list in that file's source order +// (the 5.7 comparator's within-file leg; `specs/apple.mdx` < `src/app.ts` +// by path bytes on the cross-file leg). Every staged (file, kind, source, +// target) tuple is unique, so the per-index tuple compare individuates a +// dropped, phantom, or out-of-domain record by name. +const FILTER_APPLE_TUPLES: readonly RecordTuple[] = [ + { + file: FILTER_APPLE_FILE, + kind: "depends", + source: "specs/apple.mdx#pick", + target: "specs/beta.mdx#far", + }, + { + file: FILTER_APPLE_FILE, + kind: "embeds", + source: "specs/apple.mdx#pick", + target: "specs/apple.mdx#apple", + }, +]; +const FILTER_APP_TUPLES: readonly RecordTuple[] = [ + { + file: FILTER_APP_FILE, + kind: "references", + source: "src/app.ts#grab", + target: "specs/apple.mdx#apple", + }, +]; +const FILTER_BETA_TUPLES: readonly RecordTuple[] = [ + { + file: FILTER_BETA_FILE, + kind: "depends", + source: "specs/beta.mdx#near", + target: "specs/beta.mdx#far", + }, +]; + +// The conjunction workspace (valid): occurrences P→x, P→y, Q→x, so `--file +// specs/P.mdx` alone admits two records, `--to specs/T.mdx#x` alone selects +// two, and the conjunction is exactly the one-record intersection — each +// filter alone admits MORE than the intersection, TEST-SPEC's fixture +// condition, so a product applying either filter alone (or their union) +// fails the exact compare. +const CONJ_T_FILE = "specs/T.mdx"; +const CONJ_P_FILE = "specs/P.mdx"; +const CONJ_Q_FILE = "specs/Q.mdx"; +const CONJ_X_ID = "specs/T.mdx#x"; +const CONJ_Y_ID = "specs/T.mdx#y"; + +const CONJ_T_SOURCE = [ + '<S id="x">', + "X text.", + "</S>", + "", + '<S id="y">', + "Y text.", + "</S>", + "", +].join("\n"); + +const CONJ_P_SOURCE = [ + 'import T from "./T.xspec"', + "", + '<S id="p" d={[T.x, T.y]}>', + "P text.", + "</S>", + "", +].join("\n"); + +const CONJ_Q_SOURCE = [ + 'import T from "./T.xspec"', + "", + '<S id="q" d={T.x}>', + "Q text.", + "</S>", + "", +].join("\n"); + +const CONJ_P_TO_X: RecordTuple = { + file: CONJ_P_FILE, + kind: "depends", + source: "specs/P.mdx#p", + target: CONJ_X_ID, +}; +const CONJ_P_TO_Y: RecordTuple = { + file: CONJ_P_FILE, + kind: "depends", + source: "specs/P.mdx#p", + target: CONJ_Y_ID, +}; +const CONJ_Q_TO_X: RecordTuple = { + file: CONJ_Q_FILE, + kind: "depends", + source: "specs/Q.mdx#q", + target: CONJ_X_ID, +}; + +/** + * The answer's one finding of a condition, returned for its located-home + * assertion; the caller has already pinned the count map, so a miss here is + * diagnosed against the whole findings array. + */ +function findingByCondition( + findings: readonly Finding[], + condition: string, + context: string, +): Finding { + const matches = findings.filter((finding) => finding.condition === condition); + if (matches.length !== 1) { + fail( + `${context}: expected exactly one ${condition} finding in the ` + + `answer; got ${String(matches.length)} among ` + + JSON.stringify(findings), + ); + } + return matches[0]!; +} + +const T11_3_2 = defineProductTest({ + id: "T11.3-2", + title: + "`--file` is a set restriction over discovered files, spec and code alike: one glob (`**/ap*`) admitting a spec source and a code source restricts the consulted domain to exactly the admitted files — only their findings accompany (never the excluded file's 14.3) and only their occurrences are enumerated, the admitted spec file's record into the excluded file still resolving and recording (the domain restricts consultation, not resolution), exit 1; the complementary literal glob flips the domain (exactly the 14.3, exactly the excluded file's record); a glob matching no discovered file — one matching an on-disk file no configured group discovers, and one matching nothing at all — admits the empty set: an empty, finding-free answer, exit 0, no unknown-file usage error on this filter, whatever findings the workspace carries; an outside-root pattern (a leading and an embedded `..` traversal) exits 2 as an invalid flag value with the single 12.7 error document, the argument check preceding answering; `--file` and `--to` combine conjunctively — a fixture where each filter alone admits more records than the intersection (SPEC 11.3, 11.2, 11.1, 7, 12.0, 12.7)", + run: async (product) => { + // --- Workspace 1: the restriction ground (failing on purpose). ------------ + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + [FILTER_APPLE_FILE]: FILTER_APPLE_SOURCE, + [FILTER_BETA_FILE]: FILTER_BETA_SOURCE, + [FILTER_APP_FILE]: FILTER_APP_SOURCE, + [FILTER_TRAP_FILE]: FILTER_TRAP_SOURCE, + }, + }); + try { + await assertLeavesUnchanged( + workspace.root, + async () => { + // Gate reference and staging integrity (SPEC 12.1, 14): exactly + // one finding per file, each of a condition no other file + // stages, homes pinned — so every domain assertion below reads + // on staged ground. The decoy is in no configured group and + // contributes nothing (SPEC 7: discovery is controlled + // exclusively by configuration). + const gateContext = + "T11.3-2 `build --json` (staging integrity: one 14.5 in " + + "apple, one 14.3 in beta, one 14.8 in the code source; the " + + "undiscovered docs/note.mdx contributes nothing)"; + const gateFindings = await buildFindings( + product, + workspace, + gateContext, + ); + assertConditionCounts( + gateFindings, + FILTER_WORKSPACE_CONDITIONS, + `${gateContext} — exactly the staged conditions (SPEC 14)`, + ); + assertFindingLocated( + findingByCondition(gateFindings, "14.5", gateContext), + { file: FILTER_APPLE_FILE }, + `${gateContext} — the unresolved \`"nosuch"\` entry locates ` + + `in apple (SPEC 14)`, + ); + assertFindingLocated( + findingByCondition(gateFindings, "14.3", gateContext), + { file: FILTER_BETA_FILE }, + `${gateContext} — the duplicate \`twin\` pair locates every ` + + `bearer, both in beta (SPEC 14)`, + ); + assertFindingLocated( + findingByCondition(gateFindings, "14.8", gateContext), + { file: FILTER_APP_FILE }, + `${gateContext} — the string-form \`text("apple")\` call ` + + `locates in the code source (SPEC 4.3, 14)`, + ); + + // --- One glob admitting a spec source AND a code source (SPEC + // 11.3: the discovered files, spec and code alike): the + // consulted domain is exactly {apple, app.ts} — only their + // findings accompany, only their occurrences are enumerated, + // and apple's reference INTO the excluded beta still resolves + // and records (never a phantom 14.5, never a dropped record). + { + const context = + 'T11.3-2 `occurrences --file "**/ap*"` (a subset of spec ' + + "and code files alike)"; + const result = await expectExit( + product, + workspace, + ["occurrences", "--file", "**/ap*"], + 1, + `${context} — the admitted files' findings accompany, so ` + + `exit 1 with the full answer (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + context, + ); + assertConditionCounts( + report.findings, + { "14.5": 1, "14.8": 1 }, + `${context}: ONLY the admitted files' findings accompany — ` + + `apple's one 14.5 and the code source's one 14.8, never ` + + `the excluded beta's 14.3, and never a second 14.5 for ` + + `apple's resolving reference into the excluded file ` + + `(SPEC 11.2, 11.3, 14)`, + ); + assertFindingLocated( + findingByCondition(report.findings, "14.5", context), + { file: FILTER_APPLE_FILE }, + `${context} — the accompanying 14.5 is the ADMITTED ` + + `apple's (SPEC 11.2)`, + ); + assertFindingLocated( + findingByCondition(report.findings, "14.8", context), + { file: FILTER_APP_FILE }, + `${context} — the accompanying 14.8 is the ADMITTED code ` + + `source's (SPEC 11.2)`, + ); + assertSameJson( + report.occurrences.map(projectTuple), + [...FILTER_APPLE_TUPLES, ...FILTER_APP_TUPLES], + `${context}: the complete enumeration per index in ` + + `occurrence order — apple's two records (the external ` + + `reference into the EXCLUDED beta included: resolution ` + + `is workspace-wide, the domain restricts consultation) ` + + `and the code source's marker record; nothing of beta's ` + + `(SPEC 5.7, 11.2, 11.3)`, + ); + } + + // --- The complementary literal glob: the domain flips to + // exactly {beta} — the other side of "only its findings + // accompany" over the same staging. + { + const context = + 'T11.3-2 `occurrences --file "specs/beta.mdx"` (the ' + + "complementary single-file subset)"; + const result = await expectExit( + product, + workspace, + ["occurrences", "--file", FILTER_BETA_FILE], + 1, + `${context} — beta's finding accompanies, so exit 1 with ` + + `the full answer (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + context, + ); + assertConditionCounts( + report.findings, + { "14.3": 1 }, + `${context}: ONLY beta's 14.3 accompanies — never apple's ` + + `14.5 or the code source's 14.8 (SPEC 11.2, 11.3, 14)`, + ); + assertFindingLocated( + findingByCondition(report.findings, "14.3", context), + { file: FILTER_BETA_FILE }, + `${context} — the 14.3 locates in beta (SPEC 14)`, + ); + assertSameJson( + report.occurrences.map(projectTuple), + FILTER_BETA_TUPLES, + `${context}: exactly beta's one record — nothing of ` + + `apple's or the code source's (SPEC 5.7, 11.2, 11.3)`, + ); + } + + // --- A glob matching no DISCOVERED file admits the empty set + // (SPEC 11.3: a set restriction, not an existence assertion): + // an empty, finding-free answer, exit 0, no unknown-file usage + // error — whatever findings the workspace carries. First with a + // pattern matching a real on-disk file no group discovers (a + // product globbing the filesystem consults the unparseable + // decoy and answers nonempty), then with one matching nothing + // at all. + for (const [glob, what] of [ + [ + "docs/*.mdx", + "matching the on-disk but UNDISCOVERED docs/note.mdx", + ], + ["nosuch/**/*.mdx", "matching nothing at all"], + ] as const) { + const context = `T11.3-2 \`occurrences --file "${glob}"\` (${what})`; + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences", "--file", glob], + `${context} — the glob admits the empty set: an empty, ` + + `finding-free answer exits 0, and no unknown-file ` + + `usage error exists on this filter, whatever findings ` + + `the workspace carries (SPEC 11.2, 11.3)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: an empty consulted domain has no findings — ` + + `the workspace's staged 14.3/14.5/14.8 are no domain ` + + `file's findings here (SPEC 11.2, 11.3)`, + ); + assertSameJson( + report.occurrences, + [], + `${context}: the empty enumeration (SPEC 11.3)`, + ); + } + + // --- An outside-root pattern is an invalid flag value, exit 2 + // (SPEC 11.3, 11.1, 7): the argument check precedes answering + // (11.2), whatever findings the named files carry — asserted on + // this failing workspace via the shared JSON-only usage-error + // protocol (single 12.7 error document, message on stderr). + await expectAvailabilityUsageError( + product, + workspace, + ["occurrences", "--file", "../elsewhere/**/*.mdx"], + "T11.3-2 outside-root `--file` pattern (leading `..` " + + "traversal) on the failing workspace", + ); + await expectAvailabilityUsageError( + product, + workspace, + ["occurrences", "--file", "specs/../../evil/*.mdx"], + "T11.3-2 outside-root `--file` pattern (embedded `..` " + + "traversal escaping the root mid-pattern) on the failing " + + "workspace", + ); + }, + "T11.3-2 workspace 1 — no invocation of the sweep modifies " + + "anything: the gate build fails writing nothing (SPEC 12.1) " + + "and on a failing workspace these surfaces answer from current " + + "sources and write nothing (SPEC 11.2; the no-write contract " + + "clauses live at T11.2-1/T11.2-6)", + ); + } finally { + await workspace.dispose(); + } + } + + // --- Workspace 2: `--file` and `--to` combine conjunctively. -------------- + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [CONJ_T_FILE]: CONJ_T_SOURCE, + [CONJ_P_FILE]: CONJ_P_SOURCE, + [CONJ_Q_FILE]: CONJ_Q_SOURCE, + }, + }); + try { + await buildOk( + product, + workspace, + "T11.3-2 `build` (premise: the conjunction workspace is valid, " + + "so every answer below is complete and finding-free, SPEC " + + "11.2, 11.3)", + ); + + // `--file` alone admits P's two records — more than the + // intersection. + { + const context = + "T11.3-2 `occurrences --file specs/P.mdx` (the file filter " + + "alone)"; + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences", "--file", CONJ_P_FILE], + `${context} — complete and finding-free, exit 0 (SPEC 11.2, ` + + `11.3)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the domain carries no finding (SPEC 11.2)`, + ); + assertSameJson( + report.occurrences.map(projectTuple), + [CONJ_P_TO_X, CONJ_P_TO_Y], + `${context}: exactly P's two records — the file filter alone ` + + `admits MORE than the conjunction's one (SPEC 11.3)`, + ); + } + + // `--to` alone selects the two records targeting x — more than the + // intersection. + { + const context = + "T11.3-2 `occurrences --to specs/T.mdx#x` (the target filter " + + "alone)"; + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences", "--to", CONJ_X_ID], + `${context} — complete and finding-free, exit 0 (SPEC 11.2, ` + + `11.3)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the domain (the entire discovered set) carries ` + + `no finding (SPEC 11.2)`, + ); + assertSameJson( + report.occurrences.map(projectTuple), + [CONJ_P_TO_X, CONJ_Q_TO_X], + `${context}: exactly the two records targeting x, P's before ` + + `Q's by path bytes — the target filter alone selects MORE ` + + `than the conjunction's one (SPEC 5.7, 11.3)`, + ); + } + + // Both filters combine conjunctively: exactly the one-record + // intersection — a union, or either filter applied alone, reports + // two or three records and fails. + { + const context = + "T11.3-2 `occurrences --file specs/P.mdx --to specs/T.mdx#x` " + + "(the conjunction)"; + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences", "--file", CONJ_P_FILE, "--to", CONJ_X_ID], + `${context} — complete and finding-free, exit 0 (SPEC 11.2, ` + + `11.3)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the domain carries no finding (SPEC 11.2)`, + ); + assertSameJson( + report.occurrences.map(projectTuple), + [CONJ_P_TO_X], + `${context}: exactly the intersection — P's record targeting ` + + `x and nothing else: the two filters combine conjunctively ` + + `(SPEC 11.3)`, + ); + } + } finally { + await workspace.dispose(); + } + } + }, +}); + /** TEST-SPEC §11.3, in canonical ID order (SUITE-53). */ -export const section113Tests: readonly ProductTestEntry[] = [T11_3_1]; +export const section113Tests: readonly ProductTestEntry[] = [T11_3_1, T11_3_2]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index e6bc8ae..11c4de6 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -305,6 +305,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.2-5": ["11.2"], "T11.2-6": ["11.2"], "T11.3-1": ["11.3"], + "T11.3-2": ["11.3"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From af3eb4deb348062b72905f63a3aa96668d9babfc Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 17:35:10 +0000 Subject: [PATCH 103/226] sdg(phase-9): implement T11.3-3's --to acceptance/malformed/selection matrix (FP-058) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 53 ++- test/suite/registry/section-11.3.ts | 589 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 640 insertions(+), 3 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 37cb021..51c01e2 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2513,8 +2513,59 @@ certify against FP-091's fixtures once those land. mid-loop reds (certification-document ×3 → FP-091; S-1's 5 unmapped keys {11.4, 11.5, 11.6, 12.6, 12.7} → stage G), S-5 and certification green; sections 5.7/11.2 unchanged (4 resp. 6 diagnosed reds).] -- [ ] FP-058 — Implement T11.3-3: `--to` syntactic acceptance / malformed +- [x] FP-058 — Implement T11.3-3: `--to` syntactic acceptance / malformed spellings. [R2 #3; TEST-SPEC §11.3] + [Done 2026-08-14: registered in section-11.3.ts (SUITE-53) with + traceability ["11.3"] (no TEST-SPEC 14 staging record — the FP-056/057 + precedent; 1.4/1.5/12.0/12.7 are context with home coverage at + T1.4-*/T1.5-*/T12.0-*/T12.7-*); no certification scope (Exclusions name + T11.3-2/3's matrices). Two self-owned fixtures. (1) The acceptance ground + (failing on purpose; specs-only config): OK.mdx holds the domain's ONE + resolving occurrence (`use`→`ok`) so every empty selection is provably the + filter's doing (pinned by a bare-enumeration staging arm); the three + non-resolving grounds each carry a spelling a mis-implemented product + would resolve INTO — broken.mdx (14.20; sibling `hidden` + + `hiddenUse d={"hidden"}` complete before the unclosed final tag, so an + error-recovering product serves the record), dup.mdx (14.3 twin pair + + `watcher d={"twin"}` → 14.5, so a winner-picking product serves it), and + the undiscovered docs/other.mdx (valid `x` + `xuse d={"x"}` in no group, + so a filesystem-resolving product serves it). Gate `build --json` pins + {14.20,14.3,14.5} with homes; the five accepted arms (`path#id` nosuch, + bare-path no file, undiscovered, masked, undefined bearer) each assert + exit 1 — never an error, the T12.0-9 partition's stated exception — with + the domain's findings as the exact multiset and occurrences exactly []; + the six malformed arms (two `#`, `#ok` empty path, `ok..use` empty + segment, `ok use` whitespace, `then` forbidden, trailing `OK.mdx#`) ride + T11.2-5's exported usage-error protocol on the same failing workspace, + each defect spelled over the DISCOVERED OK.mdx path where the form allows + (TEST-SPEC's `a#b..c`/`a#then`/`a.mdx#` give classes, not byte-exact + operands — the FP-018 precedent) so a resolve-first product answers and + fails the exit; all under one whole-root modifies-nothing compare. (2) + The exact-selection ground (valid): BASE (top ⊃ top.sub) + USE staging + four records (useTop's d AND embedding → top, useSub → top.sub, useRoot's + module-form d={BASE} → root), all four pinned bare first; `--to #top` → + exactly the two top-targeting records (both kinds, never the + descendant's, never the root's), `--to #top.sub` → exactly the + descendant's own, bare `--to specs/BASE.mdx` → exactly the module-form + root record (T2.2-2), never the file's section-targeted ones. Verified: + typecheck/format clean; suite section-11.3 red-as-diagnosed — T11.3-3 + fails at the gate build's FP-001-class form-exact decode ("expected no + member \"column\""), T11.3-1/2's known reds unchanged; direct probes + against the built product prove the stagings (ws1 build exit 1 with + old-shape 14.20-in-broken + 14.3-in-dup — the absent 14.5 is FP-053's + diagnosed pre-patch resolution gap, same as T11.2-4's; ws2 build exit 0 + with exactly the four staged dependency edges incl. the module-form root + edge; `occurrences` still unknown, exit 2 — the pre-patch gap); scratch + run (deleted) of the registered body against a conforming fake deriving + findings/records/selection from workspace bytes green end-to-end, ten + deviation fakes each failing at exactly the targeted arm — ignore-to, + unknown-node-error, and findings-follow-to at the accepted arms, + lenient-spelling at the malformed protocol, serve-picked/recover-masked/ + fs-resolve at their non-resolving arms, prefix-select and depends-only at + the `#top` exact compare, file-select at the bare-path compare. `npm run + test:self`: unchanged 4 planned mid-loop reds (certification-document ×3 + → FP-091; S-1's 5 unmapped keys {11.4, 11.5, 11.6, 12.6, 12.7} → stage + G), S-5 and certification green.] - [ ] FP-059 — Implement T11.3-4 (CONF-AVAIL): definitive emptiness. [R2 #3, R3 gap 1 (VIOL-AVAIL-NOFILE certifies exactly this), VERIFY; TEST-SPEC §11.3] diff --git a/test/suite/registry/section-11.3.ts b/test/suite/registry/section-11.3.ts index 2a98ab8..4f0c708 100644 --- a/test/suite/registry/section-11.3.ts +++ b/test/suite/registry/section-11.3.ts @@ -1,4 +1,5 @@ -// TEST-SPEC §11.3 (`xspec occurrences`) — SUITE-53: T11.3-1 and T11.3-2. +// TEST-SPEC §11.3 (`xspec occurrences`) — SUITE-53: T11.3-1, T11.3-2, and +// T11.3-3. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -49,6 +50,35 @@ // target) tuple unique; ranges and order enforced by the decode); the // exit-2 arms ride T11.2-5's exported usage-error protocol // (registry/section-11.2.ts). +// +// T11.3-3 owns its two fixtures. (1) The acceptance ground (failing on +// purpose): SPEC 11.3 makes `--to` acceptance purely syntactic — only a +// malformed spelling is a usage error (12.0; T12.0-9's partition states the +// same exception) — so every well-formed spelling naming an identity that +// does not currently resolve is ACCEPTED and selects the empty set while the +// domain's findings stay on the answer, exit 1, never exit 2. The workspace +// stages one resolving occurrence (so each empty selection is the filter's +// doing, pinned by a bare-enumeration staging arm, never a product that +// enumerates nothing) beside the three non-resolving grounds the TEST-SPEC +// names — an undiscovered on-disk file (valid content whose occurrence a +// configuration-blind product would resolve and select), a masked file +// (14.20; its pre-breakage sections and reference spellings recorded by a +// recovering product), and duplicate bearers (14.3) with an ambiguous +// reference to them (14.5; recorded by a winner-picking product) — plus the +// no-such-node spellings in both syntactic forms. Malformed spellings ride +// T11.2-5's exported usage-error protocol on this same failing workspace +// (the argument checks precede answering, whatever findings the workspace +// carries); each malformed arm spells its defect over the DISCOVERED +// specs/OK.mdx path where the form allows, so a resolve-first product that +// finds the file and answers (empty or otherwise) instead of erring is +// discriminated — TEST-SPEC's parenthetical `a#b..c`/`a#then`/`a.mdx#` +// spellings give the malformed classes, not byte-exact operands (the +// FP-018/T6.5-4 `b.mdx#` precedent). (2) The exact-selection ground +// (valid): a two-file workspace whose four records make every mis-selection +// nonempty-visible — a resolving identity selects the occurrences targeting +// it (both edge kinds), never its descendant's records and never the +// root's, and a bare path selects exactly the module-form root reference +// (T2.2-2), never the file's section-targeted records. import { Buffer } from "node:buffer"; import type { @@ -1228,5 +1258,560 @@ const T11_3_2 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T11.3-3 — `--to`: syntactic acceptance / malformed spellings; exact +// selection +// --------------------------------------------------------------------------- + +// The acceptance workspace (failing on purpose). specs/OK.mdx is the +// finding-free file holding the domain's ONE resolving occurrence +// (`use` → `ok`), so every accepted-but-empty answer below is provably the +// selection's doing: a product ignoring `--to` returns this record and fails +// the empty compare, while a product erring on a non-resolving identity +// fails the exit assertion (SPEC 11.3: acceptance is syntactic, never an +// error). The three non-resolving grounds each carry a spelling a +// mis-implemented product would resolve INTO: +// +// - specs/broken.mdx (masked, 14.20): sibling sections `hidden` and +// `hiddenUse d={"hidden"}` precede the breakage (the final section never +// closes), so an error-recovering product that keeps the pre-breakage +// parse resolves `hiddenUse` → `hidden` and serves it under +// `--to specs/broken.mdx#hidden`, where the whole-file masking of 14 +// demands the empty set. +// - specs/dup.mdx: two bearers of `twin` (14.3 — every bearer undefined, no +// winner) and `watcher d={"twin"}` (ambiguous → no occurrence, its 14.5 +// reporting it instead), so a winner-picking product records +// `watcher` → `twin` and serves it under `--to specs/dup.mdx#twin`. +// - docs/other.mdx: fully VALID content (`x` and `xuse d={"x"}`) in NO +// configured group (SPEC 7: discovery is controlled exclusively by +// configuration), so a product resolving the operand against the +// filesystem instead of the discovered set records `xuse` → `x` and +// serves it under `--to docs/other.mdx#x` — while for a conforming +// product the file contributes nothing: no finding, no record. +const TO_OK_FILE = "specs/OK.mdx"; +const TO_MASKED_FILE = "specs/broken.mdx"; +const TO_DUP_FILE = "specs/dup.mdx"; +const TO_DECOY_FILE = "docs/other.mdx"; + +const TO_OK_SOURCE = [ + '<S id="ok">', + "Ok text.", + "</S>", + "", + '<S id="use" d={"ok"}>', + "Use text.", + "</S>", + "", +].join("\n"); + +const TO_MASKED_SOURCE = [ + '<S id="hidden">', + "Hidden text.", + "</S>", + "", + '<S id="hiddenUse" d={"hidden"}>', + "Hidden use — this final section never closes, so the file is", + "unparseable on purpose (14.20) and masked whole.", + "", +].join("\n"); + +const TO_DUP_SOURCE = [ + '<S id="twin">', + "Twin one.", + "</S>", + "", + '<S id="twin">', + "Twin two.", + "</S>", + "", + '<S id="watcher" d={"twin"}>', + "Watcher text.", + "</S>", + "", +].join("\n"); + +const TO_DECOY_SOURCE = [ + '<S id="x">', + "X text.", + "</S>", + "", + '<S id="xuse" d={"x"}>', + "X use.", + "</S>", + "", +].join("\n"); + +/** + * The acceptance workspace's complete finding multiset — the `build --json` + * gate and every accepted-arm answer pin exactly this (no `--file`, so the + * consulted domain is the entire discovered set and `--to` never changes the + * accompanying findings): broken's parse failure, dup's duplicate pair, and + * dup's ambiguous reference; nothing from OK.mdx, nothing from the + * undiscovered decoy. + */ +const TO_WORKSPACE_CONDITIONS: Readonly<Record<string, number>> = { + "14.20": 1, + "14.3": 1, + "14.5": 1, +}; + +/** The whole domain's one record — the ground every empty selection filters. */ +const TO_BASELINE_TUPLES: readonly RecordTuple[] = [ + { + file: TO_OK_FILE, + kind: "depends", + source: "specs/OK.mdx#use", + target: "specs/OK.mdx#ok", + }, +]; + +/** + * The five accepted-but-empty spellings (SPEC 11.3: acceptance is syntactic, + * and a named identity that does not currently resolve selects the empty + * set) — the TEST-SPEC's list: `path#id`, bare `path`, an undiscovered + * file's identity, a masked file's, an undefined bearer's. + */ +const TO_ACCEPTED_EMPTY: ReadonlyArray<readonly [string, string]> = [ + [ + `${TO_OK_FILE}#nosuch`, + "well-formed `path#id` — a discovered file's nonexistent id (no such " + + "node)", + ], + [ + "specs/none.mdx", + "well-formed bare `path` — a root identity no discovered file bears " + + "(no such file anywhere)", + ], + [ + `${TO_DECOY_FILE}#x`, + "an undiscovered file's identity — the on-disk docs/other.mdx is in no " + + "configured group, so its section `x` resolves for no conforming " + + "product", + ], + [ + `${TO_MASKED_FILE}#hidden`, + "a masked file's identity — specs/broken.mdx is unparseable (14.20), " + + "its pre-breakage `hidden` section masked with the rest", + ], + [ + `${TO_DUP_FILE}#twin`, + "an undefined bearer's identity — duplicate spellings of `twin` leave " + + "every bearer undefined, no winner picked", + ], +]; + +/** + * The malformed spellings, one arm per TEST-SPEC class (whitespace-bearing + * and forbidden-name staged one arm each), each exit 2 (SPEC 11.3, 1.4, + * 12.0). Where the form allows, the defect is spelled over the DISCOVERED + * specs/OK.mdx path, so a product that resolves first and errs only on + * unknown names answers (empty or otherwise) and fails the exit assertion. + */ +const TO_MALFORMED: ReadonlyArray<readonly [string, string]> = [ + [`${TO_OK_FILE}#ok#use`, "more than one `#`"], + ["#ok", "an empty path part"], + [`${TO_OK_FILE}#ok..use`, "an empty segment (the `a#b..c` class)"], + [`${TO_OK_FILE}#ok use`, "a whitespace-bearing segment (U+0020 inside)"], + [`${TO_OK_FILE}#then`, "a forbidden-name segment (the `a#then` class)"], + [`${TO_OK_FILE}#`, "a trailing empty id part (the `a.mdx#` class)"], +]; + +// The exact-selection workspace (valid): four records, all in specs/USE.mdx +// in source order, chosen so every mis-selection is nonempty-visible against +// the per-index compares — `--to specs/BASE.mdx#top` must select the two +// records targeting `top` (one per edge kind: the `d` entry and the +// embedding), never `useSub`'s record targeting the DESCENDANT `top.sub` +// (a prefix- or subtree-selecting product fails) and never the root-targeted +// record; `--to specs/BASE.mdx#top.sub` selects exactly the descendant's own +// record (the complement); and the bare `--to specs/BASE.mdx` selects +// exactly the module-form root reference `d={BASE}` (T2.2-2: a `depends` +// edge to the file's root node, identified by the path alone, SPEC 1.5) — +// a product reading the bare path as "anything in (or into) that file" +// returns the section-targeted records and fails. +const SEL_BASE_FILE = "specs/BASE.mdx"; +const SEL_USE_FILE = "specs/USE.mdx"; + +const SEL_BASE_SOURCE = [ + '<S id="top">', + "Top text.", + "", + '<S id="top.sub">', + "Sub text.", + "</S>", + "</S>", + "", +].join("\n"); + +const SEL_USE_SOURCE = [ + 'import BASE from "./BASE.xspec"', + "", + '<S id="useTop" d={BASE.top}>', + "Top use: {text(BASE.top)}", + "</S>", + "", + '<S id="useSub" d={BASE.top.sub}>', + "Sub use.", + "</S>", + "", + '<S id="useRoot" d={BASE}>', + "Root use.", + "</S>", + "", +].join("\n"); + +const SEL_TOP_D: RecordTuple = { + file: SEL_USE_FILE, + kind: "depends", + source: "specs/USE.mdx#useTop", + target: "specs/BASE.mdx#top", +}; +const SEL_TOP_EMBED: RecordTuple = { + file: SEL_USE_FILE, + kind: "embeds", + source: "specs/USE.mdx#useTop", + target: "specs/BASE.mdx#top", +}; +const SEL_SUB_D: RecordTuple = { + file: SEL_USE_FILE, + kind: "depends", + source: "specs/USE.mdx#useSub", + target: "specs/BASE.mdx#top.sub", +}; +const SEL_ROOT_D: RecordTuple = { + file: SEL_USE_FILE, + kind: "depends", + source: "specs/USE.mdx#useRoot", + target: "specs/BASE.mdx", +}; + +/** All four records in occurrence order (one file, source order). */ +const SEL_ALL_TUPLES: readonly RecordTuple[] = [ + SEL_TOP_D, + SEL_TOP_EMBED, + SEL_SUB_D, + SEL_ROOT_D, +]; + +const T11_3_3 = defineProductTest({ + id: "T11.3-3", + title: + "`--to` acceptance is syntactic: well-formed spellings naming identities that do not currently resolve — a discovered file's nonexistent id (`path#id`), a bare `path` no file bears, an undiscovered on-disk file's identity, a masked (14.20) file's, an undefined duplicate bearer's — are each accepted and select the empty set while the domain's one real occurrence stays enumerable (pinned bare) and the domain's findings stay on the answer (exactly {14.20, 14.3, 14.5}, exit 1), never an error; malformed spellings — more than one `#`, an empty path part, an empty segment, a whitespace-bearing segment, a forbidden-name segment (`then`), a trailing empty id part — each exit 2 with the single 12.7 error document, the argument check preceding answering whatever findings the workspace carries; selection is exact over a valid workspace: a resolving identity selects the occurrences targeting it — both its `d`-entry and its embedding record, never the descendant `top.sub`'s record and never the root's — the descendant's own identity selects exactly its record, and a bare path selects exactly the module-form root reference (T2.2-2), never the file's section-targeted records (SPEC 11.3, 11.2, 1.4, 1.5, 12.0, 12.7)", + run: async (product) => { + // --- Workspace 1: the acceptance ground (failing on purpose). ------------- + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [TO_OK_FILE]: TO_OK_SOURCE, + [TO_MASKED_FILE]: TO_MASKED_SOURCE, + [TO_DUP_FILE]: TO_DUP_SOURCE, + [TO_DECOY_FILE]: TO_DECOY_SOURCE, + }, + }); + try { + await assertLeavesUnchanged( + workspace.root, + async () => { + // Gate reference and staging integrity (SPEC 12.1, 14): exactly + // the three staged conditions, homes pinned, so every + // acceptance assertion below reads on staged ground. + const gateContext = + "T11.3-3 `build --json` (staging integrity: broken's 14.20, " + + "dup's 14.3 and 14.5; OK.mdx finding-free; the undiscovered " + + "docs/other.mdx contributes nothing)"; + const gateFindings = await buildFindings( + product, + workspace, + gateContext, + ); + assertConditionCounts( + gateFindings, + TO_WORKSPACE_CONDITIONS, + `${gateContext} — exactly the staged conditions (SPEC 14)`, + ); + assertFindingLocated( + findingByCondition(gateFindings, "14.20", gateContext), + { file: TO_MASKED_FILE }, + `${gateContext} — the parse failure locates in broken.mdx ` + + `(SPEC 14)`, + ); + assertFindingLocated( + findingByCondition(gateFindings, "14.3", gateContext), + { file: TO_DUP_FILE }, + `${gateContext} — the duplicate \`twin\` pair locates every ` + + `bearer, both in dup.mdx (SPEC 14)`, + ); + assertFindingLocated( + findingByCondition(gateFindings, "14.5", gateContext), + { file: TO_DUP_FILE }, + `${gateContext} — the ambiguous \`watcher\` reference ` + + `locates in dup.mdx (SPEC 14)`, + ); + + // Bare-enumeration staging pin: the domain holds EXACTLY the one + // resolving record, so each accepted arm's empty selection below + // is the `--to` filter's observable doing — never a domain that + // was empty to begin with. + { + const context = + "T11.3-3 bare `occurrences` (staging pin: the whole " + + "domain's one record)"; + const result = await expectExit( + product, + workspace, + ["occurrences"], + 1, + `${context} — the domain's findings accompany, so exit 1 ` + + `with the full answer (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + context, + ); + assertConditionCounts( + report.findings, + TO_WORKSPACE_CONDITIONS, + `${context}: the domain's findings — nothing for the ` + + `undiscovered decoy (SPEC 11.2, 14)`, + ); + assertSameJson( + report.occurrences.map(projectTuple), + TO_BASELINE_TUPLES, + `${context}: exactly the one resolving record ` + + `(\`use\` → \`ok\`) — no record for the masked file's ` + + `spellings, the ambiguous \`d={"twin"}\`, or the ` + + `undiscovered decoy's content (SPEC 5.7, 11.2, 11.3)`, + ); + } + + // --- The accepted-but-empty spellings: acceptance is syntactic + // (SPEC 11.3) — each well-formed spelling is accepted whatever + // the workspace contains, selects the empty set, keeps the + // domain's findings on the answer, and is NEVER an error (the + // T12.0-9 partition: unknown-node usage errors exist everywhere + // except `occurrences --to`). + for (const [spelling, what] of TO_ACCEPTED_EMPTY) { + const context = `T11.3-3 \`occurrences --to "${spelling}"\` (${what})`; + const result = await expectExit( + product, + workspace, + ["occurrences", "--to", spelling], + 1, + `${context} — accepted, never an error: the named identity ` + + `does not currently resolve, so the selection is empty ` + + `while the domain's findings keep the answer at exit 1 ` + + `(SPEC 11.3, 11.2, 12.0)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + context, + ); + assertConditionCounts( + report.findings, + TO_WORKSPACE_CONDITIONS, + `${context}: \`--to\` selects occurrences and never ` + + `changes the consulted domain — the domain's findings ` + + `accompany unchanged (SPEC 11.2, 11.3)`, + ); + assertSameJson( + report.occurrences, + [], + `${context}: the empty selection — never the domain's ` + + `\`use\` → \`ok\` record (a product ignoring \`--to\`), ` + + `never a masked file's, winner-picked, or ` + + `filesystem-resolved record (SPEC 11.2, 11.3)`, + ); + } + + // --- The malformed spellings: each exit 2 via the shared + // JSON-only usage-error protocol (single 12.7 error document, + // message on stderr), the argument check preceding answering, + // whatever findings the workspace carries (SPEC 11.2, 11.3, + // 1.4, 12.0). + for (const [spelling, what] of TO_MALFORMED) { + await expectAvailabilityUsageError( + product, + workspace, + ["occurrences", "--to", spelling], + `T11.3-3 malformed \`--to\` spelling ` + + `${JSON.stringify(spelling)} — ${what} — on the failing ` + + `workspace`, + ); + } + }, + "T11.3-3 workspace 1 — no invocation of the sweep modifies " + + "anything: the gate build fails writing nothing (SPEC 12.1) " + + "and on a failing workspace these surfaces answer from current " + + "sources and write nothing (SPEC 11.2; the no-write contract " + + "clauses live at T11.2-1/T11.2-6)", + ); + } finally { + await workspace.dispose(); + } + } + + // --- Workspace 2: selection is exact (valid ground). ---------------------- + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [SEL_BASE_FILE]: SEL_BASE_SOURCE, + [SEL_USE_FILE]: SEL_USE_SOURCE, + }, + }); + try { + await buildOk( + product, + workspace, + "T11.3-3 `build` (premise: the selection workspace is valid, so " + + "every answer below is complete and finding-free, SPEC 11.2, " + + "11.3)", + ); + + // Staging pin: all four records exist in the unrestricted + // enumeration, so each selection below provably filters a domain + // that HOLDS the records it must exclude (the descendant's and the + // root's records are absent from the `top` selection because of the + // selection, never because they were never recorded). + { + const context = + "T11.3-3 bare `occurrences` (staging pin: all four records)"; + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences"], + `${context} — complete and finding-free, exit 0 (SPEC 11.2, ` + + `11.3)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the domain carries no finding (SPEC 11.2)`, + ); + assertSameJson( + report.occurrences.map(projectTuple), + SEL_ALL_TUPLES, + `${context}: the complete four-record sequence per index — ` + + `\`useTop\`'s \`d\` entry and embedding (both targeting ` + + `\`top\`), \`useSub\`'s record targeting the descendant ` + + `\`top.sub\`, and the module-form \`d={BASE}\` record ` + + `targeting the root (SPEC 2.2, 5.7, 11.3)`, + ); + } + + // A resolving identity selects the occurrences targeting it — not + // its descendants' and not the root's. + { + const context = + "T11.3-3 `occurrences --to specs/BASE.mdx#top` (a resolving " + + "identity)"; + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences", "--to", `${SEL_BASE_FILE}#top`], + `${context} — complete and finding-free, exit 0 (SPEC 11.2, ` + + `11.3)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the domain carries no finding (SPEC 11.2)`, + ); + assertSameJson( + report.occurrences.map(projectTuple), + [SEL_TOP_D, SEL_TOP_EMBED], + `${context}: exactly the two records whose resolved target is ` + + `\`top\` — the \`d\` entry and the embedding, whatever the ` + + `edge kind — never the descendant \`top.sub\`'s record (a ` + + `prefix- or subtree-selecting product fails here) and never ` + + `the root-targeted one (SPEC 11.3, 5.7)`, + ); + } + + // The complement: the descendant's own identity selects exactly its + // record. + { + const context = + "T11.3-3 `occurrences --to specs/BASE.mdx#top.sub` (the " + + "descendant's own identity)"; + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences", "--to", `${SEL_BASE_FILE}#top.sub`], + `${context} — complete and finding-free, exit 0 (SPEC 11.2, ` + + `11.3)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the domain carries no finding (SPEC 11.2)`, + ); + assertSameJson( + report.occurrences.map(projectTuple), + [SEL_SUB_D], + `${context}: exactly \`useSub\`'s record — the descendant's ` + + `occurrences belong to the descendant's own identity, not ` + + `to its parent's selection (SPEC 11.3)`, + ); + } + + // A bare path selects module-form root references (T2.2-2). + { + const context = + "T11.3-3 `occurrences --to specs/BASE.mdx` (a bare path — the " + + "root)"; + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences", "--to", SEL_BASE_FILE], + `${context} — complete and finding-free, exit 0 (SPEC 11.2, ` + + `11.3)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the domain carries no finding (SPEC 11.2)`, + ); + assertSameJson( + report.occurrences.map(projectTuple), + [SEL_ROOT_D], + `${context}: exactly the module-form \`d={BASE}\` record — the ` + + `bare path names the file's root node (the path alone, SPEC ` + + `1.5), so the selection is the root-targeted references ` + + `(T2.2-2), never the file's section-targeted records (SPEC ` + + `11.3, 2.2)`, + ); + } + } finally { + await workspace.dispose(); + } + } + }, +}); + /** TEST-SPEC §11.3, in canonical ID order (SUITE-53). */ -export const section113Tests: readonly ProductTestEntry[] = [T11_3_1, T11_3_2]; +export const section113Tests: readonly ProductTestEntry[] = [ + T11_3_1, + T11_3_2, + T11_3_3, +]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 11c4de6..2076a8a 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -306,6 +306,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.2-6": ["11.2"], "T11.3-1": ["11.3"], "T11.3-2": ["11.3"], + "T11.3-3": ["11.3"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From d5168124766abffe7d6e303399c1a11a7f3cf407 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 17:52:29 +0000 Subject: [PATCH 104/226] sdg(phase-9): implement T11.3-4's definitive-emptiness contrast pair (FP-059) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 41 +++++- test/suite/registry/section-11.3.ts | 200 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 239 insertions(+), 3 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 51c01e2..aaf475c 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2566,9 +2566,48 @@ certify against FP-091's fixtures once those land. test:self`: unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; S-1's 5 unmapped keys {11.4, 11.5, 11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-059 — Implement T11.3-4 (CONF-AVAIL): definitive emptiness. +- [x] FP-059 — Implement T11.3-4 (CONF-AVAIL): definitive emptiness. [R2 #3, R3 gap 1 (VIOL-AVAIL-NOFILE certifies exactly this), VERIFY; TEST-SPEC §11.3] + [Done 2026-08-14: registered in section-11.3.ts (SUITE-53) with + traceability ["11.3"] (no numbered condition asserted — finding-free + everywhere). IN CONF-AVAIL scope (VIOL-AVAIL-NOFILE certifies exactly it; + fixtures land with FP-091), so the body obeys the scope's staging + constraints exactly: spec-only workspace (imports + `d` + embedding), NO + gate-reference `build` — the validity premise rides arm 1's own empty + findings member (no `--file` → the whole discovered set's findings + accompany, SPEC 11.2/11.3) — no snapshot compare (graph-data/refresh + behavior expressly out of scope; both states valid, so a conforming + product may refresh), and exactly two `occurrences` answers, both the + empty enumeration `{"findings":[],"occurrences":[]}` exit 0 (the + datum-form violators' stated passing ground). One workspace, one X + (specs/target.mdx#tgt), evolved between the arms per the entry's single + narrative: arm 1 bare `--to X` on a nonempty other-target ground + (teammate.mdx's local `d` + embedding → its own `mate`; X's defining + spelling unreferenced) → absolute emptiness — an ignore-`--to`, + enumerate-the-domain, definition-as-record, or error-on-empty product + fails here; then holder.mdx (import + `d={TGT.tgt}`, the workspace's ONE + resolving occurrence of X) is staged and arm 2 runs `--to X --file + "specs/t*.mdx"` — the glob admits exactly the nonempty {target, teammate} + domain away from holder → still empty, finding-free, exit 0. The + restricted arm carries NO in-test positive control by design (holder lies + outside every domain the test observes — CERTIFICATIONS' stated hazard, + certified through VIOL-AVAIL-NOFILE, whose whole-set enumeration serves + holder's record exactly when successfully staged). Verified: + typecheck/format clean; direct probes against the built product prove the + staging (both states build exit 0 with findings []; the only edge into + `tgt` is holder#user's depends edge; `occurrences` still unknown, exit 2 + — the pre-patch gap); suite section-11.3 red-as-diagnosed — T11.3-4 fails + at arm 1's runJson (exit 2 unknown command), siblings' known reds + unchanged (file 4 failed / 0 passed); scratch run (deleted) of the + registered body: conforming fake (deriving records from workspace bytes) + green end-to-end, the nofile deviation failing exactly at arm 2's + exact-empty enumeration compare (the certified diagnosis), ignore-to / + error-on-empty / definition-as-record each failing at arm 1's targeted + assertion. `npm run test:self`: unchanged 4 planned mid-loop reds + (certification-document ×3 → FP-091, its in-scope-registry gap narrowed + to exactly {T11.4-1, T11.4-3, T11.4-4}; S-1's 5 unmapped keys {11.4, + 11.5, 11.6, 12.6, 12.7} → stage G), S-5 and certification green.] - [ ] FP-060 — Implement T11.4-1 (CONF-AVAIL): whole-domain views and positional tree with tag-range decomposition byte-asserted; stages no diff --git a/test/suite/registry/section-11.3.ts b/test/suite/registry/section-11.3.ts index 4f0c708..bc20821 100644 --- a/test/suite/registry/section-11.3.ts +++ b/test/suite/registry/section-11.3.ts @@ -1,5 +1,5 @@ -// TEST-SPEC §11.3 (`xspec occurrences`) — SUITE-53: T11.3-1, T11.3-2, and -// T11.3-3. +// TEST-SPEC §11.3 (`xspec occurrences`) — SUITE-53: T11.3-1 through +// T11.3-4. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -79,6 +79,31 @@ // it (both edge kinds), never its descendant's records and never the // root's, and a bare path selects exactly the module-form root reference // (T2.2-2), never the file's section-targeted records. +// +// Certification (CERTIFICATIONS.md CONF-AVAIL): T11.3-4 is in scope — +// VIOL-AVAIL-NOFILE certifies exactly it (the fixture family lands with the +// certification-manifest task) — while T11.3-1/2/3 are not (T11.3-1 sits +// behind the section-4 consumer wall, T11.3-2/3's matrices are named +// Exclusions entries). CONF-AVAIL's staging constraint pins every command an +// in-scope test drives to the enumerated `view`/`occurrences` surface, so +// T11.3-4 — unlike its module siblings — runs NO gate-reference `build`: +// its validity premise rides the answers themselves (the unrestricted arm's +// empty findings member IS the whole discovered set's finding-freeness at +// that point, SPEC 11.2/11.3). It observes no graph-data or refresh +// behavior (no snapshot compare: both workspace states are valid, and +// passing-side refresh participation is T13.3-2's subject, expressly out of +// CONF-AVAIL scope), and it makes exactly two `occurrences` answers, both +// empty enumerations — the ground the datum-form violators' passing sides +// stand on (`[]` is not `null`, no member to omit, no marker to replace). +// Its restricted arm carries NO in-test positive control by design: the +// excluded file is staged between the arms and lies outside every consulted +// domain the test ever observes, so nothing observable in-test separates +// restricted-away-from-the-occurrence from an occurrence never successfully +// staged (a mis-staged reference's finding would lie outside the restricted +// domain with the file that holds it) — the staging hazard CERTIFICATIONS.md +// assigns to VIOL-AVAIL-NOFILE, whose whole-set enumeration serves the +// excluded record, failing the exact-empty compare, exactly when the +// occurrence IS successfully staged. import { Buffer } from "node:buffer"; import type { @@ -1809,9 +1834,180 @@ const T11_3_3 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T11.3-4 — definitive emptiness (CONF-AVAIL) +// --------------------------------------------------------------------------- + +// One valid workspace and one queried identity X = specs/target.mdx#tgt +// (CONF-AVAIL's workspace scope: one configured spec group of `.mdx` sources +// at valid-UTF-8 `#`-free workspace-relative paths, imports + `d` props + +// embeddings only), staged in two states around the two arms: +// +// - Arm 1 ground (at creation): specs/target.mdx defines `tgt`, referenced +// by nothing; specs/teammate.mdx holds a local `d` entry AND a local +// embedding, both targeting its own `mate`. The workspace holds real +// occurrences — none of them targeting X — so the empty selection is +// `--to`'s doing over a nonempty enumeration ground: a product ignoring +// `--to`, enumerating the domain wholesale, or serving X's DEFINING +// spelling as an occurrence answers nonempty and fails the exact-empty +// compare; a product treating a zero-occurrence resolving target as an +// error fails the exit (SPEC 11.3: acceptance is syntactic, an empty +// selection is an answer, and T12.0-9's partition states the same +// exception). +// - Between the arms: specs/holder.mdx is staged — an import of target plus +// `d={TGT.tgt}`, the workspace's ONE resolving occurrence of X (probed +// against the built product: the only dependency edge into `tgt`). The +// workspace stays valid: the reference resolves, every identity stays +// defined. +// - Arm 2 (`--file specs/t*.mdx`): the glob admits exactly {target, +// teammate} — a NONEMPTY restricted domain holding teammate's two records +// and X's defining spelling, consulted and still answering empty — away +// from holder. The guarantee is domain-wide only: the outside occurrence +// is neither reported nor denied. +const EMPTY_TARGET_FILE = "specs/target.mdx"; +const EMPTY_TEAMMATE_FILE = "specs/teammate.mdx"; +const EMPTY_HOLDER_FILE = "specs/holder.mdx"; +const EMPTY_X_ID = "specs/target.mdx#tgt"; +const EMPTY_DOMAIN_GLOB = "specs/t*.mdx"; + +const EMPTY_TARGET_SOURCE = ['<S id="tgt">', "Target text.", "</S>", ""].join( + "\n", +); + +const EMPTY_TEAMMATE_SOURCE = [ + '<S id="mate">', + "Mate text.", + "</S>", + "", + '<S id="pal" d={"mate"}>', + 'Pal: {text("mate")}', + "</S>", + "", +].join("\n"); + +const EMPTY_HOLDER_SOURCE = [ + 'import TGT from "./target.xspec"', + "", + '<S id="user" d={TGT.tgt}>', + "User text.", + "</S>", + "", +].join("\n"); + +const T11_3_4 = defineProductTest({ + id: "T11.3-4", + title: + 'Definitive emptiness: in a valid workspace with no reference to node X — real occurrences targeting other nodes on the ground — `occurrences --to X` answers `{"findings":[],"occurrences":[]}`, exit 0, and the proof is absolute without `--file`: the whole discovered set is consulted, so the empty findings member is the workspace\'s own finding-freeness and the empty enumeration says nothing anywhere references X (X\'s defining spelling is no occurrence); with a file then staged holding the workspace\'s one resolving occurrence of X, restricted by `--file` away from that file onto a NONEMPTY domain (X\'s defining file and the other-target records among it, consulted and still empty), the answer is still `{"findings":[],"occurrences":[]}`, exit 0 — the guarantee is domain-wide only, the outside occurrence neither reported nor denied (SPEC 11.3, 11.2)', + run: async (product) => { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [EMPTY_TARGET_FILE]: EMPTY_TARGET_SOURCE, + [EMPTY_TEAMMATE_FILE]: EMPTY_TEAMMATE_SOURCE, + }, + }); + try { + // --- Arm 1: absolute emptiness. No `--file`, so the consulted domain + // is the entire discovered set (SPEC 11.3): the empty, finding-free + // answer is definitive — nothing in the WORKSPACE references X — and + // its empty findings member doubles as the validity premise for this + // ground (the domain's findings accompany, SPEC 11.2; CONF-AVAIL's + // staging constraint admits no gate-reference `build` on this test). + { + const context = + "T11.3-4 `occurrences --to specs/target.mdx#tgt` (no `--file`: " + + "the whole discovered set consulted; nothing references tgt)"; + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences", "--to", EMPTY_X_ID], + `${context} — an empty, finding-free answer exits 0 (SPEC ` + + `11.2, 11.3): a resolving target with no occurrences is an ` + + `answer, never an error (T12.0-9's stated exception)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: without \`--file\` the consulted domain is the ` + + `entire discovered set, so this empty findings member is the ` + + `whole workspace's finding-freeness — the arm's validity ` + + `premise, observed on the answer itself (SPEC 11.2, 11.3)`, + ); + assertSameJson( + report.occurrences, + [], + `${context}: the empty enumeration is definitive over the whole ` + + `discovered set — teammate's two records target its own ` + + `\`mate\`, never \`tgt\`, and target.mdx's defining spelling ` + + `is no occurrence (SPEC 5.7, 11.3) — so a product ignoring ` + + `\`--to\`, enumerating the domain, or serving the definition ` + + `as a record answers nonempty here`, + ); + } + + // --- Between the arms: stage the workspace's ONE resolving + // occurrence of X — holder's `d={TGT.tgt}`. The workspace stays + // valid; no invocation of this test ever consults holder, and that is + // the point (the staging hazard is VIOL-AVAIL-NOFILE's to certify: + // under its whole-set enumeration this record IS served and arm 2's + // exact-empty compare fails — exactly when the occurrence is + // successfully staged). + await workspace.file(EMPTY_HOLDER_FILE, EMPTY_HOLDER_SOURCE); + + // --- Arm 2: domain-wide emptiness. The glob restricts the consulted + // domain to exactly {target, teammate} — nonempty, holding records + // and X's defining spelling, away from the file that holds the + // resolving occurrence of X — and the answer is still empty, + // finding-free, exit 0: the outside occurrence is neither reported + // nor denied (SPEC 11.3). + { + const context = + "T11.3-4 `occurrences --to specs/target.mdx#tgt --file " + + '"specs/t*.mdx"` (restricted away from the file holding the ' + + "one resolving occurrence of tgt)"; + const report = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences", "--to", EMPTY_X_ID, "--file", EMPTY_DOMAIN_GLOB], + `${context} — the restricted domain is finding-free and holds ` + + `no occurrence of tgt, so the empty answer exits 0 (SPEC ` + + `11.2, 11.3)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the admitted files carry no finding — the guarantee ` + + `(and the findings member) is exactly domain-wide (SPEC 11.2, ` + + `11.3)`, + ); + assertSameJson( + report.occurrences, + [], + `${context}: still the empty enumeration — the restricted domain ` + + `is consulted (teammate's two other-target records and ` + + `target.mdx's defining spelling lie within it, selected by ` + + `nothing) while holder's resolving occurrence of tgt lies ` + + `outside it, neither reported nor denied: a product consulting ` + + `the whole discovered set despite \`--file\` serves that ` + + `record and answers nonempty (SPEC 11.3)`, + ); + } + } finally { + await workspace.dispose(); + } + }, +}); + /** TEST-SPEC §11.3, in canonical ID order (SUITE-53). */ export const section113Tests: readonly ProductTestEntry[] = [ T11_3_1, T11_3_2, T11_3_3, + T11_3_4, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 2076a8a..0f96d82 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -307,6 +307,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.3-1": ["11.3"], "T11.3-2": ["11.3"], "T11.3-3": ["11.3"], + "T11.3-4": ["11.3"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From defe0d40fcf11d50061c015a0815add1f1cf223a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 18:14:14 +0000 Subject: [PATCH 105/226] sdg(phase-9): implement T11.4-1's whole-domain views and positional tree (FP-060) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 51 ++- test/suite/registry/index.ts | 2 + test/suite/registry/section-11.4.ts | 547 ++++++++++++++++++++++++++++ test/suite/registry/traceability.ts | 1 + test/suite/section-11.4.test.ts | 9 + 5 files changed, 609 insertions(+), 1 deletion(-) create mode 100644 test/suite/registry/section-11.4.ts create mode 100644 test/suite/section-11.4.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index aaf475c..bdb27a1 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2609,10 +2609,59 @@ certify against FP-091's fixtures once those land. to exactly {T11.4-1, T11.4-3, T11.4-4}; S-1's 5 unmapped keys {11.4, 11.5, 11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-060 — Implement T11.4-1 (CONF-AVAIL): whole-domain views and +- [x] FP-060 — Implement T11.4-1 (CONF-AVAIL): whole-domain views and positional tree with tag-range decomposition byte-asserted; stages no undefined datum. [R2 #4, R3 gap 1, VERIFY; TEST-SPEC §11.4] New §11.4 registry module; map `"11.4"`. + [Done 2026-08-14: new module test/suite/registry/section-11.4.ts + (SUITE-54) + wrapper section-11.4.test.ts, spread into registry/index.ts; + traceability "T11.4-1": ["11.4"] (the T11.2-*/T11.3-* precedent: the + answer-borne condition counts are staging integrity, 14.16's primary + coverage stays T2.7-1's). IN CONF-AVAIL scope, so the body obeys the + scope's constraints exactly: spec-only workspace, ONE command — the bare + whole-domain `view` — NO gate-reference `build` (the validity premise + rides the answer's own findings member), no snapshot compare, and NO + undefined datum staged: every identity spelled, well-formed, conformant + against its POSITIONAL parent, unique — the marker-free ground + VIOL-AVAIL-NULLMARKER's passing side stands on, while the stated `null`s + the answers do carry (root tags/coverage; self-closing/root decomposition + members) are exactly what decodeViewReport rejects when omitted + (VIOL-AVAIL-OMIT's certified failure; fixtures land with FP-091). One + workspace, three files, whole document asserted: specs/Zebra.mdx (0x5A) < + specs/alpha.mdx (0x61) < specs/sub/leaf.mdx (0x73) pins byte order + against case-folding/locale collation and completeness (leaf is + section-less — root-only view, a views-only-sectioned-files product + drops it); Zebra pins the finding-free tree — paired sections at three + depths, self-closing leaves at depths two and three, two top-level + sections — per node construct range + decomposition (opening AND closing + for paired, opening only = construct range for self-closing, neither for + the root, whose range is the whole file) byte-asserted via the + running-offset builder behind a multi-byte prefix, projection {identity, + range, opening, closing, children} (attributes/tags/coverage VALUES stay + T11.4-3's; the form-exact decode already enforces their presence/forms); + alpha pins invalid-element parenting — wrap ⊃ wrap.mid ⊃ <div> ⊃ + wrap.mid.inner parents to wrap.mid (INNERMOST, never wrap/root) and a + top-level <em> ⊃ free parents to the root — with exactly the two 14.16 + findings (no phantom 14.2; the chain conditions read the same enclosure, + so every identity stays a defined plain string), each located within its + own element's whole-construct window (div's ordered before em's, 12.7), + exit 1 with the full answer; per-file imports/occurrences/comments [] + (nothing staged). Verified: typecheck/format clean; suite section-11.4 + red-as-diagnosed against the stub (HarnessAssertionError at the bare + `view`: exit 2 "unknown command 'view'" where 1 is demanded — the whole + §11 surface is patch-new); scratch run (deleted) of the registered body: + conforming fake (deriving views/findings from workspace bytes) green + end-to-end, ten deviation fakes each failing at the targeted assertion — + outermost/rootparent/elementnode/noclosing/chainbreak at the tree + compare, casefold at the decode's byte-order rejection, skipempty at the + whole-domain file-list compare, omitroot at the decode's + null-is-never-omission rejection (the OMIT anchor), utf16 at the + finding's byte window, misjudge (phantom 14.2) at the exact findings + count. `npm run test:self`: the 4 planned mid-loop reds narrowed as + scheduled — certification-document ×3 → FP-091, its in-scope-registry + gap now exactly {T11.4-3, T11.4-4}; S-1's unmapped keys now exactly + {11.5, 11.6, 12.6, 12.7} → stage G — S-5 and certification green + (certification.test.ts 17/17).] - [ ] FP-061 — Implement T11.4-2: operands-vs-restriction. [R2 #4; TEST-SPEC §11.4] - [ ] FP-062 — Implement T11.4-3 (CONF-AVAIL): raw attributes and per-node diff --git a/test/suite/registry/index.ts b/test/suite/registry/index.ts index a04a229..620dcfc 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -58,6 +58,7 @@ import { section107iiTests } from "./section-10.7-ii.js"; import { section11Tests } from "./section-11.js"; import { section112Tests } from "./section-11.2.js"; import { section113Tests } from "./section-11.3.js"; +import { section114Tests } from "./section-11.4.js"; import { section120iTests } from "./section-12.0-i.js"; import { section120iiTests } from "./section-12.0-ii.js"; import { section121to122Tests } from "./section-12.1-12.2.js"; @@ -124,6 +125,7 @@ export const productTestSuite = new ProductTestSuite([ ...section11Tests, ...section112Tests, ...section113Tests, + ...section114Tests, ...section120iTests, ...section120iiTests, ...section121to122Tests, diff --git a/test/suite/registry/section-11.4.ts b/test/suite/registry/section-11.4.ts new file mode 100644 index 0000000..2042b8b --- /dev/null +++ b/test/suite/registry/section-11.4.ts @@ -0,0 +1,547 @@ +// TEST-SPEC §11.4 (`xspec view`) — SUITE-54: T11.4-1 (T11.4-2 through +// T11.4-6 are planned follow-ups in this module). +// +// Registered product-facing bodies (C-2 "one code path"): each builds its own +// fresh workspace (H-1), drives the product strictly as a subprocess (H-2), +// asserts exact exit codes (H-5), and rejects a product only via diagnosed +// assertion failures (H-8). SPEC 11: `view` is JSON-only — a single JSON +// document is its only output form, with or without `--json` — in the +// form-exact 12.7 document form (H-3), so every invocation below runs bare +// and its entire stdout decodes through `decodeViewReport`, which enforces +// the top level (`{"findings", "views"}` exactly), every per-file wrapper and +// node member (`{"identity", "range", "opening", "closing", "attributes", +// "tags", "coverage", "children"}`, the text members absent without +// `--text`), the three-state datum forms, and the pinned orders (per-file +// views by path bytes, children/attributes/imports/occurrences/comments in +// document order) over whatever the product emits. +// +// T11.4-1 — views and tree. One workspace, one bare `view` (neither operands +// nor `--file`), the whole document asserted: +// +// - Whole domain and order: every discovered spec source is viewed — a +// section-less file included (a product viewing only files that hold +// sections drops specs/sub/leaf.mdx and fails the exact file-list +// compare) — as per-file views in byte order of workspace-relative path. +// The staged names discriminate the collation: "specs/Zebra.mdx" (Z, 0x5A) +// sorts before "specs/alpha.mdx" (a, 0x61) before "specs/sub/leaf.mdx" +// (s, 0x73) by path bytes, while a case-folding or locale collation orders +// alpha first and fails (the exact compare here; the decode's +// strictly-ascending check besides). +// - Tree and decomposition (specs/Zebra.mdx, finding-free): the root and the +// full positional section tree in document order — paired sections at +// three depths, a self-closing leaf at depth three and another at depth +// two, two top-level sections — per node the construct range and the +// decomposition, byte-asserted against precomputed offsets composed by the +// running-offset builder (SPEC 1.7: zero-based byte offsets, +// start-inclusive end-exclusive; the multi-byte prefix shifts every later +// offset so code-point, UTF-16, or line/column reporters fail): opening +// AND closing tag ranges for paired sections, opening only — the whole +// self-closing tag, equal to the construct range — for self-closing +// sections, neither (both `null`) for the root, whose range is the entire +// file. +// - Invalid-element parenting (specs/alpha.mdx): a section nested inside an +// invalid non-section element parents to the INNERMOST enclosing section +// construct — `wrap.mid.inner`, inside a `<div>` inside `wrap.mid` inside +// `wrap`, parents to `wrap.mid` (never `wrap`, never the root: an +// outermost-section or root parenting fails the exact tree compare and +// would judge the ID against the wrong prefix) — and to the root when no +// section encloses the element (`free`, inside a top-level `<em>`). The +// enclosure is the one 11.2's chain conditions read: every staged identity +// is spelled, well-formed, structurally conformant against its POSITIONAL +// parent, and unique, so every identity datum is the plain expected +// string — a product reading the invalid element as a chain member (its +// spelled identity none) marks the nested section unavailable and fails +// the compare — and the answer's findings are exactly the two 14.16s (a +// mis-parenting product reports a phantom 14.2 and fails the count), each +// located within its own element's construct window in specs/alpha.mdx, +// the `<div>`'s finding ordered before the `<em>`'s (12.7: equal codes +// order by locations; the windows are disjoint). The invalid elements get +// NO view entry (SPEC 11.4: the invalid constructs of 14.16 get no view +// entry — an extra node fails the tree compare). +// - Findings and exit: the two 14.16 findings ARE the staging-integrity pin +// (no gate-reference `build` — see the certification note), and any +// finding means exit 1 with the full answer still emitted (SPEC 11.2). +// imports/occurrences/comments are asserted `[]` per file — nothing is +// staged, and empty lists are `[]`, never `null` (SPEC 12.7). +// +// Certification (CERTIFICATIONS.md CONF-AVAIL): T11.4-1 is IN scope (the +// fixture family lands with the certification-manifest task), so the body +// obeys the scope's staging constraints exactly: a spec-only workspace of +// `.mdx` sources at valid-UTF-8 `#`-free paths; the ONE command it drives is +// the enumerated surface's bare whole-domain `view` — NO gate-reference +// `build` (the validity premise rides the answer's own findings member) — +// and NO snapshot compare (graph-data and refresh behavior are expressly out +// of CONF-AVAIL scope). Its fixtures stage NO undefined datum — every node +// identity defined under 11.2's chain conditions, the invalid-element arm +// keeping every spelled identity defined — so its answers carry the +// unavailability marker nowhere: the marker-free ground +// VIOL-AVAIL-NULLMARKER's passing side stands on (nothing undefined, so the +// deviation touches nothing), while the stated `null`s the answers DO carry +// (each root's `tags`/`coverage`; `closing` on self-closing sections; +// `opening`/`closing` on roots) make the decode fail under VIOL-AVAIL-OMIT +// exactly as certified (`null` is never omission — decodeViewReport rejects +// the absent members). The per-node `tags`/`coverage` VALUES and raw +// attribute entries stay outside this test's compare (T11.4-3's subject; the +// decode already enforces their presence and forms). + +import { Buffer } from "node:buffer"; +import type { SourceRange, ViewNode } from "../../helpers/adapters/index.js"; +import { decodeViewReport } from "../../helpers/adapters/index.js"; +import { fail, parseJsonStdout } from "../../helpers/assertions.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { SPECS_ONLY_CONFIG } from "./section-11.2.js"; +import { + assertConditionCounts, + assertFindingLocated, + assertSameJson, + expectExit, +} from "./support.js"; + +/** + * Running byte-offset fixture assembler (the T5.7-2/T11.2-1 discipline): + * `add` appends a segment and returns its byte range, so every expected + * offset is composed from the same parts the staged file is. + */ +class ByteFixture { + private readonly parts: string[] = []; + private bytes = 0; + + get pos(): number { + return this.bytes; + } + + get source(): string { + return this.parts.join(""); + } + + add(segment: string): SourceRange { + const start = this.bytes; + this.parts.push(segment); + this.bytes += Buffer.byteLength(segment, "utf8"); + return { start, end: this.bytes }; + } +} + +/** + * Fixture self-check (harness-side, before any product invocation): a + * claimed byte range must slice the staged file's bytes to exactly the span + * it claims. A failure here is a staging-arithmetic defect of the harness, + * never a product failure. + */ +function sliceCheck( + source: string, + range: SourceRange, + span: string, + what: string, +): void { + const actual = Buffer.from(source, "utf8") + .subarray(range.start, range.end) + .toString("utf8"); + if (actual !== span) { + fail( + `T11.4-1 fixture self-check — ${what}: the claimed byte range ` + + `[${String(range.start)}, ${String(range.end)}) slices the staged ` + + `bytes to ${JSON.stringify(actual)}, expected ` + + `${JSON.stringify(span)} (a harness-side staging error, not a ` + + `product failure)`, + ); + } +} + +// --- specs/Zebra.mdx — the decomposition ground (finding-free) ---------------- +// +// Paired sections at three depths (top ⊃ top.one ⊃ top.one.deep's +// self-closing sibling shape below), a self-closing leaf at depth three +// (top.one.deep) and one at depth two (top.two), a second top-level section +// (side), and prose before, between, and after constructs. The multi-byte +// prefix (é: 2 bytes; è: 2 bytes; —: 3 bytes) shifts every later offset, so +// byte offsets diverge from code-point and UTF-16 counts (SPEC 1.7). + +const ZEBRA_FILE = "specs/Zebra.mdx"; + +const Z = new ByteFixture(); +Z.add("Prélude — Zèbre guard prose.\n\n"); +const Z_TOP_OPEN = Z.add('<S id="top">'); +Z.add("\nTop own text before.\n\n"); +const Z_ONE_OPEN = Z.add('<S id="top.one">'); +Z.add("\nOne text.\n\n"); +const Z_DEEP_TAG = '<S id="top.one.deep" />'; +const Z_DEEP_RANGE = Z.add(Z_DEEP_TAG); +Z.add("\nOne tail.\n"); +const Z_ONE_CLOSE = Z.add("</S>"); +const Z_ONE_RANGE: SourceRange = { start: Z_ONE_OPEN.start, end: Z.pos }; +Z.add("\n\nBetween the children.\n\n"); +const Z_TWO_TAG = '<S id="top.two" />'; +const Z_TWO_RANGE = Z.add(Z_TWO_TAG); +Z.add("\nTop own text after.\n"); +const Z_TOP_CLOSE = Z.add("</S>"); +const Z_TOP_RANGE: SourceRange = { start: Z_TOP_OPEN.start, end: Z.pos }; +Z.add("\n\n"); +const Z_SIDE_OPEN = Z.add('<S id="side">'); +Z.add("\nSide text.\n"); +const Z_SIDE_CLOSE = Z.add("</S>"); +const Z_SIDE_RANGE: SourceRange = { start: Z_SIDE_OPEN.start, end: Z.pos }; +Z.add("\n"); +const ZEBRA_SOURCE = Z.source; +const Z_ROOT_RANGE: SourceRange = { start: 0, end: Z.pos }; + +// --- specs/alpha.mdx — invalid-element parenting (two 14.16s) ----------------- +// +// `wrap.mid.inner` sits inside a `<div>` inside `wrap.mid` inside `wrap`: +// its positional parent is the INNERMOST enclosing section construct, +// `wrap.mid`. `free` sits inside a top-level `<em>`: no section encloses it, +// so it parents to the root and its one-segment ID is checked against the +// empty prefix. Every spelled identity is well-formed, conformant against +// its positional parent, and unique, so the file's only findings are the two +// invalid elements' 14.16s — each element's WHOLE construct recorded as the +// byte window its finding's locations must fall within (located-range +// precision is T11.4-6/T14-8's business). + +const ALPHA_FILE = "specs/alpha.mdx"; + +const AL = new ByteFixture(); +AL.add("Alpha prose — enclosure guard.\n\n"); +const AL_WRAP_OPEN = AL.add('<S id="wrap">'); +AL.add("\nWrap own text.\n\n"); +const AL_MID_OPEN = AL.add('<S id="wrap.mid">'); +AL.add("\nMid text.\n"); +const AL_DIV_START = AL.pos; +AL.add("<div>\n"); +const AL_INNER_OPEN = AL.add('<S id="wrap.mid.inner">'); +AL.add("\nInner text.\n"); +const AL_INNER_CLOSE = AL.add("</S>"); +const AL_INNER_RANGE: SourceRange = { start: AL_INNER_OPEN.start, end: AL.pos }; +AL.add("\n</div>"); +const AL_DIV_WINDOW: SourceRange = { start: AL_DIV_START, end: AL.pos }; +AL.add("\n"); +const AL_MID_CLOSE = AL.add("</S>"); +const AL_MID_RANGE: SourceRange = { start: AL_MID_OPEN.start, end: AL.pos }; +AL.add("\n"); +const AL_WRAP_CLOSE = AL.add("</S>"); +const AL_WRAP_RANGE: SourceRange = { start: AL_WRAP_OPEN.start, end: AL.pos }; +AL.add("\n\n"); +const AL_EM_START = AL.pos; +AL.add("<em>\n"); +const AL_FREE_TAG = '<S id="free" />'; +const AL_FREE_RANGE = AL.add(AL_FREE_TAG); +AL.add("\n</em>"); +const AL_EM_WINDOW: SourceRange = { start: AL_EM_START, end: AL.pos }; +AL.add("\n"); +const ALPHA_SOURCE = AL.source; +const AL_ROOT_RANGE: SourceRange = { start: 0, end: AL.pos }; + +// --- specs/sub/leaf.mdx — a section-less file (root-only view) ---------------- + +const LEAF_FILE = "specs/sub/leaf.mdx"; +const LEAF_SOURCE = "Only prose in this file — no section at all.\n"; +const LEAF_ROOT_RANGE: SourceRange = { + start: 0, + end: Buffer.byteLength(LEAF_SOURCE, "utf8"), +}; + +// --- expected trees ----------------------------------------------------------- + +/** + * The projection T11.4-1 pins per node (its named clauses): the identity + * datum, the construct range (1.7), the range's decomposition — opening and + * closing tag ranges, `null` where none exists — and the children in + * document order. Raw attribute entries and interpreted tags/coverage stay + * outside (T11.2-1 and T11.4-3 pin those); the form-exact decode has already + * validated their presence and forms. + */ +interface TreeShape { + readonly identity: string | { readonly unavailable: true }; + readonly range: SourceRange; + readonly opening: SourceRange | null; + readonly closing: SourceRange | null; + readonly children: readonly TreeShape[]; +} + +function projectShape(node: ViewNode): TreeShape { + return { + identity: node.identity, + range: node.range, + opening: node.opening, + closing: node.closing, + children: node.children.map(projectShape), + }; +} + +const ZEBRA_TREE: TreeShape = { + identity: ZEBRA_FILE, + range: Z_ROOT_RANGE, + opening: null, + closing: null, + children: [ + { + identity: `${ZEBRA_FILE}#top`, + range: Z_TOP_RANGE, + opening: Z_TOP_OPEN, + closing: Z_TOP_CLOSE, + children: [ + { + identity: `${ZEBRA_FILE}#top.one`, + range: Z_ONE_RANGE, + opening: Z_ONE_OPEN, + closing: Z_ONE_CLOSE, + children: [ + { + identity: `${ZEBRA_FILE}#top.one.deep`, + range: Z_DEEP_RANGE, + opening: Z_DEEP_RANGE, + closing: null, + children: [], + }, + ], + }, + { + identity: `${ZEBRA_FILE}#top.two`, + range: Z_TWO_RANGE, + opening: Z_TWO_RANGE, + closing: null, + children: [], + }, + ], + }, + { + identity: `${ZEBRA_FILE}#side`, + range: Z_SIDE_RANGE, + opening: Z_SIDE_OPEN, + closing: Z_SIDE_CLOSE, + children: [], + }, + ], +}; + +const ALPHA_TREE: TreeShape = { + identity: ALPHA_FILE, + range: AL_ROOT_RANGE, + opening: null, + closing: null, + children: [ + { + identity: `${ALPHA_FILE}#wrap`, + range: AL_WRAP_RANGE, + opening: AL_WRAP_OPEN, + closing: AL_WRAP_CLOSE, + children: [ + { + identity: `${ALPHA_FILE}#wrap.mid`, + range: AL_MID_RANGE, + opening: AL_MID_OPEN, + closing: AL_MID_CLOSE, + children: [ + { + identity: `${ALPHA_FILE}#wrap.mid.inner`, + range: AL_INNER_RANGE, + opening: AL_INNER_OPEN, + closing: AL_INNER_CLOSE, + children: [], + }, + ], + }, + ], + }, + { + identity: `${ALPHA_FILE}#free`, + range: AL_FREE_RANGE, + opening: AL_FREE_RANGE, + closing: null, + children: [], + }, + ], +}; + +const LEAF_TREE: TreeShape = { + identity: LEAF_FILE, + range: LEAF_ROOT_RANGE, + opening: null, + closing: null, + children: [], +}; + +const EXPECTED_VIEWS: readonly { + readonly file: string; + readonly tree: TreeShape; +}[] = [ + { file: ZEBRA_FILE, tree: ZEBRA_TREE }, + { file: ALPHA_FILE, tree: ALPHA_TREE }, + { file: LEAF_FILE, tree: LEAF_TREE }, +]; + +const T11_4_1 = defineProductTest({ + id: "T11.4-1", + title: + "with neither operands nor `--file`, one bare `view` (JSON-only, a single form-exact 12.7 document) serves every discovered spec source — a section-less file included — as per-file views in byte order of workspace-relative path (specs/Zebra.mdx < specs/alpha.mdx < specs/sub/leaf.mdx: 0x5A < 0x61 < 0x73, never a case-folding or locale collation); per file the root and the full positional section tree in document order, each node's construct range and decomposition byte-asserted against precomputed offsets behind a multi-byte prefix (SPEC 1.7): opening and closing tag ranges for paired sections at three depths, opening only — the whole self-closing tag, equal to the construct range — for self-closing sections, neither for the root, whose range is the entire file; a section nested inside an invalid `<div>` parents to the INNERMOST enclosing section construct (`wrap.mid`, never `wrap`, never the root — the enclosure 11.2's chain conditions read, so every staged identity stays a defined plain string) and a section inside a top-level `<em>` parents to the root, the invalid elements getting no view entry, exactly the two 14.16 findings accompanying (no phantom 14.2), each located within its own element's construct window, exit 1 with the full answer (SPEC 11.4, 11.2, 1.7, 12.7, 14)", + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline) — composed-range arithmetic + // proven against the staged bytes before any product invocation. + sliceCheck(ZEBRA_SOURCE, Z_TOP_OPEN, '<S id="top">', "top's opening tag"); + sliceCheck(ZEBRA_SOURCE, Z_TOP_CLOSE, "</S>", "top's closing tag"); + sliceCheck( + ZEBRA_SOURCE, + Z_ONE_OPEN, + '<S id="top.one">', + "top.one's opening tag", + ); + sliceCheck(ZEBRA_SOURCE, Z_ONE_CLOSE, "</S>", "top.one's closing tag"); + sliceCheck( + ZEBRA_SOURCE, + Z_DEEP_RANGE, + Z_DEEP_TAG, + "top.one.deep's self-closing tag", + ); + sliceCheck( + ZEBRA_SOURCE, + Z_TWO_RANGE, + Z_TWO_TAG, + "top.two's self-closing tag", + ); + sliceCheck( + ZEBRA_SOURCE, + Z_SIDE_OPEN, + '<S id="side">', + "side's opening tag", + ); + sliceCheck(ZEBRA_SOURCE, Z_SIDE_CLOSE, "</S>", "side's closing tag"); + sliceCheck( + ALPHA_SOURCE, + AL_DIV_WINDOW, + '<div>\n<S id="wrap.mid.inner">\nInner text.\n</S>\n</div>', + "the in-section invalid element's whole construct", + ); + sliceCheck( + ALPHA_SOURCE, + AL_EM_WINDOW, + '<em>\n<S id="free" />\n</em>', + "the top-level invalid element's whole construct", + ); + sliceCheck( + ALPHA_SOURCE, + AL_INNER_RANGE, + '<S id="wrap.mid.inner">\nInner text.\n</S>', + "wrap.mid.inner's whole construct", + ); + sliceCheck(ALPHA_SOURCE, AL_FREE_RANGE, AL_FREE_TAG, "free's tag"); + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [ZEBRA_FILE]: ZEBRA_SOURCE, + [ALPHA_FILE]: ALPHA_SOURCE, + [LEAF_FILE]: LEAF_SOURCE, + }, + }); + try { + // The one invocation (CONF-AVAIL's enumerated surface: no + // gate-reference `build`, no snapshot compare): the bare whole-domain + // `view`. The answer carries alpha's two 14.16 findings, so exit 1 + // with the full answer still emitted (SPEC 11.2). + const context = "T11.4-1 bare `view` (whole domain, no operands)"; + const result = await expectExit( + product, + workspace, + ["view"], + 1, + `${context} — the answer carries the two staged 14.16 findings, so ` + + `the invocation exits 1 with the full document still emitted ` + + `(SPEC 11.2, 11.4)`, + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form, ` + + `with or without --json (SPEC 11)`, + ), + { text: false }, + context, + ); + + // Staging integrity rides the answer itself (no `build` gate): exactly + // the two invalid elements' findings — one 14.16 per element, nothing + // else. A product mis-parenting a nested section reports a phantom + // 14.2 here; one reading the invalid element as a masking chain member + // drops nothing observable here but fails the identity compare below. + assertConditionCounts( + report.findings, + { "14.16": 2 }, + `${context}: the consulted domain's findings are exactly the two ` + + `invalid-element findings — every staged identity is spelled, ` + + `well-formed, conformant against its positional parent, and ` + + `unique, so no 14.1/14.2/14.3/14.4 arises (SPEC 11.2, 11.4, 14)`, + ); + const invalidElementFindings = report.findings.filter( + (finding) => finding.condition === "14.16", + ); + // The findings order is decode-enforced (12.7: equal codes order by + // locations element-wise), and the two elements' windows are disjoint + // with the `<div>` wholly before the `<em>`, so the array order pins + // which finding is which. + assertFindingLocated( + invalidElementFindings[0]!, + { file: ALPHA_FILE, window: AL_DIV_WINDOW }, + `${context} — the in-section \`<div>\`'s 14.16 locates within that ` + + `element's construct in specs/alpha.mdx (SPEC 14, 12.7)`, + ); + assertFindingLocated( + invalidElementFindings[1]!, + { file: ALPHA_FILE, window: AL_EM_WINDOW }, + `${context} — the top-level \`<em>\`'s 14.16 locates within that ` + + `element's construct in specs/alpha.mdx (SPEC 14, 12.7)`, + ); + + // Whole domain, byte order: exactly the three discovered spec sources, + // Zebra (0x5A) < alpha (0x61) < sub/leaf (0x73) — completeness (the + // section-less leaf viewed) and collation in one compare. + assertSameJson( + report.views.map((view) => view.file), + EXPECTED_VIEWS.map((view) => view.file), + `${context}: every discovered spec source is viewed — the ` + + `section-less file included — in byte order of ` + + `workspace-relative path (SPEC 11.4, 12.7)`, + ); + + // Per file: the full positional section tree in document order, each + // node's construct range and decomposition byte-exact; nothing else is + // staged, so imports, occurrences, and comments are `[]` (never + // `null`, SPEC 12.7). + EXPECTED_VIEWS.forEach((expected, index) => { + const view = report.views[index]!; + assertSameJson( + projectShape(view.root), + expected.tree, + `${context} — ${expected.file}: the root and the full positional ` + + `section tree in document order, per node the construct range ` + + `and its decomposition against precomputed byte offsets — ` + + `opening and closing tag ranges for paired sections, opening ` + + `only for self-closing, neither for the root — and every ` + + `identity the defined plain string (SPEC 11.4, 11.2, 1.7)`, + ); + assertSameJson( + view.imports, + [], + `${context} — ${expected.file}: no import is staged, and an ` + + `empty list is [], never null (SPEC 11.4, 12.7)`, + ); + assertSameJson( + view.occurrences, + [], + `${context} — ${expected.file}: no reference spelling is staged ` + + `(SPEC 11.4, 5.7, 12.7)`, + ); + assertSameJson( + view.comments, + [], + `${context} — ${expected.file}: no MDX comment is staged (SPEC ` + + `11.4, 12.7)`, + ); + }); + } finally { + await workspace.dispose(); + } + }, +}); + +export const section114Tests: readonly ProductTestEntry[] = [T11_4_1]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 0f96d82..65f47ec 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -308,6 +308,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.3-2": ["11.3"], "T11.3-3": ["11.3"], "T11.3-4": ["11.3"], + "T11.4-1": ["11.4"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], diff --git a/test/suite/section-11.4.test.ts b/test/suite/section-11.4.test.ts new file mode 100644 index 0000000..9ac9194 --- /dev/null +++ b/test/suite/section-11.4.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §11.4 (SUITE-54): thin Vitest wrapper over the registered +// bodies — the identical bodies the certification runner executes against +// fixture products (C-2 "one code path"). Expected to fail as diagnosed +// assertion failures until the product exists (H-8). + +import { declareProductTests } from "./declare.js"; +import { section114Tests } from "./registry/section-11.4.js"; + +declareProductTests(section114Tests); From 9c5a9bbd15a763de144bcc86846732aa22820501 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 18:32:22 +0000 Subject: [PATCH 106/226] sdg(phase-9): implement T11.4-2's operands-vs-restriction matrix (FP-061) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 41 +++- test/suite/registry/section-11.4.ts | 345 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 381 insertions(+), 6 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index bdb27a1..341fe3a 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2662,8 +2662,47 @@ certify against FP-091's fixtures once those land. gap now exactly {T11.4-3, T11.4-4}; S-1's unmapped keys now exactly {11.5, 11.6, 12.6, 12.7} → stage G — S-5 and certification green (certification.test.ts 17/17).] -- [ ] FP-061 — Implement T11.4-2: operands-vs-restriction. [R2 #4; +- [x] FP-061 — Implement T11.4-2: operands-vs-restriction. [R2 #4; TEST-SPEC §11.4] + [Done 2026-08-14: T11.4-2 appended to test/suite/registry/section-11.4.ts + (module header extended); traceability "T11.4-2": ["11.4"] (the T11.3-2 + precedent: gate condition counts are staging integrity, no "14"; + 12.0/7 parentheticals context). NOT in CONF-AVAIL scope — a named + Exclusions entry (the domain-and-exit matrices) — so unlike T11.4-1 it + drives the gate-reference `build --json` (staging integrity: exactly one + 14.3 in specs/bad.mdx, one 14.8 in src/app.ts — the T11.3-2 staging + mirror — specs/dup.mdx finding-free, docs/note.mdx an on-disk unparseable + decoy in NO configured group) and wraps the whole sweep in one + assertLeavesUnchanged. Arms: operands assert membership via T11.2-5's + exported usage-error protocol — a file existing nowhere, the on-disk + undiscovered decoy (a filesystem-resolving product accepts it), and the + discovered code source as wrong-kind (its own 14.8 notwithstanding — + checks precede answering), each exit 2 with the single 12.7 error + document; `--file` restricts — the decoy glob, a nothing-at-all glob, and + the SAME `src/app.ts` spelling that just erred as an operand each answer + `{"findings": [], "views": []}` exit 0 (the code arm is the sharp half: + a product reusing 11.3's spec-and-code-alike filter carries the staged + 14.8 and exits 1); combining operand with `--file`, each part + individually valid, exit 2; `view specs/dup.mdx specs/dup.mdx` → ONE + view, findings [], exit 0 on the failing workspace (domain = requested + files; positive control that empty answers are the filter's doing), the + view's substance pinned at identity level (root + `#solo`; ranges/ + attributes stay T11.4-1/-3's). Verified: typecheck/format clean; staging + premise probed against the built product (`build --json` reports exactly + the one 14.3 in bad.mdx and one 14.8 in app.ts, old shape); suite + section-11.4 red-as-diagnosed (T11.4-2 fails at the gate's FP-001-class + form-exact findings decode — "expected no member \"condition\"" — arms + unreached until that product gap closes; T11.4-1 unchanged at `view` + unknown-command); scratch run (deleted) of the registered body: + conforming fake (deriving findings/views from workspace bytes) green + end-to-end, seven deviation fakes each failing at the targeted + assertion — fsoperand/wrongkind at the operand exit-2 arms, fsglob/ + codeglob/wsfindings at the empty-answer arms, combine at the combining + exit-2 arm, dupview at the decode's strictly-ascending views rejection. + `npm run test:self`: unchanged 4 planned mid-loop reds + (certification-document ×3 → FP-091, its in-scope-registry gap still + exactly {T11.4-3, T11.4-4}; S-1's unmapped keys exactly {11.5, 11.6, + 12.6, 12.7} → stage G), S-5 and certification green.] - [ ] FP-062 — Implement T11.4-3 (CONF-AVAIL): raw attributes and per-node data with stated-`null` root `tags`/`coverage`. [R2 #4, R3 gap 1, VERIFY; TEST-SPEC §11.4] diff --git a/test/suite/registry/section-11.4.ts b/test/suite/registry/section-11.4.ts index 2042b8b..c04cd0e 100644 --- a/test/suite/registry/section-11.4.ts +++ b/test/suite/registry/section-11.4.ts @@ -1,5 +1,5 @@ -// TEST-SPEC §11.4 (`xspec view`) — SUITE-54: T11.4-1 (T11.4-2 through -// T11.4-6 are planned follow-ups in this module). +// TEST-SPEC §11.4 (`xspec view`) — SUITE-54: T11.4-1 and T11.4-2 (T11.4-3 +// through T11.4-6 are planned follow-ups in this module). // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -64,6 +64,47 @@ // imports/occurrences/comments are asserted `[]` per file — nothing is // staged, and empty lists are `[]`, never `null` (SPEC 12.7). // +// T11.4-2 — operands vs restriction (SPEC 11.4). One failing-on-purpose +// workspace, the whole sweep inside one modifies-nothing compare: +// +// - Staging (the `build --json` gate pins it before any arm, so every +// domain-and-exit assertion below reads on staged ground): specs/dup.mdx +// is finding-free with one section `solo` (the positive-control file the +// set arm views); specs/bad.mdx holds exactly one 14.3 (a duplicate +// `twin` pair); src/app.ts is a DISCOVERED code source holding exactly one +// 14.8 (the string-form `text("solo")` call, invalid in TypeScript by +// form, SPEC 4.3) beside a resolving `SPEC.solo` marker; docs/note.mdx is +// an on-disk, deliberately unparseable decoy in NO configured group (SPEC +// 7: discovery is controlled exclusively by configuration). +// - `<file>` operands assert membership in the DISCOVERED spec-source +// domain: a file existing nowhere and the on-disk undiscovered decoy each +// exit 2 as an unknown file (a product resolving operands against the +// filesystem accepts the decoy and answers — or surfaces its 14.20 — +// instead of erring); the discovered code source exits 2 as a wrong-kind +// operand (12.0), its own 14.8 notwithstanding — the argument checks +// precede answering (11.2, the T11.2-5 protocol), never exit 1 with the +// file's findings. +// - `--file` is instead a set restriction over the domain: a glob matching +// only the undiscovered decoy, one matching nothing at all, and the SAME +// `src/app.ts` spelling that just erred as an operand each admit the +// empty set — `{"findings": [], "views": []}`, exit 0, no unknown-file +// usage error on this filter, whatever findings the workspace carries. +// The only-code-sources arm is the sharp half (SPEC 11.4: the restriction +// admits the discovered SPEC sources it matches, unlike 11.3's +// spec-and-code-alike filter): a product reusing the occurrences filter +// consults the finding-laden code file, carries its 14.8, and exits 1. +// - Combining `<file>` operands with `--file` — each part individually +// valid — is a usage error, exit 2 (an intersecting or union product +// answers instead). +// - The requested files form a set: the discovered specs/dup.mdx named +// twice yields ONE view (the decode besides rejects a duplicated view +// entry: per-file views are strictly ascending by path bytes), the +// finding-free domain {dup} exiting 0 with an empty findings member while +// bad.mdx and the code source stay failing — the domain is the requested +// files (T11.2-5's ground riding as this arm's positive control). The +// view's substance is pinned at identity level (root and child identity); +// ranges, attributes, and interpreted values stay T11.4-1/-3's subject. +// // Certification (CERTIFICATIONS.md CONF-AVAIL): T11.4-1 is IN scope (the // fixture family lands with the certification-manifest task), so the body // obeys the scope's staging constraints exactly: a spec-only workspace of @@ -82,7 +123,12 @@ // exactly as certified (`null` is never omission — decodeViewReport rejects // the absent members). The per-node `tags`/`coverage` VALUES and raw // attribute entries stay outside this test's compare (T11.4-3's subject; the -// decode already enforces their presence and forms). +// decode already enforces their presence and forms). T11.4-2 is NOT in +// scope: CERTIFICATIONS.md's Exclusions name the argument, spelling, and +// domain-and-exit matrices of the machine-interface surfaces (T11.2-5, +// T11.3-2/3, T11.4-2, T11.5-2) — certified representatively through the +// shared machinery — so unlike its sibling it is free to drive the +// gate-reference `build` and the snapshot compare. import { Buffer } from "node:buffer"; import type { SourceRange, ViewNode } from "../../helpers/adapters/index.js"; @@ -90,13 +136,20 @@ import { decodeViewReport } from "../../helpers/adapters/index.js"; import { fail, parseJsonStdout } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import { SPECS_ONLY_CONFIG } from "./section-11.2.js"; +import { + expectAvailabilityUsageError, + SPEC_AND_CODE_CONFIG, + SPECS_ONLY_CONFIG, +} from "./section-11.2.js"; import { assertConditionCounts, assertFindingLocated, assertSameJson, + buildFindings, expectExit, + runJson, } from "./support.js"; /** @@ -544,4 +597,286 @@ const T11_4_1 = defineProductTest({ }, }); -export const section114Tests: readonly ProductTestEntry[] = [T11_4_1]; +// --- T11.4-2 — operands vs restriction ---------------------------------------- +// +// The matrix ground (failing on purpose; module header): a finding-free spec +// source, a spec source with one 14.3, a discovered code source with one +// 14.8, and an on-disk decoy no configured group discovers. + +const OV_DUP_FILE = "specs/dup.mdx"; +const OV_DUP_SOURCE = ['<S id="solo">', "Solo text.", "</S>", ""].join("\n"); + +const OV_BAD_FILE = "specs/bad.mdx"; +const OV_BAD_SOURCE = [ + '<S id="twin">', + "Twin one.", + "</S>", + "", + '<S id="twin">', + "Twin two.", + "</S>", + "", +].join("\n"); + +const OV_CODE_FILE = "src/app.ts"; +const OV_CODE_SOURCE = [ + 'import SPEC, { text } from "../specs/dup.xspec";', + "", + "export function grab(): void {", + " SPEC.solo;", + "}", + "", + "export function bad(): string {", + ' return text("solo");', + "}", + "", +].join("\n"); + +const OV_DECOY_FILE = "docs/note.mdx"; +const OV_DECOY_SOURCE = '<S id="trap">\nUnclosed on purpose.\n'; + +/** The workspace's complete finding multiset (the `build --json` gate). */ +const OV_WORKSPACE_CONDITIONS: Readonly<Record<string, number>> = { + "14.3": 1, + "14.8": 1, +}; + +/** + * The set arm's identity-level projection: the served view's substance is + * pinned by node identities alone — the construct ranges, decompositions, + * attribute entries, and interpreted values are T11.4-1's and T11.4-3's + * subject (the form-exact decode has already enforced their presence and + * forms). + */ +interface IdentityShape { + readonly identity: string | { readonly unavailable: true }; + readonly children: readonly IdentityShape[]; +} + +function projectIdentities(node: ViewNode): IdentityShape { + return { + identity: node.identity, + children: node.children.map(projectIdentities), + }; +} + +const OV_DUP_IDENTITY_TREE: IdentityShape = { + identity: OV_DUP_FILE, + children: [{ identity: `${OV_DUP_FILE}#solo`, children: [] }], +}; + +const T11_4_2 = defineProductTest({ + id: "T11.4-2", + title: + '`<file>` operands assert membership in the DISCOVERED spec-source domain while `--file` is a set restriction over it: an undiscovered operand — a file existing nowhere, and an on-disk `docs/note.mdx` no configured group discovers — exits 2 as an unknown file, and a discovered code source exits 2 as a wrong-kind operand (12.0), its own staged 14.8 notwithstanding — the argument checks precede answering — each with the single 12.7 error document; the SAME `src/app.ts` spelling as a `--file` value instead admits the empty set — a glob matching only code sources, one matching the undiscovered on-disk decoy, and one matching nothing at all each answer `{"findings": [], "views": []}`, exit 0, no unknown-file usage error on this filter, whatever findings the workspace carries; combining `<file>` operands with `--file`, each part individually valid, exits 2; and the requested files form a set — the discovered `specs/dup.mdx` named twice yields ONE view, its finding-free domain exiting 0 with the root and section identities served while the rest of the workspace stays failing, no invocation of the sweep modifying anything (SPEC 11.4, 11.2, 12.0, 12.7, 7)', + run: async (product) => { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + [OV_DUP_FILE]: OV_DUP_SOURCE, + [OV_BAD_FILE]: OV_BAD_SOURCE, + [OV_CODE_FILE]: OV_CODE_SOURCE, + [OV_DECOY_FILE]: OV_DECOY_SOURCE, + }, + }); + try { + await assertLeavesUnchanged( + workspace.root, + async () => { + // Gate reference and staging integrity (SPEC 12.1, 14): exactly + // one 14.3 in bad.mdx and one 14.8 in the discovered code source, + // nothing else — dup.mdx is finding-free and the decoy is in no + // configured group, contributing nothing (SPEC 7: discovery is + // controlled exclusively by configuration). Every domain-and-exit + // assertion below reads on this staged ground. + const gateContext = + "T11.4-2 `build --json` (staging integrity: one 14.3 in " + + "specs/bad.mdx, one 14.8 in src/app.ts; specs/dup.mdx " + + "finding-free; the undiscovered docs/note.mdx contributes " + + "nothing)"; + const gateFindings = await buildFindings( + product, + workspace, + gateContext, + ); + assertConditionCounts( + gateFindings, + OV_WORKSPACE_CONDITIONS, + `${gateContext} — exactly the staged conditions (SPEC 14)`, + ); + assertFindingLocated( + gateFindings.find((finding) => finding.condition === "14.3")!, + { file: OV_BAD_FILE }, + `${gateContext} — the duplicate \`twin\` pair locates every ` + + `bearer, both in specs/bad.mdx (SPEC 14)`, + ); + assertFindingLocated( + gateFindings.find((finding) => finding.condition === "14.8")!, + { file: OV_CODE_FILE }, + `${gateContext} — the string-form \`text("solo")\` call ` + + `locates in the code source (SPEC 4.3, 14)`, + ); + + // --- `<file>` operands assert membership (SPEC 11.4, 12.0): an + // undiscovered file is unknown — whether it exists nowhere or + // sits on disk outside every configured group (a product + // resolving operands against the filesystem accepts the decoy + // and answers, or surfaces its 14.20, instead of erring) — and a + // discovered code source is a wrong-kind operand, each exit 2 + // with the single 12.7 error document, the checks preceding + // answering whatever findings the workspace or the named file + // carries (SPEC 11.2, T11.2-5's protocol). + await expectAvailabilityUsageError( + product, + workspace, + ["view", "specs/Nope.mdx"], + "T11.4-2 unknown `<file>` operand (a file existing nowhere) " + + "on the failing workspace", + ); + await expectAvailabilityUsageError( + product, + workspace, + ["view", OV_DECOY_FILE], + "T11.4-2 unknown `<file>` operand (docs/note.mdx exists on " + + "disk but no configured group discovers it — membership is " + + "in the DISCOVERED set, SPEC 7) on the failing workspace", + ); + await expectAvailabilityUsageError( + product, + workspace, + ["view", OV_CODE_FILE], + "T11.4-2 wrong-kind `<file>` operand (src/app.ts is a " + + "discovered CODE source, which has no structural view — " + + "SPEC 11.4, 12.0), its own staged 14.8 notwithstanding: the " + + "argument checks precede answering, never exit 1 with the " + + "file's findings", + ); + + // --- `--file` restricts the domain (SPEC 11.4): a glob + // admitting no discovered SPEC source admits the empty set — an + // empty, finding-free answer, exit 0, no unknown-file usage + // error on this filter, whatever findings the workspace + // carries. The `src/app.ts` arm is the operand-vs-restriction + // contrast in one spelling — the path that just erred as an + // operand — and the sharp half of "only code sources": a + // product reusing 11.3's spec-and-code-alike filter consults + // the code file, carries its staged 14.8, and exits 1. + for (const [glob, what] of [ + [ + "docs/*.mdx", + "matching the on-disk but UNDISCOVERED docs/note.mdx — a " + + "product globbing the filesystem consults the unparseable " + + "decoy and answers nonempty", + ], + ["nosuch/**/*.mdx", "matching nothing at all"], + [ + OV_CODE_FILE, + "matching only a discovered CODE source — the restriction " + + "admits the discovered SPEC sources it matches (SPEC " + + "11.4), so the finding-laden src/app.ts is never " + + "consulted, unlike 11.3's spec-and-code-alike filter", + ], + ] as const) { + const context = `T11.4-2 \`view --file "${glob}"\` (${what})`; + const report = decodeViewReport( + await runJson( + product, + workspace, + ["view", "--file", glob], + `${context} — the glob admits the empty set: an empty, ` + + `finding-free answer exits 0, and no unknown-file usage ` + + `error exists on this filter, whatever findings the ` + + `workspace carries (SPEC 11.4, 11.2)`, + ), + { text: false }, + context, + ); + assertSameJson( + report.findings, + [], + `${context}: an empty consulted domain has no findings — ` + + `the workspace's staged 14.3/14.8 are no domain file's ` + + `findings here (SPEC 11.2, 11.4)`, + ); + assertSameJson( + report.views, + [], + `${context}: the empty set of views — an empty list is [], ` + + `never null (SPEC 11.4, 12.7)`, + ); + } + + // --- Combining `<file>` operands with `--file` is a usage + // error, exit 2 (SPEC 11.4) — each part individually valid (the + // operand is a discovered spec source; the glob matches + // discovered spec sources), so an intersecting or union product + // answers with views instead of erring. + await expectAvailabilityUsageError( + product, + workspace, + ["view", OV_DUP_FILE, "--file", "specs/*.mdx"], + "T11.4-2 combining a `<file>` operand with `--file` (each " + + "part individually valid — the combination itself is the " + + "usage error, SPEC 11.4)", + ); + + // --- The requested files form a set (SPEC 11.4): a file named + // twice yields one view. The decode besides rejects a + // duplicated per-file entry (views strictly ascending by path + // bytes). Domain {dup} is finding-free, so exit 0 with an empty + // findings member while bad.mdx and the code source stay + // failing — the domain is the requested files (T11.2-5's + // ground, riding as this arm's positive control that the + // workspace serves views at all: the empty answers above are + // the filter's doing, not a product serving nothing). + { + const context = + "T11.4-2 `view specs/dup.mdx specs/dup.mdx` (a discovered " + + "file named twice)"; + const report = decodeViewReport( + await runJson( + product, + workspace, + ["view", OV_DUP_FILE, OV_DUP_FILE], + `${context} — the requested files form a set with the ` + + `finding-free domain {specs/dup.mdx}, so exit 0 with ` + + `the full answer (SPEC 11.4, 11.2)`, + ), + { text: false }, + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the domain's one file is finding-free — ` + + `bad.mdx's 14.3 and the code source's 14.8 are no domain ` + + `file's findings (SPEC 11.2, 11.4)`, + ); + assertSameJson( + report.views.map((view) => view.file), + [OV_DUP_FILE], + `${context}: ONE view — a file named twice yields one ` + + `(SPEC 11.4)`, + ); + assertSameJson( + projectIdentities(report.views[0]!.root), + OV_DUP_IDENTITY_TREE, + `${context}: the served view is genuinely the named ` + + `file's — the root and its one section, each identity ` + + `the defined plain string (SPEC 11.4, 11.2, 1.5)`, + ); + } + }, + "T11.4-2 — no invocation of the sweep modifies anything: the gate " + + "build fails writing nothing (SPEC 12.1) and on a failing " + + "workspace these surfaces answer from current sources and write " + + "nothing (SPEC 11.2; the no-write contract clauses live at " + + "T11.2-1/T11.2-6)", + ); + } finally { + await workspace.dispose(); + } + }, +}); + +export const section114Tests: readonly ProductTestEntry[] = [T11_4_1, T11_4_2]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 65f47ec..8b0b74c 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -309,6 +309,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.3-3": ["11.3"], "T11.3-4": ["11.3"], "T11.4-1": ["11.4"], + "T11.4-2": ["11.4"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From fb3bdeb09f89283ed9083749afdadccab3763d5f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 18:54:09 +0000 Subject: [PATCH 107/226] sdg(phase-9): implement T11.4-3's attribute matrix and per-node datum states (FP-062) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 37 ++- test/suite/registry/section-11.4.ts | 473 ++++++++++++++++++++++++++-- test/suite/registry/traceability.ts | 1 + 3 files changed, 485 insertions(+), 26 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 341fe3a..40a44c3 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2703,9 +2703,44 @@ certify against FP-091's fixtures once those land. (certification-document ×3 → FP-091, its in-scope-registry gap still exactly {T11.4-3, T11.4-4}; S-1's unmapped keys exactly {11.5, 11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-062 — Implement T11.4-3 (CONF-AVAIL): raw attributes and per-node +- [x] FP-062 — Implement T11.4-3 (CONF-AVAIL): raw attributes and per-node data with stated-`null` root `tags`/`coverage`. [R2 #4, R3 gap 1, VERIFY; TEST-SPEC §11.4] + [Done 2026-08-14: T11.4-3 registered in section-11.4.ts (wrapper + auto-declares; traceability `"T11.4-3": ["11.4"]` — T11.4-3 is in no + TEST-SPEC 14 staging record, the T11.2-2 precedent). One workspace, two + files, two invocations, inside CONF-AVAIL's staging constraints + (spec-only `.mdx` workspace, no `build` gate, no snapshot compare, + `view` alone — bare + one `<file>` operand — staged conditions 14.17 + only, within the scope's stated set): specs/attrs.mdx stages the + five-attribute tag `<S id="dup" id="dup" note="mystery" {...extras} + tags>` plus `<S id="cov" coverage={"none"}>`; the bare view asserts + every raw attribute entry {name, range, text} byte-exactly in tag order + (the repeated id's BOTH entries; the spread's name the stated null, its + text the whole braced construct; the valueless bare-name tags), exactly + five located 14.17 beside the view (inclusion by form, never an + omission; no 14.1/14.16/14.2/14.3 beside), and per-node + identity/tags/coverage in every legitimate state (unavailable: the + repeated-id identity, the valueless tags, the braced coverage; plain: + cov/ok identities, [] and ["solo"] tags, the absent-prop default + "required" on the spread-bearing tag and "none"; the roots' stated + nulls); the finding-free specs/clean.mdx named as an operand exits 0 + with root tags/coverage null — the no-finding, no-exit-1 root arm + VIOL-AVAIL-OMIT's note names. Verified: typecheck/format clean; suite + red-as-diagnosed at the first invocation (`view` unknown command, exit + 2 — the same product gap as T11.4-1); scratch run (deleted) of the + registered body against a workspace-deriving conforming fake green + end-to-end, nine deviation fakes each failing at the targeted + assertion — nullmarker at the decode's never-null identity, omit at the + decode's absent finding-`path` member, dropinvalid/spreadname at the + attributes compare, takefirst/readbraced/nofinding at the exact 14.17 + count, rootmarker at the root-null tree compare, cleanmarker + (operand-invocation-only misbehavior) at the exit-0 assertion. + `npm run test:self`: unchanged 4 planned mid-loop reds, the + certification-document in-scope-registry gap narrowed exactly + {T11.4-3, T11.4-4} → {T11.4-4} (rest → FP-091; S-1's unmapped keys + {11.5, 11.6, 12.6, 12.7} → stage G); S-5, S-7, and certification + green.] - [ ] FP-063 — Implement T11.4-4 (CONF-AVAIL): imports datum. [R2 #4, R3 gap 1, VERIFY; TEST-SPEC §11.4] - [ ] FP-064 — Implement T11.4-5: `--text` expansion domain. [R2 #4; diff --git a/test/suite/registry/section-11.4.ts b/test/suite/registry/section-11.4.ts index c04cd0e..a209bbc 100644 --- a/test/suite/registry/section-11.4.ts +++ b/test/suite/registry/section-11.4.ts @@ -1,5 +1,5 @@ -// TEST-SPEC §11.4 (`xspec view`) — SUITE-54: T11.4-1 and T11.4-2 (T11.4-3 -// through T11.4-6 are planned follow-ups in this module). +// TEST-SPEC §11.4 (`xspec view`) — SUITE-54: T11.4-1 through T11.4-3 +// (T11.4-4 through T11.4-6 are planned follow-ups in this module). // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -105,33 +105,89 @@ // view's substance is pinned at identity level (root and child identity); // ranges, attributes, and interpreted values stay T11.4-1/-3's subject. // -// Certification (CERTIFICATIONS.md CONF-AVAIL): T11.4-1 is IN scope (the -// fixture family lands with the certification-manifest task), so the body -// obeys the scope's staging constraints exactly: a spec-only workspace of -// `.mdx` sources at valid-UTF-8 `#`-free paths; the ONE command it drives is -// the enumerated surface's bare whole-domain `view` — NO gate-reference -// `build` (the validity premise rides the answer's own findings member) — -// and NO snapshot compare (graph-data and refresh behavior are expressly out -// of CONF-AVAIL scope). Its fixtures stage NO undefined datum — every node -// identity defined under 11.2's chain conditions, the invalid-element arm -// keeping every spelled identity defined — so its answers carry the +// T11.4-3 — attributes and per-node data (SPEC 11.4, 11.2, 2.7). One +// workspace, two files, two invocations: +// +// - specs/attrs.mdx, staged via the running-offset builder: a +// five-attribute section tag `<S id="dup" id="dup" note="mystery" +// {...extras} tags>` — a repeated `id` (BOTH entries listed), an unknown +// prop, a spread attribute (its `name` structurally absent — the stated +// `null` — its source text the whole braced construct), and a valueless +// bare-name `tags` — and a second section `<S id="cov" +// coverage={"none"}>`. The bare `view` asserts every attribute entry +// `{name, range, text}` byte-exactly in tag order: inclusion is by form — +// a product omitting an invalid form from the listing (or folding the +// repeated pair to one entry) fails the exact attributes compare — while +// each invalidity is a located finding beside the view: exactly five +// 14.17 (repeated `id`; unknown prop; spread attribute; valueless `tags`; +// braced `coverage` — SPEC 2.7 assigns each), every finding located in +// specs/attrs.mdx (file granularity; range precision is T14-8's), and +// nothing else: no 14.1 (an invalid-form `id` is condition 17, never +// condition 1), no 14.16 (a spread attribute is an attribute form of a +// permitted section element, not an invalid construct), no 14.2/14.3 +// (`cov` and `ok` are unique and structurally conformant). +// - Per-node interpreted data ride the same tree compare, each datum +// observed in every legitimate state (the full definedness matrix is +// T11.2-2's home; this test carries each state once): identity — plain +// (`cov`, `ok`, every root) and unavailable (the repeated-`id` bearer +// spells none); tags — plain default `[]` (`cov`), plain `["solo"]` +// (`ok`), the roots' stated `null`, and unavailable (the valueless +// `tags`); coverage — plain default `"required"` (the five-attribute tag: +// `coverage` is absent there, and an absent prop defines the default +// whatever OTHER attributes the tag spells, SPEC 11.2), plain `"none"` +// (`ok`), the roots' stated `null`, and unavailable (the braced +// `coverage={"none"}` — quoted-static form required, 2.7). +// - specs/clean.mdx is finding-free (`<S id="ok" tags="solo" +// coverage="none">`); the second invocation names it as a `<file>` +// operand and asserts SPEC 11.4's root sentence sharply: a root's `tags` +// and `coverage` are structurally absent — the stated `null`, never the +// unavailability marker, NO finding and NO exit-1 consequence — so the +// finding-free domain exits 0 with them `null` (a product reading the +// structural absence as unavailability owes exit 1 per 11.2's +// any-unavailable-datum rule and fails the exit compare; the bare +// invocation exits 1 for the matrix file's findings and markers). +// +// Certification (CERTIFICATIONS.md CONF-AVAIL): T11.4-1 and T11.4-3 are IN +// scope (the fixture family lands with the certification-manifest task), so +// both bodies obey the scope's staging constraints exactly: spec-only +// workspaces of `.mdx` sources at valid-UTF-8 `#`-free paths; every command +// driven is drawn from the enumerated surface — T11.4-1's bare whole-domain +// `view`, T11.4-3's bare `view` plus one `<file>`-operand `view`, never +// `occurrences` or `at` — with NO gate-reference `build` (each answer's own +// findings member is the staging integrity) and NO snapshot compare +// (graph-data and refresh behavior are expressly out of CONF-AVAIL scope), +// and every staged condition drawn from the scope's stated set (T11.4-3 +// stages 14.17 alone). T11.4-1's fixtures stage NO undefined datum — every +// node identity defined under 11.2's chain conditions, the invalid-element +// arm keeping every spelled identity defined — so its answers carry the // unavailability marker nowhere: the marker-free ground // VIOL-AVAIL-NULLMARKER's passing side stands on (nothing undefined, so the // deviation touches nothing), while the stated `null`s the answers DO carry // (each root's `tags`/`coverage`; `closing` on self-closing sections; // `opening`/`closing` on roots) make the decode fail under VIOL-AVAIL-OMIT // exactly as certified (`null` is never omission — decodeViewReport rejects -// the absent members). The per-node `tags`/`coverage` VALUES and raw -// attribute entries stay outside this test's compare (T11.4-3's subject; the -// decode already enforces their presence and forms). T11.4-2 is NOT in -// scope: CERTIFICATIONS.md's Exclusions name the argument, spelling, and -// domain-and-exit matrices of the machine-interface surfaces (T11.2-5, -// T11.3-2/3, T11.4-2, T11.5-2) — certified representatively through the -// shared machinery — so unlike its sibling it is free to drive the -// gate-reference `build` and the snapshot compare. +// the absent members). T11.4-3 is the per-node unavailability carrier the +// document names: under VIOL-AVAIL-NULLMARKER its identity, tags, and +// coverage unavailability arms read `null` where the test asserts the +// marker literally (a `null` identity fails the form-exact decode outright; +// `null` tags/coverage fail the tree compare against the expected marker); +// under VIOL-AVAIL-OMIT every stated-`null` member its answers carry (each +// root's `tags`/`coverage`, every finding's `null` path, the spread entry's +// `null` name) is absent and the decode rejects the omission, the exit-0 +// operand arm asserting the root distinction directly; under +// VIOL-AVAIL-NOFILE it passes untouched — T11.4-3 drives `view` alone. +// T11.4-2 is NOT in scope: CERTIFICATIONS.md's Exclusions name the +// argument, spelling, and domain-and-exit matrices of the machine-interface +// surfaces (T11.2-5, T11.3-2/3, T11.4-2, T11.5-2) — certified +// representatively through the shared machinery — so unlike its siblings it +// is free to drive the gate-reference `build` and the snapshot compare. import { Buffer } from "node:buffer"; -import type { SourceRange, ViewNode } from "../../helpers/adapters/index.js"; +import type { + SourceRange, + ViewAttributeEntry, + ViewNode, +} from "../../helpers/adapters/index.js"; import { decodeViewReport } from "../../helpers/adapters/index.js"; import { fail, parseJsonStdout } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; @@ -154,8 +210,11 @@ import { /** * Running byte-offset fixture assembler (the T5.7-2/T11.2-1 discipline): - * `add` appends a segment and returns its byte range, so every expected - * offset is composed from the same parts the staged file is. + * `add` appends a segment and returns its byte range, and `attr` an + * attribute segment as the expected `{name, range, text}` view entry (SPEC + * 11.4: the source text is the attribute's own characters, so entry text = + * segment), so every expected offset is composed from the same parts the + * staged file is. */ class ByteFixture { private readonly parts: string[] = []; @@ -175,8 +234,15 @@ class ByteFixture { this.bytes += Buffer.byteLength(segment, "utf8"); return { start, end: this.bytes }; } + + attr(name: string | null, text: string): ViewAttributeEntry { + return { name, range: this.add(text), text }; + } } +/** The 12.7 unavailability marker, as decoded (one-datum state). */ +const UNAVAILABLE = { unavailable: true } as const; + /** * Fixture self-check (harness-side, before any product invocation): a * claimed byte range must slice the staged file's bytes to exactly the span @@ -194,7 +260,7 @@ function sliceCheck( .toString("utf8"); if (actual !== span) { fail( - `T11.4-1 fixture self-check — ${what}: the claimed byte range ` + + `§11.4 fixture self-check — ${what}: the claimed byte range ` + `[${String(range.start)}, ${String(range.end)}) slices the staged ` + `bytes to ${JSON.stringify(actual)}, expected ` + `${JSON.stringify(span)} (a harness-side staging error, not a ` + @@ -879,4 +945,361 @@ const T11_4_2 = defineProductTest({ }, }); -export const section114Tests: readonly ProductTestEntry[] = [T11_4_1, T11_4_2]; +// --- T11.4-3 — attributes and per-node data ----------------------------------- +// +// The staging ground (module header): specs/attrs.mdx carries the raw +// attribute matrix — the five-attribute tag and the braced-coverage tag, +// exactly five 14.17 — while specs/clean.mdx is finding-free with all three +// interpreted data plain. The multi-byte prose prefixes shift every later +// offset (SPEC 1.7: byte offsets, not code points or UTF-16 units). + +const ATTRS_FILE = "specs/attrs.mdx"; + +const AT = new ByteFixture(); +AT.add("Prélude — matrice d'attributs.\n\n"); +const AT_DUP_START = AT.pos; +AT.add("<S "); +const AT_DUP_ID1 = AT.attr("id", 'id="dup"'); +AT.add(" "); +const AT_DUP_ID2 = AT.attr("id", 'id="dup"'); +AT.add(" "); +const AT_NOTE = AT.attr("note", 'note="mystery"'); +AT.add(" "); +// The spread attribute (SPEC 2.7): `name` is structurally absent — the +// stated null — and the source text is its entire braced construct. +const AT_SPREAD = AT.attr(null, "{...extras}"); +AT.add(" "); +const AT_TAGS = AT.attr("tags", "tags"); +AT.add(">\nDup text.\n</S>"); +const AT_DUP_RANGE: SourceRange = { start: AT_DUP_START, end: AT.pos }; +AT.add("\n\n"); +const AT_COV_START = AT.pos; +AT.add("<S "); +const AT_COV_ID = AT.attr("id", 'id="cov"'); +AT.add(" "); +const AT_COV_COVERAGE = AT.attr("coverage", 'coverage={"none"}'); +AT.add(">\nCov text.\n</S>"); +const AT_COV_RANGE: SourceRange = { start: AT_COV_START, end: AT.pos }; +AT.add("\n"); +const ATTRS_SOURCE = AT.source; +const ATTRS_ROOT_RANGE: SourceRange = { start: 0, end: AT.pos }; + +const CLEAN_FILE = "specs/clean.mdx"; + +const CN = new ByteFixture(); +CN.add("Épilogue — sol sans finding.\n\n"); +const CN_OK_START = CN.pos; +CN.add("<S "); +const CN_OK_ID = CN.attr("id", 'id="ok"'); +CN.add(" "); +const CN_OK_TAGS = CN.attr("tags", 'tags="solo"'); +CN.add(" "); +const CN_OK_COVERAGE = CN.attr("coverage", 'coverage="none"'); +CN.add(">\nOk text.\n</S>"); +const CN_OK_RANGE: SourceRange = { start: CN_OK_START, end: CN.pos }; +CN.add("\n"); +const CLEAN_SOURCE = CN.source; +const CLEAN_ROOT_RANGE: SourceRange = { start: 0, end: CN.pos }; + +/** + * The answer's exact accompanying findings (SPEC 11.2, 14) — doubling as + * staging integrity (no `build` gate reference: CONF-AVAIL surface + * constraint, module header). One 14.17 per afflicted prop name per element + * (SPEC 2.7; T11.2-2's counting precedent): the repeated `id`, the unknown + * prop, the spread attribute, the valueless `tags`, the braced `coverage` — + * and nothing else (no 14.1, no 14.16, no 14.2/14.3; module header). + */ +const ATTRS_CONDITION_COUNTS: Readonly<Record<string, number>> = { + "14.17": 5, +}; + +/** + * T11.4-3's projection: the identity datum, the construct range, the raw + * attribute entries (`{name, range, text}` — this test's own subject), and + * the interpreted `tags`/`coverage` datums, per node. Tag-range + * decompositions stay outside (T11.4-1 byte-asserts them; the form-exact + * decode has already validated their presence and forms). + */ +interface AttributeDataShape { + readonly identity: ViewNode["identity"]; + readonly range: SourceRange; + readonly attributes: readonly ViewAttributeEntry[]; + readonly tags: ViewNode["tags"]; + readonly coverage: ViewNode["coverage"]; + readonly children: readonly AttributeDataShape[]; +} + +function projectAttributeData(node: ViewNode): AttributeDataShape { + return { + identity: node.identity, + range: node.range, + attributes: node.attributes.map((entry) => ({ + name: entry.name, + range: entry.range, + text: entry.text, + })), + tags: node.tags, + coverage: node.coverage, + children: node.children.map(projectAttributeData), + }; +} + +// The complete expected trees (document order). Each root: identity defined +// (the path is valid), attributes [], tags/coverage the stated +// structural-absence null (SPEC 11.4, 12.7) — never the marker. +const ATTRS_TREE: AttributeDataShape = { + identity: ATTRS_FILE, + range: ATTRS_ROOT_RANGE, + attributes: [], + tags: null, + coverage: null, + children: [ + { + // Repeated `id` spells no identity (SPEC 11.2) — explicitly + // unavailable, never a picked value; BOTH raw entries listed in tag + // order. `coverage` is absent on this tag, so its interpreted value + // is the plain default "required" (an absent prop defines the + // default whatever other attributes the tag spells), while the + // valueless `tags` leaves the interpreted tags unavailable. + identity: UNAVAILABLE, + range: AT_DUP_RANGE, + attributes: [AT_DUP_ID1, AT_DUP_ID2, AT_NOTE, AT_SPREAD, AT_TAGS], + tags: UNAVAILABLE, + coverage: "required", + children: [], + }, + { + // The braced `coverage={"none"}` is not quoted-static form (SPEC + // 2.7): interpreted coverage unavailable — never the braced value + // read through — while the identity stays defined (tags/coverage + // invalidity never undefines identity) and absent `tags` defines + // the plain default []. + identity: `${ATTRS_FILE}#cov`, + range: AT_COV_RANGE, + attributes: [AT_COV_ID, AT_COV_COVERAGE], + tags: [], + coverage: UNAVAILABLE, + children: [], + }, + ], +}; + +const CLEAN_TREE: AttributeDataShape = { + identity: CLEAN_FILE, + range: CLEAN_ROOT_RANGE, + attributes: [], + tags: null, + coverage: null, + children: [ + { + identity: `${CLEAN_FILE}#ok`, + range: CN_OK_RANGE, + attributes: [CN_OK_ID, CN_OK_TAGS, CN_OK_COVERAGE], + tags: ["solo"], + coverage: "none", + children: [], + }, + ], +}; + +const T11_4_3 = defineProductTest({ + id: "T11.4-3", + title: + 'raw attribute spellings as parsed, one entry per spelled attribute in tag order on the five-attribute tag `<S id="dup" id="dup" note="mystery" {...extras} tags>` — a repeated `id` (BOTH entries), an unknown prop, a spread attribute (its `name` structurally absent — the stated `null` — its source text the whole braced construct), a valueless bare-name `tags` — each entry\'s name, range, and source text byte-asserted against precomputed offsets behind a multi-byte prefix; inclusion is by form: every invalid form stays a listed entry, its invalidity a located finding beside the view, never a view omission — exactly five 14.17 (those four plus a braced `coverage={"none"}` on a second section), each located in the matrix file; per-node `identity`, `tags`, `coverage` each plain or explicitly unavailable per T11.2-2, every state carried once (identity unavailable on the repeated-`id` bearer; tags unavailable on the valueless `tags` beside its absent-prop default coverage "required"; coverage unavailable on the braced value beside its defined identity and default empty tags; all three plain in the sibling file); a root\'s `tags` and `coverage` are structurally absent — the stated `null`, never the unavailability marker, no finding and no exit-1 consequence: the finding-free specs/clean.mdx named as a `<file>` operand exits 0 with them `null`, the bare whole-domain view exiting 1 for the matrix file\'s findings and markers (SPEC 11.4, 11.2, 2.7, 12.7, 14; CERTIFICATIONS.md CONF-AVAIL in scope)', + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline) — composed-range arithmetic + // proven against the staged bytes before any product invocation. + for (const [entry, what] of [ + [AT_DUP_ID1, "the first repeated id spelling"], + [AT_DUP_ID2, "the second repeated id spelling"], + [AT_NOTE, "the unknown prop"], + [AT_SPREAD, "the spread attribute's whole braced construct"], + [AT_TAGS, "the valueless tags prop"], + [AT_COV_ID, "the cov id"], + [AT_COV_COVERAGE, "the braced coverage"], + ] as const) { + sliceCheck(ATTRS_SOURCE, entry.range, entry.text, what); + } + sliceCheck( + ATTRS_SOURCE, + AT_DUP_RANGE, + '<S id="dup" id="dup" note="mystery" {...extras} tags>\nDup text.\n</S>', + "the five-attribute construct", + ); + sliceCheck( + ATTRS_SOURCE, + AT_COV_RANGE, + '<S id="cov" coverage={"none"}>\nCov text.\n</S>', + "the braced-coverage construct", + ); + sliceCheck(ATTRS_SOURCE, ATTRS_ROOT_RANGE, ATTRS_SOURCE, "the matrix file"); + for (const [entry, what] of [ + [CN_OK_ID, "the ok id"], + [CN_OK_TAGS, "the ok tags"], + [CN_OK_COVERAGE, "the ok coverage"], + ] as const) { + sliceCheck(CLEAN_SOURCE, entry.range, entry.text, what); + } + sliceCheck( + CLEAN_SOURCE, + CN_OK_RANGE, + '<S id="ok" tags="solo" coverage="none">\nOk text.\n</S>', + "the clean construct", + ); + sliceCheck(CLEAN_SOURCE, CLEAN_ROOT_RANGE, CLEAN_SOURCE, "the clean file"); + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [ATTRS_FILE]: ATTRS_SOURCE, + [CLEAN_FILE]: CLEAN_SOURCE, + }, + }); + try { + // --- Invocation 1: the bare whole-domain `view` (CONF-AVAIL's + // enumerated surface; no gate-reference `build`, no snapshot + // compare). The answer carries the five 14.17 findings and the + // explicitly-unavailable datums, so exit 1 with the full document + // still emitted (SPEC 11.2). + const context = "T11.4-3 bare `view` (whole domain: attrs + clean)"; + const result = await expectExit( + product, + workspace, + ["view"], + 1, + `${context} — the answer carries the staged 14.17 findings and ` + + `explicitly-unavailable datums, so the invocation exits 1 with ` + + `the full document still emitted (SPEC 11.2, 11.4)`, + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form, ` + + `with or without --json (SPEC 11)`, + ), + { text: false }, + context, + ); + + // Staging integrity rides the answer itself (no `build` gate): + // exactly one 14.17 per afflicted prop name per element, nothing + // else — the invalidity is a located finding beside the view, never + // a view omission (SPEC 11.4, 2.7, 14). + assertConditionCounts( + report.findings, + ATTRS_CONDITION_COUNTS, + `${context}: exactly five 14.17 accompany — the repeated id, the ` + + `unknown prop, the spread attribute, the valueless tags, and ` + + `the braced coverage (SPEC 2.7, 14) — and nothing masked or ` + + `phantom reports: no 14.1 from the invalid-form id (condition ` + + `17, never condition 1), no 14.16 for the spread attribute (an ` + + `attribute form of a permitted section element, not an invalid ` + + `construct), no 14.2/14.3 (cov and ok are unique and conformant)`, + ); + for (const finding of report.findings) { + assertFindingLocated( + finding, + { file: ATTRS_FILE }, + `${context} — every 14.17 locates in the matrix file (file ` + + `granularity; range precision is T14-8's)`, + ); + } + + // The whole domain in path-byte order, then each per-file tree with + // its raw attribute entries and interpreted datums (module header). + assertSameJson( + report.views.map((view) => view.file), + [ATTRS_FILE, CLEAN_FILE], + `${context}: both discovered spec sources are viewed, in byte ` + + `order of workspace-relative path (SPEC 11.4, 12.7)`, + ); + assertSameJson( + projectAttributeData(report.views[0]!.root), + ATTRS_TREE, + `${context} — ${ATTRS_FILE}: raw attribute spellings as parsed, ` + + `one entry per spelled attribute in tag order — the repeated ` + + `id's BOTH entries, the unknown prop, the spread attribute ` + + `(name the stated null, text the whole braced construct), the ` + + `valueless bare-name tags — each with byte-exact range and ` + + `source text, none omitted for its invalidity (SPEC 11.4); ` + + `per-node identity/tags/coverage per 11.2: the repeated-id ` + + `bearer's identity and valueless-tags value explicitly ` + + `unavailable beside its absent-prop default coverage ` + + `"required", the braced-coverage value unavailable beside its ` + + `defined identity and default empty tags, and the root's ` + + `tags/coverage the stated null, never the marker (SPEC 12.7)`, + ); + assertSameJson( + projectAttributeData(report.views[1]!.root), + CLEAN_TREE, + `${context} — ${CLEAN_FILE}: the sibling file's section carries ` + + `all three interpreted data plain (identity "ok", tags ` + + `["solo"], coverage "none") with its three attribute entries ` + + `byte-exact, and the root's tags/coverage stay the stated null ` + + `(SPEC 11.4, 11.2, 12.7)`, + ); + [ATTRS_FILE, CLEAN_FILE].forEach((file, index) => { + const view = report.views[index]!; + assertSameJson( + [view.imports, view.occurrences, view.comments], + [[], [], []], + `${context} — ${file}: no import, reference spelling, or MDX ` + + `comment is staged — empty lists are [], never null (SPEC ` + + `11.4, 12.7)`, + ); + }); + + // --- Invocation 2: the finding-free file named as a `<file>` + // operand (SPEC 11.4's root sentence, sharply): the root's + // tags/coverage are structurally absent — the stated null, never + // the unavailability marker — with NO finding and NO exit-1 + // consequence, so the finding-free domain {clean} exits 0 with the + // full answer while the matrix file stays failing outside the + // domain (SPEC 11.2, 11.4, 12.7). + const cleanContext = + "T11.4-3 `view specs/clean.mdx` (the finding-free file as a " + + "`<file>` operand)"; + const cleanReport = decodeViewReport( + await runJson( + product, + workspace, + ["view", CLEAN_FILE], + `${cleanContext} — a finding-free file's view exits 0 with the ` + + `root's tags/coverage the stated null: structural absence ` + + `carries no finding and no exit-1 consequence, unlike an ` + + `explicitly-unavailable datum (SPEC 11.4, 11.2, 12.7)`, + ), + { text: false }, + cleanContext, + ); + assertSameJson( + cleanReport.findings, + [], + `${cleanContext}: the domain's one file is finding-free — the ` + + `matrix file's 14.17s are no domain file's findings — and a ` + + `root's stated-null tags/coverage contribute none (SPEC 11.2, ` + + `11.4)`, + ); + assertSameJson( + cleanReport.views.map((view) => view.file), + [CLEAN_FILE], + `${cleanContext}: one per-file view — the requested file (SPEC 11.4)`, + ); + assertSameJson( + projectAttributeData(cleanReport.views[0]!.root), + CLEAN_TREE, + `${cleanContext}: the same tree as the whole-domain answer — the ` + + `root's tags/coverage the stated null, never the unavailability ` + + `marker, on the exit-0 side too (SPEC 11.4, 12.7)`, + ); + } finally { + await workspace.dispose(); + } + }, +}); + +export const section114Tests: readonly ProductTestEntry[] = [ + T11_4_1, + T11_4_2, + T11_4_3, +]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 8b0b74c..4b547f0 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -310,6 +310,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.3-4": ["11.3"], "T11.4-1": ["11.4"], "T11.4-2": ["11.4"], + "T11.4-3": ["11.4"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From dde2e093ad328bbbd8b6264247fa5b3f6e4eab52 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 19:14:32 +0000 Subject: [PATCH 108/226] sdg(phase-9): implement T11.4-4's imports datum matrix (FP-063) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 44 +++- test/suite/registry/section-11.4.ts | 334 ++++++++++++++++++++++++++-- test/suite/registry/traceability.ts | 1 + 3 files changed, 365 insertions(+), 14 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 40a44c3..0804e5d 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2741,8 +2741,50 @@ certify against FP-091's fixtures once those land. {T11.4-3, T11.4-4} → {T11.4-4} (rest → FP-091; S-1's unmapped keys {11.5, 11.6, 12.6, 12.7} → stage G); S-5, S-7, and certification green.] -- [ ] FP-063 — Implement T11.4-4 (CONF-AVAIL): imports datum. [R2 #4, R3 +- [x] FP-063 — Implement T11.4-4 (CONF-AVAIL): imports datum. [R2 #4, R3 gap 1, VERIFY; TEST-SPEC §11.4] + [Done 2026-08-14: T11.4-4 registered in section-11.4.ts (wrapper + auto-declares; traceability `"T11.4-4": ["11.4"]` — T11.4-4 is in no + TEST-SPEC 14 staging record, the T11.4-3 precedent). One workspace, two + files, one bare `view`, inside CONF-AVAIL's staging constraints + (spec-only `.mdx` workspace, no `build` gate, no snapshot compare, + `view` alone; staged condition 14.15 within the scope's stated set): + specs/imports.mdx opens with the six-declaration matrix, one per line + (the §2.1 byte-window discipline), the valid first declaration's + multi-byte identifier `BÄSE` (Ä: 2 bytes) shifting every later byte + offset off code-point/UTF-16 counts; then the side-effect-only, + named-only (`{ part }`), and namespace-only (`* as ns`) forms each with + the same valid resolving specifier, the valid-form default import of the + undiscovered `./typo.xspec`, and the bare specifier `base.xspec` (a + suffix-keyed resolver bait). Asserted as ONE exact six-entry imports + compare: every declaration, valid and invalid, with its byte-exact range; + `name` the default binding's identifier ("BÄSE"/"TYPO"/"BARE") or the + stated null for the three no-default forms (never the marker — decode + rejects; never `part`/`ns`); `target` turning on specifier form and + discovery ALONE — the invalid binding forms carry the plain + "specs/base.mdx" (name null beside a defined target) while typo/bare are + the literal marker, never null (decode rejects — VIOL-AVAIL-NULLMARKER's + certified failure names exactly these two). Exactly five located 14.15 + beside the view (one per invalid declaration, each within its own + declaration's end-widened window, order decode-pinned), exit 1 with the + full answer; both root-only trees byte-asserted, prose-only + specs/base.mdx viewed with imports []. Verified: typecheck/format clean; + staging premise probed against the built product (`build --json` reports + exactly the five 14.15 in imports.mdx, old shape, at byte-exactly the + ByteFixture declaration ranges — the Ä shift visible); suite + red-as-diagnosed at the one invocation (`view` unknown command, exit 2 — + the same product gap as T11.4-1); scratch run (deleted) of the registered + body: workspace-deriving conforming fake green end-to-end, eight + deviation fakes each failing at the targeted assertion — nullmarker at + the decode's never-null target, omitname/omittags at the decode's + absent-member rejections, namedname/collapse at the imports compare, + dropfinding at the exact 14.15 count, cpranges at the finding-window + byte assertion, exit0 at the exit-1 arm. `npm run test:self`: planned + mid-loop reds narrowed 4 → 3 exactly as predicted — the + certification-document in-scope-registry gate went GREEN (gap {T11.4-4} + → {}), its two fixture-manifest gates stay red → FP-091; S-1's unmapped + keys exactly {11.5, 11.6, 12.6, 12.7} → stage G; S-5, S-7, and + certification green.] - [ ] FP-064 — Implement T11.4-5: `--text` expansion domain. [R2 #4; TEST-SPEC §11.4] - [ ] FP-065 — Implement T11.4-6: byte classification reproducing compiled diff --git a/test/suite/registry/section-11.4.ts b/test/suite/registry/section-11.4.ts index a209bbc..c00dc3a 100644 --- a/test/suite/registry/section-11.4.ts +++ b/test/suite/registry/section-11.4.ts @@ -1,5 +1,5 @@ -// TEST-SPEC §11.4 (`xspec view`) — SUITE-54: T11.4-1 through T11.4-3 -// (T11.4-4 through T11.4-6 are planned follow-ups in this module). +// TEST-SPEC §11.4 (`xspec view`) — SUITE-54: T11.4-1 through T11.4-4 +// (T11.4-5 and T11.4-6 are planned follow-ups in this module). // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -147,17 +147,62 @@ // any-unavailable-datum rule and fails the exit compare; the bare // invocation exits 1 for the matrix file's findings and markers). // -// Certification (CERTIFICATIONS.md CONF-AVAIL): T11.4-1 and T11.4-3 are IN -// scope (the fixture family lands with the certification-manifest task), so -// both bodies obey the scope's staging constraints exactly: spec-only -// workspaces of `.mdx` sources at valid-UTF-8 `#`-free paths; every command -// driven is drawn from the enumerated surface — T11.4-1's bare whole-domain -// `view`, T11.4-3's bare `view` plus one `<file>`-operand `view`, never -// `occurrences` or `at` — with NO gate-reference `build` (each answer's own -// findings member is the staging integrity) and NO snapshot compare -// (graph-data and refresh behavior are expressly out of CONF-AVAIL scope), -// and every staged condition drawn from the scope's stated set (T11.4-3 -// stages 14.17 alone). T11.4-1's fixtures stage NO undefined datum — every +// T11.4-4 — imports (SPEC 11.4, 11.2, 2.1). One workspace, two files, one +// bare `view`, the imports member asserted as ONE exact list: +// +// - specs/imports.mdx opens with the six-declaration matrix, one declaration +// per line at the very start of the file (the §2.1 staging discipline: +// each offending statement is its own byte window), composed by the +// running-offset builder: (1) a VALID single default binding +// `import BÄSE from "./base.xspec"` — the bound identifier is multi-byte +// (Ä: 2 bytes), so every later declaration's byte offset diverges from +// code-point and UTF-16 counts (SPEC 1.7); (2) the side-effect-only, (3) +// named-only (`{ part }`), and (4) namespace-only (`* as ns`) forms, each +// with the SAME valid resolving specifier; (5) a valid-form default import +// of the undiscovered `./typo.xspec`; (6) the bare specifier `base.xspec` +// — not beginning `./`, so specifier form defines no target even though a +// suffix-keyed resolver would land on the discovered specs/base.mdx. +// - Every declaration, valid and invalid, is listed with its range (SPEC +// 11.4): the exact six-entry compare fails a product that omits invalid +// declarations from the listing or misplaces a byte. +// - The binding-name datum is the DEFAULT binding's identifier: plain +// ("BÄSE", "TYPO", "BARE") where the declaration binds a default — +// validly or not — and the stated `null`, never the unavailability marker +// (the form-exact decode rejects a marker name outright), for the three +// no-default forms; `part` and `ns` are named-clause and namespace +// identifiers, never this datum (a product reporting either fails the +// `null` compare). +// - The resolved-target datum turns on specifier form and discovery ALONE, +// never on binding validity: the three invalid binding forms still carry +// the plain target "specs/base.mdx" (name `null` beside a defined target +// — the sharp cross-product cell against a product that marks every +// datum of an invalid import unavailable), while `./typo.xspec` +// (discovery defines none) and the bare specifier (form defines none) +// each carry `{"unavailable": true}` literally — never `null` (the +// decode rejects a `null` target outright). +// - Findings: exactly five 14.15 — one per invalid declaration, nothing +// else (staging integrity rides the answer itself; no gate-reference +// `build` — certification note below) — each located within its own +// declaration's end-widened byte window in specs/imports.mdx (equal +// codes order by locations, SPEC 12.7, so array position pins which +// finding is which); any finding or explicitly-unavailable datum means +// exit 1 with the full answer still emitted (SPEC 11.2). +// - specs/base.mdx (the import target: prose-only, finding-free) is viewed +// too: imports/occurrences/comments `[]`, both files' root-only trees +// byte-asserted, the roots' stated-null tags/coverage riding the decode. +// +// Certification (CERTIFICATIONS.md CONF-AVAIL): T11.4-1, T11.4-3, and +// T11.4-4 are IN scope (the fixture family lands with the +// certification-manifest task), so those bodies obey the scope's staging +// constraints exactly: spec-only workspaces of `.mdx` sources at valid-UTF-8 +// `#`-free paths, imports as the fixtures stage them; every command driven +// is drawn from the enumerated surface — T11.4-1's and T11.4-4's bare +// whole-domain `view`s, T11.4-3's bare `view` plus one `<file>`-operand +// `view`, never `occurrences` or `at` — with NO gate-reference `build` (each +// answer's own findings member is the staging integrity) and NO snapshot +// compare (graph-data and refresh behavior are expressly out of CONF-AVAIL +// scope), and every staged condition drawn from the scope's stated set +// (T11.4-3 stages 14.17 alone; T11.4-4 stages 14.15 alone). T11.4-1's fixtures stage NO undefined datum — every // node identity defined under 11.2's chain conditions, the invalid-element // arm keeping every spelled identity defined — so its answers carry the // unavailability marker nowhere: the marker-free ground @@ -176,6 +221,15 @@ // `null` name) is absent and the decode rejects the omission, the exit-0 // operand arm asserting the root distinction directly; under // VIOL-AVAIL-NOFILE it passes untouched — T11.4-3 drives `view` alone. +// T11.4-4 is the import-datum carrier VIOL-AVAIL-NULLMARKER's entry names: +// its two unresolved import targets (`./typo.xspec`, the bare specifier) +// read `null` under that deviation where the form-exact decode admits only +// a path value or the marker, so the decode itself rejects the answer; +// under VIOL-AVAIL-OMIT every stated-`null` member its answer carries (each +// root's `tags`/`coverage`, every finding's `null` path, the three +// no-default declarations' `null` name) is absent and the decode rejects +// the omission; under VIOL-AVAIL-NOFILE it passes untouched — T11.4-4 +// drives `view` alone. // T11.4-2 is NOT in scope: CERTIFICATIONS.md's Exclusions name the // argument, spelling, and domain-and-exit matrices of the machine-interface // surfaces (T11.2-5, T11.3-2/3, T11.4-2, T11.5-2) — certified @@ -186,6 +240,7 @@ import { Buffer } from "node:buffer"; import type { SourceRange, ViewAttributeEntry, + ViewImportEntry, ViewNode, } from "../../helpers/adapters/index.js"; import { decodeViewReport } from "../../helpers/adapters/index.js"; @@ -1298,8 +1353,261 @@ const T11_4_3 = defineProductTest({ }, }); +// --- T11.4-4 — imports ---------------------------------------------------------- +// +// The declaration matrix (module header): six imports, one per line, at the +// very start of specs/imports.mdx (the §2.1 staging discipline — every +// offending statement is its own byte window, and nothing precedes the first +// declaration), the valid first declaration's multi-byte bound identifier +// `BÄSE` (Ä: 2 bytes) shifting every later declaration's byte offset away +// from code-point and UTF-16 counts (SPEC 1.7). specs/base.mdx is the +// discovered, prose-only, finding-free import target; specs/typo.mdx exists +// nowhere. + +const IMPORTS_FILE = "specs/imports.mdx"; + +const IMPORT_TARGET_FILE = "specs/base.mdx"; +const IMPORT_TARGET_SOURCE = "Socle — cible d'import découverte.\n"; +const IMPORT_TARGET_ROOT_RANGE: SourceRange = { + start: 0, + end: Buffer.byteLength(IMPORT_TARGET_SOURCE, "utf8"), +}; + +const IMP = new ByteFixture(); +const IMP_VALID_TEXT = 'import BÄSE from "./base.xspec"'; +const IMP_VALID = IMP.add(IMP_VALID_TEXT); +IMP.add("\n"); +const IMP_SIDE_TEXT = 'import "./base.xspec"'; +const IMP_SIDE = IMP.add(IMP_SIDE_TEXT); +IMP.add("\n"); +const IMP_NAMED_TEXT = 'import { part } from "./base.xspec"'; +const IMP_NAMED = IMP.add(IMP_NAMED_TEXT); +IMP.add("\n"); +const IMP_NAMESPACE_TEXT = 'import * as ns from "./base.xspec"'; +const IMP_NAMESPACE = IMP.add(IMP_NAMESPACE_TEXT); +IMP.add("\n"); +const IMP_TYPO_TEXT = 'import TYPO from "./typo.xspec"'; +const IMP_TYPO = IMP.add(IMP_TYPO_TEXT); +IMP.add("\n"); +const IMP_BARE_TEXT = 'import BARE from "base.xspec"'; +const IMP_BARE = IMP.add(IMP_BARE_TEXT); +IMP.add("\n\nProse après les imports — aucun autre construct en scène.\n"); +const IMPORTS_SOURCE = IMP.source; +const IMPORTS_ROOT_RANGE: SourceRange = { start: 0, end: IMP.pos }; + +/** + * The complete expected imports member, in document order (SPEC 11.4, 12.7): + * every declaration, valid and invalid, listed with its byte-exact range; + * `name` the default binding's identifier — plain where the declaration + * binds a default, validly or not, and the stated `null` (never the + * unavailability marker, never a named-clause or namespace identifier) for + * the no-default forms; `target` the resolved file where specifier form and + * discovery define one, `{"unavailable": true}` otherwise — never `null`. + */ +const EXPECTED_IMPORT_ENTRIES: readonly ViewImportEntry[] = [ + { range: IMP_VALID, name: "BÄSE", target: IMPORT_TARGET_FILE }, + { range: IMP_SIDE, name: null, target: IMPORT_TARGET_FILE }, + { range: IMP_NAMED, name: null, target: IMPORT_TARGET_FILE }, + { range: IMP_NAMESPACE, name: null, target: IMPORT_TARGET_FILE }, + { range: IMP_TYPO, name: "TYPO", target: UNAVAILABLE }, + { range: IMP_BARE, name: "BARE", target: UNAVAILABLE }, +]; + +/** + * The five invalid declarations in document order — also the answer's + * findings order: the five 14.15 findings share one code, and equal codes + * order by locations (SPEC 12.7), so array position pins which finding is + * which. Each finding must fall within its own declaration's end-widened + * byte window (the §2.1/byteWindow discipline: one byte of slack for a + * line-granular location; the next declaration starts past the window). + */ +const INVALID_IMPORT_ARMS: readonly { + readonly what: string; + readonly range: SourceRange; +}[] = [ + { what: "the side-effect-only form", range: IMP_SIDE }, + { what: "the named-only form (`{ part }`)", range: IMP_NAMED }, + { what: "the namespace-only form (`* as ns`)", range: IMP_NAMESPACE }, + { what: "the undiscovered `./typo.xspec` target", range: IMP_TYPO }, + { what: "the bare specifier `base.xspec`", range: IMP_BARE }, +]; + +// Root-only expected trees (neither file stages a section): identity the +// defined plain string (valid paths), range the whole file, no +// decomposition. The roots' stated-null tags/coverage and the attributes [] +// ride the form-exact decode (T11.4-3 asserts the root distinction sharply). +const IMPORTS_TREE: TreeShape = { + identity: IMPORTS_FILE, + range: IMPORTS_ROOT_RANGE, + opening: null, + closing: null, + children: [], +}; + +const IMPORT_TARGET_TREE: TreeShape = { + identity: IMPORT_TARGET_FILE, + range: IMPORT_TARGET_ROOT_RANGE, + opening: null, + closing: null, + children: [], +}; + +const T11_4_4 = defineProductTest({ + id: "T11.4-4", + title: + 'every import declaration, valid and invalid, is listed in the view\'s imports member with its byte-exact range in document order — a valid default binding whose multi-byte identifier `BÄSE` shifts every later byte offset away from code-point and UTF-16 counts, the side-effect-only, named-only (`{ part }`), and namespace-only (`* as ns`) forms each with the same valid resolving specifier, a valid-form default import of the undiscovered `./typo.xspec`, and the bare specifier `base.xspec` — the binding-name datum the DEFAULT binding\'s identifier: plain ("BÄSE", "TYPO", "BARE") where a default is bound, validly or not, and the stated `null` for the three no-default forms, never the unavailability marker and never a named-clause or namespace identifier; the resolved-target datum turning on specifier form and discovery ALONE: the invalid binding forms still carry the plain target "specs/base.mdx" (name `null` beside a defined target) while `./typo.xspec` (discovery defines none) and the bare specifier (form defines none — a suffix-keyed resolver notwithstanding) are each `{"unavailable": true}` literally, never `null`; each invalidity a located 14.15 finding beside the view — exactly five, one per invalid declaration, each within its own declaration\'s end-widened byte window — and any finding or explicitly-unavailable datum means exit 1 with the full answer still emitted (SPEC 11.4, 11.2, 2.1, 1.7, 12.7, 14; CERTIFICATIONS.md CONF-AVAIL in scope)', + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline) — composed-range arithmetic + // proven against the staged bytes before any product invocation. + for (const [range, span, what] of [ + [IMP_VALID, IMP_VALID_TEXT, "the valid default import"], + [IMP_SIDE, IMP_SIDE_TEXT, "the side-effect-only import"], + [IMP_NAMED, IMP_NAMED_TEXT, "the named-only import"], + [IMP_NAMESPACE, IMP_NAMESPACE_TEXT, "the namespace-only import"], + [IMP_TYPO, IMP_TYPO_TEXT, "the undiscovered-target import"], + [IMP_BARE, IMP_BARE_TEXT, "the bare-specifier import"], + ] as const) { + sliceCheck(IMPORTS_SOURCE, range, span, what); + } + sliceCheck( + IMPORTS_SOURCE, + IMPORTS_ROOT_RANGE, + IMPORTS_SOURCE, + "the imports file", + ); + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [IMPORT_TARGET_FILE]: IMPORT_TARGET_SOURCE, + [IMPORTS_FILE]: IMPORTS_SOURCE, + }, + }); + try { + // The one invocation (CONF-AVAIL's enumerated surface: no + // gate-reference `build`, no snapshot compare): the bare whole-domain + // `view`. The answer carries the five 14.15 findings and the two + // explicitly-unavailable targets, so exit 1 with the full document + // still emitted (SPEC 11.2). + const context = "T11.4-4 bare `view` (whole domain: base + imports)"; + const result = await expectExit( + product, + workspace, + ["view"], + 1, + `${context} — the answer carries the five staged 14.15 findings ` + + `and two explicitly-unavailable import targets, so the ` + + `invocation exits 1 with the full document still emitted (SPEC ` + + `11.2, 11.4)`, + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form, ` + + `with or without --json (SPEC 11)`, + ), + { text: false }, + context, + ); + + // Staging integrity rides the answer itself (no `build` gate): + // exactly one 14.15 per invalid declaration, nothing else — the + // valid default import is finding-free (an unused binding is valid, + // SPEC 2.1), no binding collision is staged (five distinct + // identifiers), and neither file spells a section (SPEC 11.4, 14). + assertConditionCounts( + report.findings, + { "14.15": 5 }, + `${context}: exactly five 14.15 accompany — the side-effect-only, ` + + `named-only, and namespace-only binding forms, the undiscovered ` + + `./typo.xspec target, and the bare specifier (SPEC 2.1, 14) — ` + + `and nothing else: the valid default import contributes none, ` + + `and no other condition is staged`, + ); + report.findings.forEach((finding, index) => { + const arm = INVALID_IMPORT_ARMS[index]!; + assertFindingLocated( + finding, + { + file: IMPORTS_FILE, + window: { start: arm.range.start, end: arm.range.end + 1 }, + }, + `${context} — the 14.15 for ${arm.what} locates within that ` + + `declaration's own byte window in specs/imports.mdx (equal ` + + `codes order by locations, so findings arrive in declaration ` + + `order; SPEC 14, 12.7)`, + ); + }); + + // Both discovered spec sources are viewed, in byte order of + // workspace-relative path ("specs/base.mdx" < "specs/imports.mdx"). + assertSameJson( + report.views.map((view) => view.file), + [IMPORT_TARGET_FILE, IMPORTS_FILE], + `${context}: both discovered spec sources are viewed, in byte ` + + `order of workspace-relative path (SPEC 11.4, 12.7)`, + ); + const targetView = report.views[0]!; + const importsView = report.views[1]!; + + // The subject compare: the imports member is exactly the six-entry + // list — every declaration, valid and invalid, with its byte-exact + // range, the binding-name datum plain or the stated null, and the + // resolved-target datum plain or the literal unavailability marker + // (SPEC 11.4, 11.2, 12.7; module header). + assertSameJson( + importsView.imports, + EXPECTED_IMPORT_ENTRIES, + `${context} — ${IMPORTS_FILE}: every import declaration, valid ` + + `and invalid, listed with its range in document order; name the ` + + `default binding's identifier ("BÄSE"/"TYPO"/"BARE") or the ` + + `stated null for the side-effect-only, named-only, and ` + + `namespace-only forms — never the marker, never part/ns; target ` + + `the resolved specs/base.mdx wherever specifier form and ` + + `discovery define one — binding validity notwithstanding — and ` + + `the literal unavailability marker for ./typo.xspec and the ` + + `bare specifier, never null (SPEC 11.4, 11.2, 2.1, 12.7)`, + ); + + // The rest of each per-file view: root-only trees byte-asserted; + // nothing else staged, so occurrences/comments (and the target's + // imports) are [] — empty lists are [], never null (SPEC 12.7). + assertSameJson( + projectShape(importsView.root), + IMPORTS_TREE, + `${context} — ${IMPORTS_FILE}: a section-less file's view is the ` + + `root alone, its identity the defined plain string, its range ` + + `the whole file (SPEC 11.4, 11.2, 1.7)`, + ); + assertSameJson( + [importsView.occurrences, importsView.comments], + [[], []], + `${context} — ${IMPORTS_FILE}: no reference spelling or MDX ` + + `comment is staged — empty lists are [], never null (SPEC 11.4, ` + + `12.7)`, + ); + assertSameJson( + projectShape(targetView.root), + IMPORT_TARGET_TREE, + `${context} — ${IMPORT_TARGET_FILE}: the prose-only import ` + + `target's view is the root alone (SPEC 11.4, 1.7)`, + ); + assertSameJson( + [targetView.imports, targetView.occurrences, targetView.comments], + [[], [], []], + `${context} — ${IMPORT_TARGET_FILE}: no import, reference ` + + `spelling, or MDX comment is staged — empty lists are [], never ` + + `null (SPEC 11.4, 12.7)`, + ); + } finally { + await workspace.dispose(); + } + }, +}); + export const section114Tests: readonly ProductTestEntry[] = [ T11_4_1, T11_4_2, T11_4_3, + T11_4_4, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 4b547f0..ac10e4b 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -311,6 +311,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.4-1": ["11.4"], "T11.4-2": ["11.4"], "T11.4-3": ["11.4"], + "T11.4-4": ["11.4"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From f964c21216f10f353efbfcf3cf49e909b3bf32df Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 19:42:46 +0000 Subject: [PATCH 109/226] sdg(phase-9): implement T11.4-5's --text expansion domain (FP-064) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 45 +- test/suite/registry/section-11.4.ts | 1118 ++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 1156 insertions(+), 8 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 0804e5d..c7a2f38 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2785,8 +2785,51 @@ certify against FP-091's fixtures once those land. → {}), its two fixture-manifest gates stay red → FP-091; S-1's unmapped keys exactly {11.5, 11.6, 12.6, 12.7} → stage G; S-5, S-7, and certification green.] -- [ ] FP-064 — Implement T11.4-5: `--text` expansion domain. [R2 #4; +- [x] FP-064 — Implement T11.4-5: `--text` expansion domain. [R2 #4; TEST-SPEC §11.4] + [Done 2026-08-14: T11.4-5 registered in section-11.4.ts (wrapper + auto-declares; traceability `"T11.4-5": ["11.4"]` — in no TEST-SPEC 14 + staging record, the T11.4-3/-4 precedent; NOT in CONF-AVAIL scope — the + Exclusions name its consultation-domain negatives — so unlike its + siblings it drives a `build --json` staging gate per workspace, the + T11.4-2 precedent). Four workspaces, six views: (1) the chain A→B→C with + X beyond the boundary — A embeds B#b, B holds an unresolved `d={"ghost"}` + (14.5) and embeds C#c, C's `{text(X.dup)}` names X's duplicate pair + (gate-proven staged) so the spelling records no occurrence (14.6, located + exactly at the braced container) and X is never consulted: `view + specs/A.mdx --text` carries exactly {14.5, 14.6} — deep findings in + consulted-never-requested files, X's 14.3 accompanying NOTHING — views + [A] alone with alpha poisoned, the embedding-free sibling and root own + text byte-exact, the resolved embedding's record and import entry + pinned; without `--text` the same request consults A alone — findings + [], exit 0, tree/imports/occurrences flag-independent (the decode + rejects text members absent the flag); (2) entry→loop where loop#l1 + self-embeds — the one 14.9 located at the participating container in + consulted-never-requested loop.mdx, entry's reaching values poisoned; + (3) main→gone (unparseable): the spelling into the masked file records + nothing (occurrences []), `view specs/main.mdx --text` carries exactly + main's own 14.6 — never the 14.20 — while requesting gone too attaches + the 14.20 and gone still contributes NO view (views stay [main]), the + import entry's target the plain path both times (discovery, not + parseability); (4) `specs/vi#ew.mdx` requested as a bare operand (`#` + has no delimiter role, 12.0) keeps its full view — every identity the + marker, text values plain and byte-exact (expansion definedness turns on + occurrence-recording spellings alone), the 14.19 with locations [] and + the file as concerned path. Verified: typecheck/format clean; every + hand-derived text constant and byte range probed byte-identical against + the built product on passing twins (`query node` own/subtree text; + scratch probe, deleted), and every staged finding multiset probed + against the failing stagings — the current product picks a winner among + X's duplicate bearers (no 14.6), reports one-location 14.3, a wide 14.9 + location, a narrow 14.6 range, and old-shape findings, all diagnosed + product gaps the certified T11.2-4 semantics pin; suite red-as-diagnosed + at the chain gate's form-exact findings decode ("expected no member + \"condition\"" — the FP-001-class gap; the `view` surface itself is the + T11.4-1-class unknown-command gap behind it), section-11.4 now 5 failed + / 0 passed. `npm run test:self`: unchanged 3 planned mid-loop reds + (certification-document fixture-manifest ×2 → FP-091; S-1's unmapped + keys exactly {11.5, 11.6, 12.6, 12.7} → stage G); S-5, S-7, and + certification green.] - [ ] FP-065 — Implement T11.4-6: byte classification reproducing compiled Markdown via the P-2 oracle (`test/helpers/oracles/markdown.ts`). [R2 #4; TEST-SPEC §11.4] diff --git a/test/suite/registry/section-11.4.ts b/test/suite/registry/section-11.4.ts index c00dc3a..df8e0c0 100644 --- a/test/suite/registry/section-11.4.ts +++ b/test/suite/registry/section-11.4.ts @@ -1,5 +1,5 @@ -// TEST-SPEC §11.4 (`xspec view`) — SUITE-54: T11.4-1 through T11.4-4 -// (T11.4-5 and T11.4-6 are planned follow-ups in this module). +// TEST-SPEC §11.4 (`xspec view`) — SUITE-54: T11.4-1 through T11.4-5 +// (T11.4-6 is the planned follow-up in this module). // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -191,6 +191,59 @@ // too: imports/occurrences/comments `[]`, both files' root-only trees // byte-asserted, the roots' stated-null tags/coverage riding the decode. // +// T11.4-5 — `--text` and the expansion domain (SPEC 11.4, 11.2, 1.6, 3, +// 12.0). Four workspaces, each staged failing on purpose and pinned by a +// `build --json` gate (T11.4-5 is NOT in CONF-AVAIL scope — certification +// note below — so the gate-reference build is free), then observed through +// operand-requested views: +// +// - The chain (A → B → C, X beyond the boundary): A imports B and embeds +// B#b; B holds its own unresolved `d={"ghost"}` (14.5) and embeds C#c; C +// imports X and holds the boundary spelling `{text(X.dup)}` — X spells +// `dup` twice, every bearer undefined (SPEC 11.2), so the reference +// records no occurrence (14.6) and X is NEVER consulted: the consulted +// domain is the requested files plus exactly the files of resolved +// targets reachable through occurrence-RECORDING embeddings (SPEC 11.4). +// `view specs/A.mdx --text`: the domain is {A, B, C} — exactly B's 14.5 +// and C's 14.6 accompany (deep findings in consulted files never +// requested) while X's 14.3, proven staged by the gate, accompanies +// NOTHING (a product picking a winner among duplicate bearers, or +// consulting import targets rather than resolved-embedding targets, +// carries it and fails the exact multiset); A's view alone is served — +// alpha poisoned (the boundary lies two hops down), the embedding-free +// sibling and the root's own text defined and byte-exact per the rules of +// 3. Without `--text`, the same request consults A alone: findings `[]`, +// exit 0 — A itself is finding-free, so the exit follows A's own findings +// while B/C/X stay failing (a product consulting embedded targets without +// `--text`, or reporting whole-workspace findings, fails both compares). +// - The cycle: entry.mdx embeds loop.mdx#l1, whose `{text("l1")}` re-enters +// itself — the length-one embedding cycle (SPEC 5.3, 14.9), one finding, +// one location: the participating container in loop.mdx. `view +// specs/entry.mdx --text`: the cycle participant is consulted — the +// entry's embedding resolves and records, whether or not any expansion +// completes (SPEC 11.4) — so the 14.9 accompanies from a consulted file +// never requested; start and the root's subtree text are poisoned, the +// root's own text defined. +// - The masked file: main.mdx imports gone.xspec (valid — discovery, not +// parseability, defines designation, SPEC 2.1) and embeds GONE.g, but +// gone.mdx is unparseable (14.20): a masked file's sections spell no +// defined identity, so the spelling records NO occurrence (main's +// occurrence list is `[]`) and gone is never consulted by expansion — +// `view specs/main.mdx --text` carries exactly main's own 14.6 (located +// exactly at the braced container), never the 14.20. Requesting gone too +// (`view specs/main.mdx specs/gone.mdx --text`) attaches the 14.20 — its +// parse-failure finding accompanies only when itself requested — and gone +// still contributes NO view: the views list stays [main]. The import +// entry's target is the plain "specs/gone.mdx" both times. +// - The invalid path: `specs/vi#ew.mdx` is discovered and parseable; a bare +// `<file>` operand is a whole path with no delimiter role for `#` (SPEC +// 12.0), so requesting it serves its full view: every identity — root +// included — explicitly unavailable (no identity over an invalid path, +// SPEC 11.2) while its text values are plain and byte-exact (expansion +// definedness turns on occurrence-recording spellings alone — the file +// holds none — never on identity definedness), the 14.19 accompanying +// with no locations and the file as concerned path, exit 1. +// // Certification (CERTIFICATIONS.md CONF-AVAIL): T11.4-1, T11.4-3, and // T11.4-4 are IN scope (the fixture family lands with the // certification-manifest task), so those bodies obey the scope's staging @@ -230,14 +283,17 @@ // no-default declarations' `null` name) is absent and the decode rejects // the omission; under VIOL-AVAIL-NOFILE it passes untouched — T11.4-4 // drives `view` alone. -// T11.4-2 is NOT in scope: CERTIFICATIONS.md's Exclusions name the -// argument, spelling, and domain-and-exit matrices of the machine-interface -// surfaces (T11.2-5, T11.3-2/3, T11.4-2, T11.5-2) — certified -// representatively through the shared machinery — so unlike its siblings it -// is free to drive the gate-reference `build` and the snapshot compare. +// T11.4-2 and T11.4-5 are NOT in scope: CERTIFICATIONS.md's Exclusions name +// the argument, spelling, and domain-and-exit matrices of the +// machine-interface surfaces (T11.2-5, T11.3-2/3, T11.4-2, T11.5-2) and +// T11.4-5's consultation-domain negatives — certified representatively +// through the shared machinery — so unlike their siblings they are free to +// drive the gate-reference `build` and the snapshot compare. import { Buffer } from "node:buffer"; import type { + Finding, + OccurrenceRecord, SourceRange, ViewAttributeEntry, ViewImportEntry, @@ -1605,9 +1661,1057 @@ const T11_4_4 = defineProductTest({ }, }); +// --- T11.4-5 — `--text` and the expansion domain ------------------------------ +// +// Module header holds the narrative; the constants below stage the four +// workspaces with the running-offset builder so every expected offset and +// every expected text value is composed from the same parts the staged files +// are (expected own/subtree text hand-derived per the rules of 3, the +// T11.2-4 discipline: the import line and every tag-only line are left empty +// purely by removals and drop WITH their terminators — a straddling +// closing-tag line's drop eats the enclosing contribution's terminator — +// while originally-blank lines stay). + +/** + * The projection T11.4-5 pins per node under `--text`: the identity datum, + * the construct range (1.7), and the own/subtree text datums — each a + * byte-exact string or the unavailability marker (T11.2-4's matrix) — plus + * tree shape. Attribute entries and interpreted tags/coverage stay at their + * home tests (T11.4-1/-3); the form-exact decode has validated their forms. + */ +interface TextTreeShape { + readonly identity: ViewNode["identity"]; + readonly range: SourceRange; + readonly ownText: string | { readonly unavailable: true }; + readonly subtreeText: string | { readonly unavailable: true }; + readonly children: readonly TextTreeShape[]; +} + +function projectTextShape(node: ViewNode): TextTreeShape { + return { + identity: node.identity, + range: node.range, + ownText: node.ownText!, + subtreeText: node.subtreeText!, + children: node.children.map(projectTextShape), + }; +} + +/** An offending construct's byte window: its range, end-widened by one. */ +function widened(range: SourceRange): { start: number; end: number } { + return { start: range.start, end: range.end + 1 }; +} + +/** The one finding of a condition — counts asserted beforehand. */ +function findingByCondition( + findings: readonly Finding[], + condition: string, + context: string, +): Finding { + const matches = findings.filter((finding) => finding.condition === condition); + if (matches.length !== 1) { + fail( + `${context}: expected exactly one ${condition} finding, got ` + + `${String(matches.length)}`, + ); + } + return matches[0]!; +} + +/** A window check for one located finding (SPEC 14 location cardinality). */ +interface LocatedWindow { + readonly file: string; + readonly window: { readonly start: number; readonly end: number }; +} + +/** + * Assert a located finding's concern: `path` null (a located condition, SPEC + * 12.7), exactly one location per offending construct (SPEC 14's cardinality + * rule), each — in 12.7 location order, which the decode has already + * enforced — lying in its expected file with its range inside the offending + * construct's byte window. + */ +function assertFindingWindows( + finding: Finding, + expected: readonly LocatedWindow[], + context: string, +): void { + assertSameJson( + finding.path, + null, + `${context} — a located condition's concerned path is null (SPEC 12.7)`, + ); + if (finding.locations.length !== expected.length) { + fail( + `${context}: expected exactly ${String(expected.length)} location(s) — ` + + `one per offending construct (SPEC 14) — got ` + + `${String(finding.locations.length)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + expected.forEach((want, index) => { + const location = finding.locations[index]!; + if (location.file !== want.file) { + fail( + `${context}: location ${String(index)} must lie in ` + + `${JSON.stringify(want.file)}, got ` + + `${JSON.stringify(location.file)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + if ( + location.range.start < want.window.start || + location.range.end > want.window.end + ) { + fail( + `${context}: location ${String(index)} ` + + `[${String(location.range.start)}, ${String(location.range.end)}) ` + + `must fall within the offending construct's byte window ` + + `[${String(want.window.start)}, ${String(want.window.end)}] ` + + `(message: ${JSON.stringify(finding.message)})`, + ); + } + }); +} + +/** + * Assert a non-recording MDX embedding spelling's finding exactly: stable + * code `unknown-text-target`, ONE location whose range is EXACTLY the full + * braced container — the span its occurrence would occupy (SPEC 14, 5.7) — + * `path` null. + */ +function assertUnresolvedEmbedding( + finding: Finding, + expected: { readonly file: string; readonly range: SourceRange }, + context: string, +): void { + assertSameJson( + { code: finding.code, locations: finding.locations, path: finding.path }, + { + code: "unknown-text-target", + locations: [{ file: expected.file, range: expected.range }], + path: null, + }, + `${context} — the non-recording embedding spelling is located by its ` + + `finding: stable code unknown-text-target, its one location's range ` + + `EXACTLY the full braced container — the span its occurrence would ` + + `occupy (SPEC 14, 5.7, 12.7)`, + ); +} + +// --- the chain workspace: A → B → C, X beyond the boundary -------------------- + +const XDA_FILE = "specs/A.mdx"; +const XDA = new ByteFixture(); +XDA.add("Ärm — the requested head.\n\n"); +const XDA_IMPORT_TEXT = 'import B from "./B.xspec"'; +const XDA_IMPORT_RANGE = XDA.add(XDA_IMPORT_TEXT); +XDA.add("\n\n"); +const XDA_ALPHA_START = XDA.pos; +XDA.add('<S id="alpha">\nAlpha head.\n\n'); +const XDA_EMBED_TEXT = "{text(B.b)}"; +const XDA_EMBED_RANGE = XDA.add(XDA_EMBED_TEXT); +XDA.add("\n</S>"); +const XDA_ALPHA_RANGE: SourceRange = { start: XDA_ALPHA_START, end: XDA.pos }; +XDA.add("\n\n"); +const XDA_PLAIN_START = XDA.pos; +XDA.add('<S id="plain">\nPlain line.\n</S>'); +const XDA_PLAIN_RANGE: SourceRange = { start: XDA_PLAIN_START, end: XDA.pos }; +XDA.add("\n"); +const XDA_SOURCE = XDA.source; +const XDA_ROOT_RANGE: SourceRange = { start: 0, end: XDA.pos }; + +const XDB_FILE = "specs/B.mdx"; +const XDB = new ByteFixture(); +XDB.add("Bäck — first hop, own finding.\n\n"); +XDB.add('import C from "./C.xspec"'); +XDB.add("\n\n"); +const XDB_B_START = XDB.pos; +XDB.add('<S id="b" d={"ghost"}>'); +const XDB_B_OPEN_END = XDB.pos; +XDB.add("\nB head.\n\n{text(C.c)}\n</S>\n"); +const XDB_SOURCE = XDB.source; + +const XDC_FILE = "specs/C.mdx"; +const XDC = new ByteFixture(); +XDC.add("Çay — second hop, the boundary.\n\n"); +XDC.add('import X from "./X.xspec"'); +XDC.add("\n\n"); +XDC.add('<S id="c">\nC head.\n\n'); +const XDC_BOUNDARY_TEXT = "{text(X.dup)}"; +const XDC_BOUNDARY_RANGE = XDC.add(XDC_BOUNDARY_TEXT); +XDC.add("\n</S>\n"); +const XDC_SOURCE = XDC.source; + +const XDX_FILE = "specs/X.mdx"; +const XDX = new ByteFixture(); +XDX.add("Xîlo — never consulted.\n\n"); +const XDX_DUP1_START = XDX.pos; +XDX.add('<S id="dup">\nFirst twin.\n</S>'); +const XDX_DUP1_RANGE: SourceRange = { start: XDX_DUP1_START, end: XDX.pos }; +XDX.add("\n\n"); +const XDX_DUP2_START = XDX.pos; +XDX.add('<S id="dup">\nSecond twin.\n</S>'); +const XDX_DUP2_RANGE: SourceRange = { start: XDX_DUP2_START, end: XDX.pos }; +XDX.add("\n"); +const XDX_SOURCE = XDX.source; + +// The chain workspace's COMPLETE findings multiset — the gate's staging +// premise: X's duplicate pair (one 14.3 locating both bearers), B's +// unresolved `d` (14.5), C's non-recording boundary spelling (14.6). A is +// finding-free (the no-`--text` arm's ground). +const XD_WORKSPACE_CONDITIONS: Readonly<Record<string, number>> = { + "14.3": 1, + "14.5": 1, + "14.6": 1, +}; + +// A's expected text values (rules of 3): the root's own text is defined — +// title line + its blank + the dropped import line's blank successor + the +// between-construct blank (each closing-tag line's drop eats the root's +// terminator) — while alpha (holding the embedding whose expansion reaches +// the boundary two hops down) and the root's subtree text are poisoned. +const XDA_ROOT_OWN = "Ärm — the requested head.\n\n\n\n"; +const XDA_PLAIN_TEXT = "Plain line.\n"; + +const XDA_TEXT_TREE: TextTreeShape = { + identity: XDA_FILE, + range: XDA_ROOT_RANGE, + ownText: XDA_ROOT_OWN, + subtreeText: UNAVAILABLE, + children: [ + { + identity: `${XDA_FILE}#alpha`, + range: XDA_ALPHA_RANGE, + ownText: UNAVAILABLE, + subtreeText: UNAVAILABLE, + children: [], + }, + { + identity: `${XDA_FILE}#plain`, + range: XDA_PLAIN_RANGE, + ownText: XDA_PLAIN_TEXT, + subtreeText: XDA_PLAIN_TEXT, + children: [], + }, + ], +}; + +const XDA_IDENTITY_TREE: IdentityShape = { + identity: XDA_FILE, + children: [ + { identity: `${XDA_FILE}#alpha`, children: [] }, + { identity: `${XDA_FILE}#plain`, children: [] }, + ], +}; + +const XDA_IMPORTS: readonly ViewImportEntry[] = [ + { range: XDA_IMPORT_RANGE, name: "B", target: XDB_FILE }, +]; +// A's one embedding resolves (b's identity is defined) and records — with +// and without `--text` alike: resolution is never flag-dependent. +const XDA_OCCURRENCES: readonly OccurrenceRecord[] = [ + { + file: XDA_FILE, + range: XDA_EMBED_RANGE, + kind: "embeds", + source: { identity: `${XDA_FILE}#alpha`, range: XDA_ALPHA_RANGE }, + target: `${XDB_FILE}#b`, + }, +]; + +// --- the cycle workspace: entry → loop, loop self-embeds ---------------------- + +const CYE_FILE = "specs/entry.mdx"; +const CYE = new ByteFixture(); +CYE.add("Öse — the cycle's entry.\n\n"); +const CYE_IMPORT_TEXT = 'import LOOP from "./loop.xspec"'; +const CYE_IMPORT_RANGE = CYE.add(CYE_IMPORT_TEXT); +CYE.add("\n\n"); +const CYE_START_START = CYE.pos; +CYE.add('<S id="start">\nStart head.\n\n'); +const CYE_EMBED_TEXT = "{text(LOOP.l1)}"; +const CYE_EMBED_RANGE = CYE.add(CYE_EMBED_TEXT); +CYE.add("\n</S>"); +const CYE_START_RANGE: SourceRange = { start: CYE_START_START, end: CYE.pos }; +CYE.add("\n"); +const CYE_SOURCE = CYE.source; +const CYE_ROOT_RANGE: SourceRange = { start: 0, end: CYE.pos }; + +const CYL_FILE = "specs/loop.mdx"; +const CYL = new ByteFixture(); +CYL.add("Løkke — the self-embedding participant.\n\n"); +CYL.add('<S id="l1">\nLoop head.\n\n'); +const CYL_SELF_TEXT = '{text("l1")}'; +const CYL_SELF_RANGE = CYL.add(CYL_SELF_TEXT); +CYL.add("\n</S>\n"); +const CYL_SOURCE = CYL.source; + +// entry's root own text: title + its blank + the dropped import line's blank +// successor; nothing after the one section (its closing-tag line's drop eats +// the root's terminator). +const CYE_ROOT_OWN = "Öse — the cycle's entry.\n\n\n"; + +const CYE_TEXT_TREE: TextTreeShape = { + identity: CYE_FILE, + range: CYE_ROOT_RANGE, + ownText: CYE_ROOT_OWN, + subtreeText: UNAVAILABLE, + children: [ + { + identity: `${CYE_FILE}#start`, + range: CYE_START_RANGE, + ownText: UNAVAILABLE, + subtreeText: UNAVAILABLE, + children: [], + }, + ], +}; + +const CYE_IMPORTS: readonly ViewImportEntry[] = [ + { range: CYE_IMPORT_RANGE, name: "LOOP", target: CYL_FILE }, +]; +const CYE_OCCURRENCES: readonly OccurrenceRecord[] = [ + { + file: CYE_FILE, + range: CYE_EMBED_RANGE, + kind: "embeds", + source: { identity: `${CYE_FILE}#start`, range: CYE_START_RANGE }, + target: `${CYL_FILE}#l1`, + }, +]; + +// --- the masked workspace: main → gone (unparseable) -------------------------- + +const MKM_FILE = "specs/main.mdx"; +const MKM = new ByteFixture(); +MKM.add("Måne — the masked target's requester.\n\n"); +const MKM_IMPORT_TEXT = 'import GONE from "./gone.xspec"'; +const MKM_IMPORT_RANGE = MKM.add(MKM_IMPORT_TEXT); +MKM.add("\n\n"); +const MKM_M_START = MKM.pos; +MKM.add('<S id="m">\nMain head.\n\n'); +const MKM_EMBED_TEXT = "{text(GONE.g)}"; +const MKM_EMBED_RANGE = MKM.add(MKM_EMBED_TEXT); +MKM.add("\n</S>"); +const MKM_M_RANGE: SourceRange = { start: MKM_M_START, end: MKM.pos }; +MKM.add("\n"); +const MKM_SOURCE = MKM.source; +const MKM_ROOT_RANGE: SourceRange = { start: 0, end: MKM.pos }; + +const MK_GONE_FILE = "specs/gone.mdx"; +// Unparseable MDX (14.20): an unclosed section tag (the T11.2-1 staging). +const MK_GONE_SOURCE = '<S id="g">\nNever closed.\n'; + +const MKM_ROOT_OWN = "Måne — the masked target's requester.\n\n\n"; + +const MKM_TEXT_TREE: TextTreeShape = { + identity: MKM_FILE, + range: MKM_ROOT_RANGE, + ownText: MKM_ROOT_OWN, + subtreeText: UNAVAILABLE, + children: [ + { + identity: `${MKM_FILE}#m`, + range: MKM_M_RANGE, + ownText: UNAVAILABLE, + subtreeText: UNAVAILABLE, + children: [], + }, + ], +}; + +// The import's resolved target turns on specifier form and discovery ALONE: +// gone.mdx is discovered, so the entry carries the plain path even while the +// file is unparseable and the embedding into it records nothing. +const MKM_IMPORTS: readonly ViewImportEntry[] = [ + { range: MKM_IMPORT_RANGE, name: "GONE", target: MK_GONE_FILE }, +]; + +// --- the invalid-path workspace: specs/vi#ew.mdx ------------------------------ + +const IP_FILE = "specs/vi#ew.mdx"; +const IPF = new ByteFixture(); +IPF.add("Vïew — invalid path, intact view.\n\n"); +const IP_H_START = IPF.pos; +IPF.add('<S id="h">\nHash line.\n</S>'); +const IP_H_RANGE: SourceRange = { start: IP_H_START, end: IPF.pos }; +IPF.add("\n"); +const IP_SOURCE = IPF.source; +const IP_ROOT_RANGE: SourceRange = { start: 0, end: IPF.pos }; + +// The file holds no embedding, so every text value is defined and byte-exact +// even though no node of the file has a defined identity: expansion +// definedness turns on occurrence-recording spellings alone (SPEC 11.2). +const IP_H_TEXT = "Hash line.\n"; +const IP_ROOT_OWN = "Vïew — invalid path, intact view.\n\n"; +const IP_ROOT_SUBTREE = IP_ROOT_OWN + IP_H_TEXT; + +const IP_TEXT_TREE: TextTreeShape = { + identity: UNAVAILABLE, + range: IP_ROOT_RANGE, + ownText: IP_ROOT_OWN, + subtreeText: IP_ROOT_SUBTREE, + children: [ + { + identity: UNAVAILABLE, + range: IP_H_RANGE, + ownText: IP_H_TEXT, + subtreeText: IP_H_TEXT, + children: [], + }, + ], +}; + +const T11_4_5 = defineProductTest({ + id: "T11.4-5", + title: + "with `--text` each node carries own and subtree text per T11.2-4, and the consulted domain is the requested files plus exactly the files of resolved targets reachable through occurrence-RECORDING embeddings: requesting ONLY A, whose embeddings reach B and C transitively, accompanies exactly B's 14.5 and C's 14.6 — deep findings lying in consulted files never requested — while the boundary spelling `{text(X.dup)}` (X's duplicate pair proven staged by the `build --json` gate) records no occurrence and consults NO further file: X's 14.3 accompanies nothing, no winner resolved through; a self-embedding cycle reached from a requested entry file accompanies its one 14.9 located in the consulted-but-never-requested participant, whether or not any expansion completes, poisoning the entry's reaching values; a masked file is never consulted by expansion — the spelling naming into it records no occurrence (an empty occurrence list), the blocking 14.6 lying in the requester at exactly the braced container — its 14.20 accompanying only when itself requested, and the unparseable requested file then contributing NO view (the views list stays [main]); an invalid-path requested file (`specs/vi#ew.mdx` — a bare `<file>` operand is a whole path, `#` having no delimiter role, 12.0) keeps its view: identities unavailable, text values plain and byte-exact, the 14.19 carrying no locations and the file as concerned path; without `--text`, requesting A consults A alone — findings `[]`, exit 0, the exit following A's own findings while B/C/X stay failing (SPEC 11.4, 11.2, 1.6, 3, 2.1, 5.3, 12.0, 12.7, 14)", + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline): composed ranges sliced back + // out of the staged bytes before any product invocation. + sliceCheck( + XDA_SOURCE, + XDA_IMPORT_RANGE, + XDA_IMPORT_TEXT, + "A's import declaration", + ); + sliceCheck( + XDA_SOURCE, + XDA_EMBED_RANGE, + XDA_EMBED_TEXT, + "A's embedding container", + ); + sliceCheck( + XDA_SOURCE, + XDA_ALPHA_RANGE, + '<S id="alpha">\nAlpha head.\n\n{text(B.b)}\n</S>', + "alpha's whole construct", + ); + sliceCheck( + XDA_SOURCE, + XDA_PLAIN_RANGE, + '<S id="plain">\nPlain line.\n</S>', + "plain's whole construct", + ); + sliceCheck( + XDB_SOURCE, + { start: XDB_B_START, end: XDB_B_OPEN_END }, + '<S id="b" d={"ghost"}>', + "b's opening tag", + ); + sliceCheck( + XDC_SOURCE, + XDC_BOUNDARY_RANGE, + XDC_BOUNDARY_TEXT, + "the boundary embedding container", + ); + sliceCheck( + XDX_SOURCE, + XDX_DUP1_RANGE, + '<S id="dup">\nFirst twin.\n</S>', + "the first dup bearer", + ); + sliceCheck( + XDX_SOURCE, + XDX_DUP2_RANGE, + '<S id="dup">\nSecond twin.\n</S>', + "the second dup bearer", + ); + sliceCheck( + CYE_SOURCE, + CYE_EMBED_RANGE, + CYE_EMBED_TEXT, + "entry's embedding container", + ); + sliceCheck( + CYE_SOURCE, + CYE_START_RANGE, + '<S id="start">\nStart head.\n\n{text(LOOP.l1)}\n</S>', + "start's whole construct", + ); + sliceCheck( + CYL_SOURCE, + CYL_SELF_RANGE, + CYL_SELF_TEXT, + "the self-embedding container", + ); + sliceCheck( + MKM_SOURCE, + MKM_EMBED_RANGE, + MKM_EMBED_TEXT, + "main's embedding container", + ); + sliceCheck( + MKM_SOURCE, + MKM_M_RANGE, + '<S id="m">\nMain head.\n\n{text(GONE.g)}\n</S>', + "m's whole construct", + ); + sliceCheck( + IP_SOURCE, + IP_H_RANGE, + '<S id="h">\nHash line.\n</S>', + "h's whole construct", + ); + + // --- The chain workspace: transitive consultation, the boundary, and + // the no-`--text` contrast. + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [XDA_FILE]: XDA_SOURCE, + [XDB_FILE]: XDB_SOURCE, + [XDC_FILE]: XDC_SOURCE, + [XDX_FILE]: XDX_SOURCE, + }, + }); + try { + // The staging gate: the workspace's COMPLETE findings multiset — + // X's 14.3 proven staged (so its absence from the view answers below + // is a real negative observation), B's 14.5 and C's 14.6 located, + // and nothing else anywhere (A finding-free). + const gateContext = + "T11.4-5 staging gate (`build --json`, the chain workspace)"; + const gateFindings = await buildFindings( + product, + workspace, + gateContext, + ); + assertConditionCounts( + gateFindings, + XD_WORKSPACE_CONDITIONS, + `${gateContext}: exactly the staged conditions — X's duplicate ` + + `pair (14.3), B's unresolved d reference (14.5), C's ` + + `non-recording boundary spelling (14.6) — and A finding-free ` + + `(SPEC 14)`, + ); + assertFindingWindows( + findingByCondition(gateFindings, "14.3", gateContext), + [ + { file: XDX_FILE, window: widened(XDX_DUP1_RANGE) }, + { file: XDX_FILE, window: widened(XDX_DUP2_RANGE) }, + ], + `${gateContext} — the duplicate-id finding locates EVERY bearer ` + + `of \`dup\` in specs/X.mdx (SPEC 14)`, + ); + assertFindingWindows( + findingByCondition(gateFindings, "14.5", gateContext), + [ + { + file: XDB_FILE, + window: { start: XDB_B_START, end: XDB_B_OPEN_END + 1 }, + }, + ], + `${gateContext} — the unresolved d reference is located within ` + + `the opening tag spelling it, in specs/B.mdx (SPEC 14)`, + ); + assertUnresolvedEmbedding( + findingByCondition(gateFindings, "14.6", gateContext), + { file: XDC_FILE, range: XDC_BOUNDARY_RANGE }, + gateContext, + ); + + // `view specs/A.mdx --text`: the consulted domain is {A, B, C} — + // B's and C's findings accompany while X's 14.3 accompanies + // NOTHING — and A's view alone is served, its text datums pinned. + const textContext = + "T11.4-5 `view specs/A.mdx --text` (requesting only the chain head)"; + const textResult = await expectExit( + product, + workspace, + ["view", XDA_FILE, "--text"], + 1, + `${textContext} — consulted-domain findings and poisoned text ` + + `values accompany, so exit 1 with the full answer (SPEC 11.2)`, + ); + const textReport = decodeViewReport( + parseJsonStdout( + textResult, + `${textContext} — a single JSON document is the only output ` + + `form, with or without --json (SPEC 11)`, + ), + { text: true }, + textContext, + ); + assertConditionCounts( + textReport.findings, + { "14.5": 1, "14.6": 1 }, + `${textContext}: the consulted domain is {A, B, C} — exactly B's ` + + `14.5 and C's 14.6 accompany (deep findings in consulted files ` + + `never requested) and X's 14.3 accompanies NOTHING: the ` + + `boundary spelling records no occurrence, so no further file ` + + `is consulted (SPEC 11.4, 11.2, 14)`, + ); + assertFindingWindows( + findingByCondition(textReport.findings, "14.5", textContext), + [ + { + file: XDB_FILE, + window: { start: XDB_B_START, end: XDB_B_OPEN_END + 1 }, + }, + ], + `${textContext} — B's own finding accompanies from a consulted ` + + `file never requested (SPEC 11.4, 14)`, + ); + assertUnresolvedEmbedding( + findingByCondition(textReport.findings, "14.6", textContext), + { file: XDC_FILE, range: XDC_BOUNDARY_RANGE }, + `${textContext} — the blocking finding lies in a file already ` + + `consulted (SPEC 11.4)`, + ); + assertSameJson( + textReport.views.map((view) => view.file), + [XDA_FILE], + `${textContext}: the requested files alone are viewed — ` + + `consultation never adds views (SPEC 11.4)`, + ); + const aTextView = textReport.views[0]!; + assertSameJson( + projectTextShape(aTextView.root), + XDA_TEXT_TREE, + `${textContext} — A's tree with text datums: alpha's own/subtree ` + + `text EXACTLY the unavailability marker (the boundary lies two ` + + `hops down; partial expansion never occurs), the embedding-free ` + + `sibling and the root's own text defined and byte-exact, the ` + + `root's subtree text poisoned (SPEC 11.2, 1.6, 3)`, + ); + assertSameJson( + aTextView.imports, + XDA_IMPORTS, + `${textContext} — A's import declaration with range, default ` + + `binding, and resolved target (SPEC 11.4)`, + ); + assertSameJson( + aTextView.occurrences, + XDA_OCCURRENCES, + `${textContext} — A's one embedding resolves and records: file, ` + + `range, kind, defined source, target (SPEC 5.7, 11.2)`, + ); + assertSameJson( + aTextView.comments, + [], + `${textContext} — no MDX comment is staged (SPEC 12.7)`, + ); + + // Without `--text`, requesting A consults A alone: B's findings + // absent, findings `[]`, and the exit follows A's own findings — + // none, so exit 0 while B/C/X stay failing. + const bareContext = + "T11.4-5 `view specs/A.mdx` (no --text: A consults A alone)"; + const bareResult = await expectExit( + product, + workspace, + ["view", XDA_FILE], + 0, + `${bareContext} — the consulted domain is the requested files ` + + `alone: A is finding-free and its answer carries no ` + + `explicitly-unavailable datum, so exit 0 whatever findings ` + + `B/C/X carry (SPEC 11.4, 11.2)`, + ); + const bareReport = decodeViewReport( + parseJsonStdout( + bareResult, + `${bareContext} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + { text: false }, + bareContext, + ); + assertSameJson( + bareReport.findings, + [], + `${bareContext}: B's findings are absent — the empty findings ` + + `member is [], never null (SPEC 11.4, 12.7)`, + ); + assertSameJson( + bareReport.views.map((view) => view.file), + [XDA_FILE], + `${bareContext} — one per-file view: the requested file (SPEC 11.4)`, + ); + const aBareView = bareReport.views[0]!; + assertSameJson( + projectIdentities(aBareView.root), + XDA_IDENTITY_TREE, + `${bareContext} — A's tree served in full (the decode has already ` + + `rejected any text member: absent without the flag, SPEC 12.7)`, + ); + assertSameJson( + aBareView.imports, + XDA_IMPORTS, + `${bareContext} — the import entry is flag-independent (SPEC 11.4)`, + ); + assertSameJson( + aBareView.occurrences, + XDA_OCCURRENCES, + `${bareContext} — the embedding's occurrence record is ` + + `flag-independent: resolution never turns on --text (SPEC 5.7, ` + + `11.2)`, + ); + } finally { + await workspace.dispose(); + } + } + + // --- The cycle workspace: a consulted participant's 14.9. + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [CYE_FILE]: CYE_SOURCE, + [CYL_FILE]: CYL_SOURCE, + }, + }); + try { + const gateContext = + "T11.4-5 staging gate (`build --json`, the cycle workspace)"; + const gateFindings = await buildFindings( + product, + workspace, + gateContext, + ); + assertConditionCounts( + gateFindings, + { "14.9": 1 }, + `${gateContext}: the length-one embedding cycle is the ` + + `workspace's ONLY condition — entry is finding-free (SPEC 5.3, ` + + `14)`, + ); + assertFindingWindows( + findingByCondition(gateFindings, "14.9", gateContext), + [{ file: CYL_FILE, window: widened(CYL_SELF_RANGE) }], + `${gateContext} — the cycle locates its full path in source: the ` + + `one participating reference spelling, the self-embedding ` + + `container in specs/loop.mdx (SPEC 14)`, + ); + + const context = + "T11.4-5 `view specs/entry.mdx --text` (the cycle participant is consulted)"; + const result = await expectExit( + product, + workspace, + ["view", CYE_FILE, "--text"], + 1, + `${context} — the consulted participant's cycle finding and ` + + `poisoned text values accompany, so exit 1 with the full ` + + `answer (SPEC 11.2)`, + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + { text: true }, + context, + ); + assertConditionCounts( + report.findings, + { "14.9": 1 }, + `${context}: the entry's embedding resolves and records, so the ` + + `cycle participant is consulted — whether or not any expansion ` + + `completes — and its 14.9 accompanies from a consulted file ` + + `never requested (SPEC 11.4, 14)`, + ); + assertFindingWindows( + findingByCondition(report.findings, "14.9", context), + [{ file: CYL_FILE, window: widened(CYL_SELF_RANGE) }], + `${context} — the cycle's finding lies in ` + + `consulted-but-never-requested specs/loop.mdx (SPEC 11.4, 14)`, + ); + assertSameJson( + report.views.map((view) => view.file), + [CYE_FILE], + `${context}: the requested file alone is viewed (SPEC 11.4)`, + ); + const entryView = report.views[0]!; + assertSameJson( + projectTextShape(entryView.root), + CYE_TEXT_TREE, + `${context} — one embedding cycle on the expansion path poisons ` + + `the whole value: start's own/subtree text and the root's ` + + `subtree text EXACTLY the unavailability marker, the root's ` + + `own text defined and byte-exact (SPEC 11.2, 1.6, 3)`, + ); + assertSameJson( + entryView.imports, + CYE_IMPORTS, + `${context} — entry's import declaration (SPEC 11.4)`, + ); + assertSameJson( + entryView.occurrences, + CYE_OCCURRENCES, + `${context} — entry's embedding into the participant resolves ` + + `and records (SPEC 5.7, 11.2)`, + ); + assertSameJson( + entryView.comments, + [], + `${context} — no MDX comment is staged (SPEC 12.7)`, + ); + } finally { + await workspace.dispose(); + } + } + + // --- The masked workspace: never consulted by expansion; a requested + // unparseable file contributes no view. + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [MKM_FILE]: MKM_SOURCE, + [MK_GONE_FILE]: MK_GONE_SOURCE, + }, + }); + try { + const gateContext = + "T11.4-5 staging gate (`build --json`, the masked workspace)"; + const gateFindings = await buildFindings( + product, + workspace, + gateContext, + ); + assertConditionCounts( + gateFindings, + { "14.6": 1, "14.20": 1 }, + `${gateContext}: gone.mdx is unparseable (14.20) and the ` + + `spelling naming into it reports as unresolved (14.6) — ` + + `nothing else (SPEC 14)`, + ); + assertUnresolvedEmbedding( + findingByCondition(gateFindings, "14.6", gateContext), + { file: MKM_FILE, range: MKM_EMBED_RANGE }, + gateContext, + ); + assertFindingLocated( + findingByCondition(gateFindings, "14.20", gateContext), + { file: MK_GONE_FILE }, + `${gateContext} — the parse-failure finding locates in ` + + `specs/gone.mdx (SPEC 14)`, + ); + + // Requesting main alone: gone is never consulted by expansion — no + // spelling resolves into a masked file — so its 14.20 does NOT + // accompany; the blocking 14.6 lies in the requester itself. + const soloContext = + "T11.4-5 `view specs/main.mdx --text` (the masked file is never consulted)"; + const soloResult = await expectExit( + product, + workspace, + ["view", MKM_FILE, "--text"], + 1, + `${soloContext} — main's own finding and poisoned text values ` + + `accompany, so exit 1 with the full answer (SPEC 11.2)`, + ); + const soloReport = decodeViewReport( + parseJsonStdout( + soloResult, + `${soloContext} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + { text: true }, + soloContext, + ); + assertConditionCounts( + soloReport.findings, + { "14.6": 1 }, + `${soloContext}: exactly main's own 14.6 — the masked file's ` + + `14.20 accompanies only when itself requested, and no spelling ` + + `consults it by expansion (SPEC 11.4, 11.2, 14)`, + ); + assertUnresolvedEmbedding( + findingByCondition(soloReport.findings, "14.6", soloContext), + { file: MKM_FILE, range: MKM_EMBED_RANGE }, + soloContext, + ); + assertSameJson( + soloReport.views.map((view) => view.file), + [MKM_FILE], + `${soloContext}: one per-file view (SPEC 11.4)`, + ); + const soloView = soloReport.views[0]!; + assertSameJson( + projectTextShape(soloView.root), + MKM_TEXT_TREE, + `${soloContext} — the non-recording spelling poisons m's ` + + `own/subtree text and the root's subtree text, the root's own ` + + `text defined and byte-exact (SPEC 11.2, 1.6, 3)`, + ); + assertSameJson( + soloView.imports, + MKM_IMPORTS, + `${soloContext} — the import entry's target is the plain ` + + `"specs/gone.mdx": discovery, not parseability, defines it ` + + `(SPEC 11.4, 2.1)`, + ); + assertSameJson( + soloView.occurrences, + [], + `${soloContext} — the spelling naming into the masked file ` + + `records NO occurrence: an empty list, never null (SPEC 11.2, ` + + `5.7, 12.7)`, + ); + assertSameJson( + soloView.comments, + [], + `${soloContext} — no MDX comment is staged (SPEC 12.7)`, + ); + + // Requesting gone too: its parse-failure finding now accompanies — + // and the unparseable requested file contributes NO view. + const bothContext = + "T11.4-5 `view specs/main.mdx specs/gone.mdx --text` (the masked file requested)"; + const bothResult = await expectExit( + product, + workspace, + ["view", MKM_FILE, MK_GONE_FILE, "--text"], + 1, + `${bothContext} — findings accompany, so exit 1 with the full ` + + `answer (SPEC 11.2)`, + ); + const bothReport = decodeViewReport( + parseJsonStdout( + bothResult, + `${bothContext} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + { text: true }, + bothContext, + ); + assertConditionCounts( + bothReport.findings, + { "14.6": 1, "14.20": 1 }, + `${bothContext}: the parse-failure finding accompanies exactly ` + + `when its file is itself requested (SPEC 11.4, 14)`, + ); + assertUnresolvedEmbedding( + findingByCondition(bothReport.findings, "14.6", bothContext), + { file: MKM_FILE, range: MKM_EMBED_RANGE }, + bothContext, + ); + assertFindingLocated( + findingByCondition(bothReport.findings, "14.20", bothContext), + { file: MK_GONE_FILE }, + `${bothContext} — the parse-failure finding locates in ` + + `specs/gone.mdx (SPEC 14)`, + ); + assertSameJson( + bothReport.views.map((view) => view.file), + [MKM_FILE], + `${bothContext}: an unparseable requested file contributes NO ` + + `view — the views list stays [specs/main.mdx] (SPEC 11.4, 11.2)`, + ); + assertSameJson( + projectTextShape(bothReport.views[0]!.root), + MKM_TEXT_TREE, + `${bothContext} — main's view is unchanged beside the requested ` + + `masked file (SPEC 11.4)`, + ); + } finally { + await workspace.dispose(); + } + } + + // --- The invalid-path workspace: a requested 14.19 file keeps its view. + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [IP_FILE]: IP_SOURCE, + }, + }); + try { + const gateContext = + "T11.4-5 staging gate (`build --json`, the invalid-path workspace)"; + const gateFindings = await buildFindings( + product, + workspace, + gateContext, + ); + assertConditionCounts( + gateFindings, + { "14.19": 1 }, + `${gateContext}: the '#'-containing path is the workspace's ONLY ` + + `condition — the file itself parses (SPEC 14.19)`, + ); + const gate19 = findingByCondition(gateFindings, "14.19", gateContext); + assertSameJson( + { code: gate19.code, locations: gate19.locations, path: gate19.path }, + { code: "invalid-source-path", locations: [], path: IP_FILE }, + `${gateContext} — a path-level condition carries no in-source ` + + `location, the file as concerned path (SPEC 14, 12.7)`, + ); + + const context = + "T11.4-5 `view specs/vi#ew.mdx --text` (an invalid-path requested file keeps its view)"; + const result = await expectExit( + product, + workspace, + ["view", IP_FILE, "--text"], + 1, + `${context} — the condition-19 finding and the unavailable ` + + `identities accompany, so exit 1 with the full answer (SPEC ` + + `11.2)`, + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + { text: true }, + context, + ); + assertConditionCounts( + report.findings, + { "14.19": 1 }, + `${context}: the condition-19 finding accompanies every answer ` + + `whose consulted domain includes the file (SPEC 11.2, 14)`, + ); + const view19 = findingByCondition(report.findings, "14.19", context); + assertSameJson( + { code: view19.code, locations: view19.locations, path: view19.path }, + { code: "invalid-source-path", locations: [], path: IP_FILE }, + `${context} — stable code invalid-source-path, no locations, the ` + + `file as concerned path (SPEC 14, 12.7)`, + ); + assertSameJson( + report.views.map((view) => view.file), + [IP_FILE], + `${context}: a bare <file> operand is a whole path — '#' has no ` + + `delimiter role — naming the discovered file of that invalid ` + + `path, whose view is served (SPEC 12.0, 11.4)`, + ); + const ipView = report.views[0]!; + assertSameJson( + projectTextShape(ipView.root), + IP_TEXT_TREE, + `${context} — structure is parse-local: the tree and byte-exact ` + + `ranges are served with every identity — root included — ` + + `EXACTLY the unavailability marker (no identity over an ` + + `invalid path) while every text value is defined and ` + + `byte-exact: expansion definedness turns on ` + + `occurrence-recording spellings alone (SPEC 11.2, 1.6, 3)`, + ); + assertSameJson( + [ipView.imports, ipView.occurrences, ipView.comments], + [[], [], []], + `${context} — no import, reference spelling, or MDX comment is ` + + `staged: empty lists are [], never null (SPEC 11.4, 12.7)`, + ); + } finally { + await workspace.dispose(); + } + } + }, +}); + export const section114Tests: readonly ProductTestEntry[] = [ T11_4_1, T11_4_2, T11_4_3, T11_4_4, + T11_4_5, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index ac10e4b..f005e1c 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -312,6 +312,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.4-2": ["11.4"], "T11.4-3": ["11.4"], "T11.4-4": ["11.4"], + "T11.4-5": ["11.4"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From d2fdd170449619aa06ae10319b136eea1afaa2dd Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 20:13:12 +0000 Subject: [PATCH 110/226] sdg(phase-9): implement T11.4-6's byte classification via the P-2 oracle (FP-065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two workspaces in section-11.4.ts. The finding-free emission loop: specs/host.mdx (import, paired/self-closing sections with tags+d props, single- and multi-line comments, external + local embeddings, CRLF among LF, multi-byte offsets) beside target specs/parts.mdx (chained local embedding); after the build gate emits both .md files, one bare view is byte-asserted whole, every annotation span is assembled from the decoded view alone (attribute ranges and the d occurrence proven subsumed by tag spans; spans disjoint, in bounds), and the P-2 oracle over those view-derived spans — expansions the contribution-derived subtree texts — must reproduce BOTH emitted files byte-equal. The imperfect file stages exactly {14.6, 14.16}: the invalid element gets no view node, the ghost spelling no record, each located by its finding — the 14.6's location EXACTLY the full braced container (T14-8) — and view plus findings re-assemble the same exact span set. Verified: scratch probe against the built product proves the staged emission and all three expansion constants (query node subtree text); the current product's narrow 14.6 range [130,137) vs the container [124,139) is the diagnosed FP-064-class gap. Suite red-as-diagnosed at the first view invocation (unknown command, exit 2); test:self unchanged 3 planned mid-loop reds (certification-document fixture-manifest x2 -> FP-091; S-1 unmapped keys {11.5, 11.6, 12.6, 12.7} -> stage G), S-5, S-7, certification green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 53 +- test/suite/registry/section-11.4.ts | 1078 ++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 1123 insertions(+), 9 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index c7a2f38..2fabaf2 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2830,9 +2830,60 @@ certify against FP-091's fixtures once those land. (certification-document fixture-manifest ×2 → FP-091; S-1's unmapped keys exactly {11.5, 11.6, 12.6, 12.7} → stage G); S-5, S-7, and certification green.] -- [ ] FP-065 — Implement T11.4-6: byte classification reproducing compiled +- [x] FP-065 — Implement T11.4-6: byte classification reproducing compiled Markdown via the P-2 oracle (`test/helpers/oracles/markdown.ts`). [R2 #4; TEST-SPEC §11.4] + [Done 2026-08-14: T11.4-6 registered in section-11.4.ts (wrapper + auto-declares; traceability `"T11.4-6": ["11.4"]` — in no TEST-SPEC 14 + staging record, the T11.4-5 precedent; NOT in CONF-AVAIL scope: its + emission loop needs the `markdown` configuration, expressly outside that + scope's workspaces, so the gate-reference `build` and emitted-file reads + are free). Two workspaces. (1) The finding-free emission loop: + specs/host.mdx (import, paired/self-closing sections with `tags`+`d` + props, single- and multi-line comments, external + local embeddings, a + CRLF among LF terminators, multi-byte offsets) beside embedding target + specs/parts.mdx (own local embedding — the expansion chains two levels); + after the `build` gate (exit 0, emitting both .md files), one bare `view` + (exit 0, findings []) byte-asserted whole — trees with decomposition and + attribute entries, imports, occurrences (the `d` reference spanning its + string literal inside the tag; both embedding containers spanning their + whole {text(...)} expressions), comments — then the classification: + module helper `assembleAnnotationSpans` builds every annotation span from + the DECODED view alone (tag decompositions, imports, comments, + embeds-occurrence containers), asserting attribute ranges inside their + opening tag and the depends occurrence inside a tag span (subsumed + annotation bytes) and the spans disjoint/in-bounds/non-empty, compared + exactly to the staged span set; then `reproduceMarkdown` (byte-slices the + staged source at the view's spans, feeds the S-6-vetted P-2 oracle, + expansions = contribution-derived subtree-text constants per SPEC 1.6/3) + must byte-equal BOTH emitted files via assertFileBytes. A fixture + self-check proves oracle(staged spans) === the hand-derived expected .md + constants before any product invocation. (2) The imperfect file: + specs/imp.mdx stages exactly {14.6: a `{text("ghost")}` no-occurrence + spelling; 14.16: `<em>stray content</em>`} beside a valid import, + comment, and resolving embedding into specs/tgt.mdx, gate-pinned; `view + specs/imp.mdx` (exit 1) asserts the em contributes NO tree node and + ghost NO occurrence record, the 14.6's one location EXACTLY the full + braced container (assertUnresolvedEmbedding — the T14-8 pin that keeps + the classification exact), the 14.16 located within the em's construct + window; the classification is re-assembled from the view PLUS the + decoded 14.6 finding's range and compared exactly to the staged spans — + view plus findings position every removable construct, the em's bytes in + no span (content by form, SPEC 11.2). Verified: typecheck/format clean; + scratch probe (deleted) against the built product — W1 builds exit 0 + with BOTH emitted files byte-identical to the hand-derived expectations + and all three expansion constants equal to `query node` subtree text + (the contribution derivation proven against the real §3 machinery), W2 + stages exactly the {14.6, 14.16} multiset with the 14.16 in-window while + the current product locates the 14.6 at the string literal [130, 137) + instead of the full container [124, 139) — the diagnosed FP-064-class + range gap the amended SPEC 14 pins; suite red-as-diagnosed at arm 1's + first `view` invocation (unknown command, exit 2 — the T11.4-1-class + gap; the fixture self-checks and the staging build pass before it), + section-11.4 now 6 failed / 0 passed. `npm run test:self`: unchanged 3 + planned mid-loop reds (certification-document fixture-manifest ×2 → + FP-091; S-1's unmapped keys exactly {11.5, 11.6, 12.6, 12.7} → stage G); + S-5, S-7, and certification green.] - [ ] FP-066 — Implement T11.5-1: total `at` resolution incl. EOF offset and derivability from view data. [R2 #5; TEST-SPEC §11.5] New §11.5 diff --git a/test/suite/registry/section-11.4.ts b/test/suite/registry/section-11.4.ts index df8e0c0..5ec2e07 100644 --- a/test/suite/registry/section-11.4.ts +++ b/test/suite/registry/section-11.4.ts @@ -1,5 +1,4 @@ -// TEST-SPEC §11.4 (`xspec view`) — SUITE-54: T11.4-1 through T11.4-5 -// (T11.4-6 is the planned follow-up in this module). +// TEST-SPEC §11.4 (`xspec view`) — SUITE-54: T11.4-1 through T11.4-6. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -244,6 +243,51 @@ // holds none — never on identity definedness), the 14.19 accompanying // with no locations and the file as concerned path, exit 1. // +// T11.4-6 — byte classification (SPEC 11.4's closing paragraph; 3, 5.7, 13.2, +// 14). Two workspaces: +// +// - The emission loop (finding-free): specs/host.mdx carries every construct +// class at once — an import, paired/self-closing sections at two depths +// with `tags` and `d` props, single- and multi-line MDX comments, and an +// external plus a local embedding — beside the embedding-target file +// specs/parts.mdx (its own local embedding chains the expansion two +// levels), with Markdown emission enabled. After the staging `build` +// (exit 0 — the finding-free premise — writing specs/host.md and +// specs/parts.md), one bare `view` answers finding-free, exit 0, and is +// byte-asserted whole (trees with decomposition and attribute entries, +// imports, occurrences, comments). Then the classification: from the +// DECODED view alone the harness assembles every annotation span — tag +// decompositions (opening and closing ranges; the whole self-closing +// tag), import ranges, comment ranges, and embedding-occurrence container +// spans (SPEC 5.7) — asserting attribute ranges lie inside their tag's +// opening range and the `d` reference occurrence inside a tag span +// (subsumed annotation bytes), and that the spans are exactly the staged +// constructs, disjoint and in document order: every spanned byte is +// annotation, every other byte content. Reproduction: the P-2 oracle +// (helpers/oracles/markdown.ts, S-6-vetted) applied to those view-derived +// spans over the staged bytes — removals deleted in place, embedding +// containers replaced by the targets' subtree texts (chain-expanded +// constants, contribution-derived per SPEC 1.6/3), the line-drop rules of +// 3 — must reproduce BOTH emitted files byte-equal (a fixture self-check +// proves the harness arithmetic against hand-derived expected output +// before any product invocation; mixed CRLF/LF terminators and multi-byte +// characters keep byte offsets sharp). +// - The imperfect file (joint with the findings): specs/imp.mdx holds an +// invalid construct (`<em>…</em>`, 14.16) and a no-occurrence embedding +// spelling (`{text("ghost")}`, 14.6) beside a valid import, comment, and +// resolving embedding into specs/tgt.mdx; the `build --json` gate pins +// exactly those two conditions. `view specs/imp.mdx` (exit 1): the +// invalid element contributes NO view node and the ghost spelling NO +// occurrence record — each is located by its finding instead, the +// embedding form's finding spanning EXACTLY its full braced container +// (the span its occurrence would occupy, SPEC 14, T14-8 — what keeps this +// classification exact), the 14.16 located within its element's construct +// window. The classification is re-assembled from the view PLUS the 14.6 +// finding's range and asserted equal to the staged span set: view plus +// findings again position every removable construct, while the invalid +// element's bytes lie in NO span — a construct matching no removal rule's +// form is content (SPEC 11.2). +// // Certification (CERTIFICATIONS.md CONF-AVAIL): T11.4-1, T11.4-3, and // T11.4-4 are IN scope (the fixture family lands with the // certification-manifest task), so those bodies obey the scope's staging @@ -283,15 +327,21 @@ // no-default declarations' `null` name) is absent and the decode rejects // the omission; under VIOL-AVAIL-NOFILE it passes untouched — T11.4-4 // drives `view` alone. -// T11.4-2 and T11.4-5 are NOT in scope: CERTIFICATIONS.md's Exclusions name -// the argument, spelling, and domain-and-exit matrices of the -// machine-interface surfaces (T11.2-5, T11.3-2/3, T11.4-2, T11.5-2) and +// T11.4-2, T11.4-5, and T11.4-6 are NOT in scope: CERTIFICATIONS.md's +// Exclusions name the argument, spelling, and domain-and-exit matrices of +// the machine-interface surfaces (T11.2-5, T11.3-2/3, T11.4-2, T11.5-2) and // T11.4-5's consultation-domain negatives — certified representatively -// through the shared machinery — so unlike their siblings they are free to -// drive the gate-reference `build` and the snapshot compare. +// through the shared machinery — so unlike their siblings those two are +// free to drive the gate-reference `build` and the snapshot compare, and +// T11.4-6 lies outside the scope by construction: its emission loop needs +// the `markdown` configuration and emitted-file reads, both expressly +// outside CONF-AVAIL's workspace scope, its assertions are the loud +// positive byte-asserted class, and its oracle is S-6-vetted — so it too +// drives the gate-reference `build` freely. import { Buffer } from "node:buffer"; import type { + FileView, Finding, OccurrenceRecord, SourceRange, @@ -300,7 +350,13 @@ import type { ViewNode, } from "../../helpers/adapters/index.js"; import { decodeViewReport } from "../../helpers/adapters/index.js"; -import { fail, parseJsonStdout } from "../../helpers/assertions.js"; +import { + assertFileBytes, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; +import type { MarkdownPiece } from "../../helpers/oracles/markdown.js"; +import { compileMarkdown } from "../../helpers/oracles/markdown.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; @@ -315,6 +371,7 @@ import { assertFindingLocated, assertSameJson, buildFindings, + buildOk, expectExit, runJson, } from "./support.js"; @@ -2708,10 +2765,1015 @@ const T11_4_5 = defineProductTest({ }, }); +// ============================================================================= +// T11.4-6 — byte classification (SPEC 11.4 closing paragraph, 3, 5.7, 13.2). +// ============================================================================= + +// Spec-only configuration with Markdown emission enabled (SPEC 7.3; default +// destination: next to each source, `specs/host.mdx` → `specs/host.md`, +// 13.2). The group globs match only `.mdx` names, so no emit destination is +// ever discovered (13.4). +const BC_EMIT_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + markdown: { emit: true } +}) +`; + +/** + * One byte-classification span: a construct Markdown compilation removes + * (imports, section tags, comments — SPEC 3) or replaces (an embedding's + * full braced container, SPEC 5.7/3). Every byte inside a span is + * annotation; every byte outside every span is content (SPEC 11.4). + * `target` carries an embeds occurrence's resolved target identity (the + * expansion key for reproduction); `null` for removals and for a container + * positioned by its finding rather than by a record (no target resolves). + */ +interface AnnotationSpan { + readonly kind: "removal" | "embedding"; + readonly range: SourceRange; + readonly target: string | null; +} + +/** The `{kind, range}` image compared against the staged expectation. */ +function classificationOf( + spans: readonly AnnotationSpan[], +): readonly { kind: string; range: SourceRange }[] { + return spans.map((span) => ({ kind: span.kind, range: span.range })); +} + +/** + * Classify every byte of a viewed file from the view's data alone (SPEC + * 11.4): tag decompositions (opening and closing ranges — the whole + * self-closing tag), import ranges, comment ranges, and embeds-occurrence + * container spans become the annotation spans; `findingEmbeddings` adds + * containers positioned by a finding's range instead of a record (the + * imperfect-file arm, SPEC 14). Asserts, as diagnosed failures, the + * classification's own soundness over the product's data: every attribute + * range lies inside its tag's opening range and every non-embeds occurrence + * (a `d` reference, spelled inside a tag) inside some tag span — subsumed + * annotation bytes, never spans of their own — and the assembled spans are + * non-empty, in bounds, and disjoint, so together they classify every byte + * exactly once. + */ +function assembleAnnotationSpans( + view: FileView, + byteLength: number, + findingEmbeddings: readonly SourceRange[], + context: string, +): readonly AnnotationSpan[] { + const spans: AnnotationSpan[] = []; + const tagSpans: SourceRange[] = []; + const walk = (node: ViewNode): void => { + for (const tag of [node.opening, node.closing]) { + if (tag !== null) { + spans.push({ kind: "removal", range: tag, target: null }); + tagSpans.push(tag); + } + } + for (const attribute of node.attributes) { + const opening = node.opening; + if ( + opening === null || + attribute.range.start < opening.start || + attribute.range.end > opening.end + ) { + fail( + `${context}: attribute ${JSON.stringify(attribute.text)} at ` + + `[${String(attribute.range.start)}, ` + + `${String(attribute.range.end)}) must lie within its tag's ` + + `opening range ${JSON.stringify(opening)} — attribute bytes ` + + `are annotation through the tag span (SPEC 11.4, 3)`, + ); + } + } + node.children.forEach(walk); + }; + walk(view.root); + for (const declaration of view.imports) { + spans.push({ kind: "removal", range: declaration.range, target: null }); + } + for (const comment of view.comments) { + spans.push({ kind: "removal", range: comment, target: null }); + } + for (const record of view.occurrences) { + if (record.kind === "embeds") { + spans.push({ + kind: "embedding", + range: record.range, + target: record.target, + }); + } else { + const contained = tagSpans.some( + (tag) => record.range.start >= tag.start && record.range.end <= tag.end, + ); + if (!contained) { + fail( + `${context}: a ${record.kind} occurrence at ` + + `[${String(record.range.start)}, ${String(record.range.end)}) ` + + `spans its reference expression inside a section tag (SPEC ` + + `5.7) — its bytes must be annotation through a tag span, but ` + + `no tag range contains it`, + ); + } + } + } + for (const range of findingEmbeddings) { + spans.push({ kind: "embedding", range, target: null }); + } + spans.sort( + (a, b) => a.range.start - b.range.start || a.range.end - b.range.end, + ); + let cursor = 0; + for (const span of spans) { + if ( + span.range.end <= span.range.start || + span.range.start < cursor || + span.range.end > byteLength + ) { + fail( + `${context}: annotation spans must be non-empty, in bounds ` + + `(byte length ${String(byteLength)}), and disjoint — span ` + + `[${String(span.range.start)}, ${String(span.range.end)}) ` + + `violates that after the previous span ended at ` + + `${String(cursor)} (SPEC 11.4: the classification is exact)`, + ); + } + cursor = span.range.end; + } + return spans; +} + +/** + * The P-2 oracle applied to view data (SPEC 11.4, 3): slice the staged + * source's bytes at the assembled annotation spans, feed the pieces to the + * S-6-vetted Markdown oracle — removals deleted in place, each embedding + * container replaced by its target's subtree text from `expansions` — and + * return the compiled output. A missing expansion is a diagnosed failure + * (the occurrence compare has already pinned every target). + */ +function reproduceMarkdown( + source: string, + spans: readonly AnnotationSpan[], + expansions: ReadonlyMap<string, string>, + context: string, +): string { + const bytes = Buffer.from(source, "utf8"); + const pieces: MarkdownPiece[] = []; + let cursor = 0; + for (const span of spans) { + pieces.push({ + kind: "content", + text: bytes.subarray(cursor, span.range.start).toString("utf8"), + }); + const text = bytes + .subarray(span.range.start, span.range.end) + .toString("utf8"); + if (span.kind === "removal") { + pieces.push({ kind: "removal", text }); + } else { + const expansion = + span.target === null ? undefined : expansions.get(span.target); + if (expansion === undefined) { + fail( + `${context}: no staged expansion for embedding target ` + + `${JSON.stringify(span.target)} at ` + + `[${String(span.range.start)}, ${String(span.range.end)}) — ` + + `the reproduction replaces each container with its resolved ` + + `target's subtree text (SPEC 3, 1.6)`, + ); + } + pieces.push({ kind: "embedding", text, expansion }); + } + cursor = span.range.end; + } + pieces.push({ + kind: "content", + text: bytes.subarray(cursor).toString("utf8"), + }); + return compileMarkdown(pieces); +} + +// --- specs/host.mdx — every construct class on one finding-free file ---------- +// +// Line map (logical lines; the multi-byte prefix and the CRLF terminator +// shift and sharpen byte offsets, SPEC 1.7/3): a CRLF-terminated prose line; +// an empty line; the import (line dropped); an empty line; a lone-comment +// line (dropped); `top`'s opening tag with `tags` and `d` props (dropped); +// prose; a multi-line comment merging its two source lines into one logical +// line; the single-line child `top.kid`; the external embedding +// `{text(PÄRT.piece)}` on its own line; the self-closing `top.gap` (dropped); +// prose; `top`'s closing tag (dropped); prose; the single-line `side` with +// an in-line local embedding; prose. + +const BC_HOST_FILE = "specs/host.mdx"; +const BC_PARTS_FILE = "specs/parts.mdx"; + +const BCH = new ByteFixture(); +BCH.add("Höst — carrier of every construct.\r\n\n"); +const BCH_IMPORT_TEXT = 'import PÄRT from "./parts.xspec"'; +const BCH_IMPORT = BCH.add(BCH_IMPORT_TEXT); +BCH.add("\n\n"); +const BCH_COMMENT1_TEXT = "{/* lone comment line */}"; +const BCH_COMMENT1 = BCH.add(BCH_COMMENT1_TEXT); +BCH.add("\n"); +const BCH_TOP_OPEN_START = BCH.pos; +BCH.add("<S "); +const BCH_TOP_ID = BCH.attr("id", 'id="top"'); +BCH.add(" "); +const BCH_TOP_TAGS = BCH.attr("tags", 'tags="tag.α mark"'); +BCH.add(" "); +const BCH_TOP_D = BCH.attr("d", 'd={"top.kid"}'); +BCH.add(">"); +const BCH_TOP_OPEN: SourceRange = { start: BCH_TOP_OPEN_START, end: BCH.pos }; +BCH.add("\nTop head.\nMerged head "); +const BCH_COMMENT2_TEXT = "{/* first half\nsecond half */}"; +const BCH_COMMENT2 = BCH.add(BCH_COMMENT2_TEXT); +BCH.add(" merged tail.\n"); +const BCH_KID_START = BCH.pos; +BCH.add("<S "); +const BCH_KID_ID = BCH.attr("id", 'id="top.kid"'); +BCH.add(">"); +const BCH_KID_OPEN: SourceRange = { start: BCH_KID_START, end: BCH.pos }; +BCH.add("Kid line."); +const BCH_KID_CLOSE = BCH.add("</S>"); +const BCH_KID_RANGE: SourceRange = { start: BCH_KID_START, end: BCH.pos }; +BCH.add("\n"); +const BCH_EMBED_PIECE_TEXT = "{text(PÄRT.piece)}"; +const BCH_EMBED_PIECE = BCH.add(BCH_EMBED_PIECE_TEXT); +BCH.add("\n"); +const BCH_GAP_START = BCH.pos; +BCH.add("<S "); +const BCH_GAP_ID = BCH.attr("id", 'id="top.gap"'); +BCH.add(" />"); +const BCH_GAP_RANGE: SourceRange = { start: BCH_GAP_START, end: BCH.pos }; +BCH.add("\nTop tail.\n"); +const BCH_TOP_CLOSE = BCH.add("</S>"); +const BCH_TOP_RANGE: SourceRange = { start: BCH_TOP_OPEN_START, end: BCH.pos }; +BCH.add("\nBetween prose.\n"); +const BCH_SIDE_START = BCH.pos; +BCH.add("<S "); +const BCH_SIDE_ID = BCH.attr("id", 'id="side"'); +BCH.add(">"); +const BCH_SIDE_OPEN: SourceRange = { start: BCH_SIDE_START, end: BCH.pos }; +BCH.add("Inline "); +const BCH_EMBED_KID_TEXT = '{text("top.kid")}'; +const BCH_EMBED_KID = BCH.add(BCH_EMBED_KID_TEXT); +BCH.add(" run."); +const BCH_SIDE_CLOSE = BCH.add("</S>"); +const BCH_SIDE_RANGE: SourceRange = { start: BCH_SIDE_START, end: BCH.pos }; +BCH.add("\nCoda.\n"); +const BCH_SOURCE = BCH.source; +const BCH_ROOT_RANGE: SourceRange = { start: 0, end: BCH.pos }; + +// The `d` reference occurrence spans that one reference's own expression: +// for the local form the string literal's characters, quotes included — +// `d={` and the closing `}` excluded (SPEC 5.7, 2.2; the T5.7-2 convention). +const BCH_D_REF: SourceRange = { + start: BCH_TOP_D.range.start + "d={".length, + end: BCH_TOP_D.range.end - 1, +}; + +// --- specs/parts.mdx — the embedding-target file (chained local embedding) ---- + +const BCP = new ByteFixture(); +BCP.add("Pärts prose head.\n\n"); +const BCP_PIECE_START = BCP.pos; +BCP.add("<S "); +const BCP_PIECE_ID = BCP.attr("id", 'id="piece"'); +BCP.add(">"); +const BCP_PIECE_OPEN: SourceRange = { start: BCP_PIECE_START, end: BCP.pos }; +BCP.add("\nPiece head.\n"); +const BCP_EMBED_TEXT = '{text("piece.leaf")}'; +const BCP_EMBED = BCP.add(BCP_EMBED_TEXT); +BCP.add("\n"); +const BCP_LEAF_START = BCP.pos; +BCP.add("<S "); +const BCP_LEAF_ID = BCP.attr("id", 'id="piece.leaf"'); +BCP.add(">"); +const BCP_LEAF_OPEN: SourceRange = { start: BCP_LEAF_START, end: BCP.pos }; +BCP.add("Leaf line."); +const BCP_LEAF_CLOSE = BCP.add("</S>"); +const BCP_LEAF_RANGE: SourceRange = { start: BCP_LEAF_START, end: BCP.pos }; +BCP.add("\nPiece tail.\n"); +const BCP_PIECE_CLOSE = BCP.add("</S>"); +const BCP_PIECE_RANGE: SourceRange = { start: BCP_PIECE_START, end: BCP.pos }; +BCP.add("\nParts tail.\n"); +const BCP_SOURCE = BCP.source; +const BCP_ROOT_RANGE: SourceRange = { start: 0, end: BCP.pos }; + +// Subtree texts (SPEC 1.6: a node's subtree text is its construct's +// contribution to the file's compiled output — the rules of 3 applied over +// the WHOLE file, then restricted to output attributable to the construct's +// range; `text(...)` returns exactly this value): +// +// - piece.leaf / top.kid: single-line paired constructs — tags removed, the +// residue between them survives on its kept line; the line's terminator +// sits after `</S>`, outside the construct range, so neither value ends +// with one. +// - piece: its opening- and closing-tag lines are dropped whole (tag-only +// lines; each terminator inside the dropped line contributes nothing), so +// the contribution is the four kept lines between them — the embedding +// line replaced by piece.leaf's chained expansion. +const BC_EXPANSION_LEAF = "Leaf line."; +const BC_EXPANSION_KID = "Kid line."; +const BC_EXPANSION_PIECE = + "Piece head.\n" + "Leaf line.\n" + "Leaf line.\n" + "Piece tail.\n"; +const BC_EXPANSIONS: ReadonlyMap<string, string> = new Map([ + [`${BC_PARTS_FILE}#piece`, BC_EXPANSION_PIECE], + [`${BC_PARTS_FILE}#piece.leaf`, BC_EXPANSION_LEAF], + [`${BC_HOST_FILE}#top.kid`, BC_EXPANSION_KID], +]); + +// Hand-derived compiled outputs (SPEC 3; the fixture self-check proves the +// oracle over the staged spans reproduces exactly these before any product +// invocation): construct-only lines drop with their terminators, the +// multi-line comment merges its residues into one line (two spaces), kept +// prose keeps its bytes and terminator — the CRLF included — and each +// embedding line carries its non-empty expansion. +const BC_EXPECTED_HOST_MD = + "Höst — carrier of every construct.\r\n" + + "\n" + + "\n" + + "Top head.\n" + + "Merged head merged tail.\n" + + "Kid line.\n" + + BC_EXPANSION_PIECE + + "\n" + + "Top tail.\n" + + "Between prose.\n" + + "Inline Kid line. run.\n" + + "Coda.\n"; +const BC_EXPECTED_PARTS_MD = + "Pärts prose head.\n" + + "\n" + + "Piece head.\n" + + "Leaf line.\n" + + "Leaf line.\n" + + "Piece tail.\n" + + "Parts tail.\n"; + +// The staged annotation spans, in document order (the classification's +// expected value; embedding entries carry the expansion key for the +// self-check's reproduction). +const BC_HOST_SPANS: readonly AnnotationSpan[] = [ + { kind: "removal", range: BCH_IMPORT, target: null }, + { kind: "removal", range: BCH_COMMENT1, target: null }, + { kind: "removal", range: BCH_TOP_OPEN, target: null }, + { kind: "removal", range: BCH_COMMENT2, target: null }, + { kind: "removal", range: BCH_KID_OPEN, target: null }, + { kind: "removal", range: BCH_KID_CLOSE, target: null }, + { + kind: "embedding", + range: BCH_EMBED_PIECE, + target: `${BC_PARTS_FILE}#piece`, + }, + { kind: "removal", range: BCH_GAP_RANGE, target: null }, + { kind: "removal", range: BCH_TOP_CLOSE, target: null }, + { kind: "removal", range: BCH_SIDE_OPEN, target: null }, + { + kind: "embedding", + range: BCH_EMBED_KID, + target: `${BC_HOST_FILE}#top.kid`, + }, + { kind: "removal", range: BCH_SIDE_CLOSE, target: null }, +]; +const BC_PARTS_SPANS: readonly AnnotationSpan[] = [ + { kind: "removal", range: BCP_PIECE_OPEN, target: null }, + { + kind: "embedding", + range: BCP_EMBED, + target: `${BC_PARTS_FILE}#piece.leaf`, + }, + { kind: "removal", range: BCP_LEAF_OPEN, target: null }, + { kind: "removal", range: BCP_LEAF_CLOSE, target: null }, + { kind: "removal", range: BCP_PIECE_CLOSE, target: null }, +]; + +/** The tree data the classification consumes, projected for exact compare. */ +interface ClassifyShape { + readonly identity: string | { readonly unavailable: true }; + readonly range: SourceRange; + readonly opening: SourceRange | null; + readonly closing: SourceRange | null; + readonly attributes: readonly ViewAttributeEntry[]; + readonly children: readonly ClassifyShape[]; +} + +function projectClassifyShape(node: ViewNode): ClassifyShape { + return { + identity: node.identity, + range: node.range, + opening: node.opening, + closing: node.closing, + attributes: node.attributes, + children: node.children.map(projectClassifyShape), + }; +} + +const BC_HOST_TREE: ClassifyShape = { + identity: BC_HOST_FILE, + range: BCH_ROOT_RANGE, + opening: null, + closing: null, + attributes: [], + children: [ + { + identity: `${BC_HOST_FILE}#top`, + range: BCH_TOP_RANGE, + opening: BCH_TOP_OPEN, + closing: BCH_TOP_CLOSE, + attributes: [BCH_TOP_ID, BCH_TOP_TAGS, BCH_TOP_D], + children: [ + { + identity: `${BC_HOST_FILE}#top.kid`, + range: BCH_KID_RANGE, + opening: BCH_KID_OPEN, + closing: BCH_KID_CLOSE, + attributes: [BCH_KID_ID], + children: [], + }, + { + identity: `${BC_HOST_FILE}#top.gap`, + range: BCH_GAP_RANGE, + opening: BCH_GAP_RANGE, + closing: null, + attributes: [BCH_GAP_ID], + children: [], + }, + ], + }, + { + identity: `${BC_HOST_FILE}#side`, + range: BCH_SIDE_RANGE, + opening: BCH_SIDE_OPEN, + closing: BCH_SIDE_CLOSE, + attributes: [BCH_SIDE_ID], + children: [], + }, + ], +}; + +const BC_HOST_IMPORTS: readonly ViewImportEntry[] = [ + { range: BCH_IMPORT, name: "PÄRT", target: BC_PARTS_FILE }, +]; +const BC_HOST_OCCURRENCES: readonly OccurrenceRecord[] = [ + { + file: BC_HOST_FILE, + range: BCH_D_REF, + kind: "depends", + source: { identity: `${BC_HOST_FILE}#top`, range: BCH_TOP_RANGE }, + target: `${BC_HOST_FILE}#top.kid`, + }, + { + file: BC_HOST_FILE, + range: BCH_EMBED_PIECE, + kind: "embeds", + source: { identity: `${BC_HOST_FILE}#top`, range: BCH_TOP_RANGE }, + target: `${BC_PARTS_FILE}#piece`, + }, + { + file: BC_HOST_FILE, + range: BCH_EMBED_KID, + kind: "embeds", + source: { identity: `${BC_HOST_FILE}#side`, range: BCH_SIDE_RANGE }, + target: `${BC_HOST_FILE}#top.kid`, + }, +]; +const BC_HOST_COMMENTS: readonly SourceRange[] = [BCH_COMMENT1, BCH_COMMENT2]; + +const BC_PARTS_TREE: ClassifyShape = { + identity: BC_PARTS_FILE, + range: BCP_ROOT_RANGE, + opening: null, + closing: null, + attributes: [], + children: [ + { + identity: `${BC_PARTS_FILE}#piece`, + range: BCP_PIECE_RANGE, + opening: BCP_PIECE_OPEN, + closing: BCP_PIECE_CLOSE, + attributes: [BCP_PIECE_ID], + children: [ + { + identity: `${BC_PARTS_FILE}#piece.leaf`, + range: BCP_LEAF_RANGE, + opening: BCP_LEAF_OPEN, + closing: BCP_LEAF_CLOSE, + attributes: [BCP_LEAF_ID], + children: [], + }, + ], + }, + ], +}; +const BC_PARTS_OCCURRENCES: readonly OccurrenceRecord[] = [ + { + file: BC_PARTS_FILE, + range: BCP_EMBED, + kind: "embeds", + source: { identity: `${BC_PARTS_FILE}#piece`, range: BCP_PIECE_RANGE }, + target: `${BC_PARTS_FILE}#piece.leaf`, + }, +]; + +// --- specs/imp.mdx — the imperfect file (14.6 + 14.16, nothing else) ---------- + +const BCI_FILE = "specs/imp.mdx"; +const BCT_FILE = "specs/tgt.mdx"; +const BCT_SOURCE = 'Tärget prose.\n\n<S id="t">Tgt line.</S>\n'; + +const BCI = new ByteFixture(); +BCI.add("Ïmp — imperfect carrier.\n\n"); +const BCI_IMPORT_TEXT = 'import TGT from "./tgt.xspec"'; +const BCI_IMPORT = BCI.add(BCI_IMPORT_TEXT); +BCI.add("\n\n"); +const BCI_ONE_START = BCI.pos; +BCI.add("<S "); +const BCI_ONE_ID = BCI.attr("id", 'id="one"'); +BCI.add(">"); +const BCI_ONE_OPEN: SourceRange = { start: BCI_ONE_START, end: BCI.pos }; +BCI.add("\nOne head.\n"); +const BCI_COMMENT_TEXT = "{/* positioned comment */}"; +const BCI_COMMENT = BCI.add(BCI_COMMENT_TEXT); +BCI.add("\n"); +const BCI_EMBED_OK_TEXT = "{text(TGT.t)}"; +const BCI_EMBED_OK = BCI.add(BCI_EMBED_OK_TEXT); +BCI.add("\n"); +const BCI_GHOST_TEXT = '{text("ghost")}'; +const BCI_GHOST = BCI.add(BCI_GHOST_TEXT); +BCI.add("\n"); +const BCI_EM_START = BCI.pos; +BCI.add("<em>stray content</em>"); +const BCI_EM_WINDOW: SourceRange = { start: BCI_EM_START, end: BCI.pos }; +BCI.add("\nOne tail.\n"); +const BCI_ONE_CLOSE = BCI.add("</S>"); +const BCI_ONE_RANGE: SourceRange = { start: BCI_ONE_START, end: BCI.pos }; +BCI.add("\n"); +const BCI_SOURCE = BCI.source; +const BCI_ROOT_RANGE: SourceRange = { start: 0, end: BCI.pos }; + +// The imperfect workspace's COMPLETE findings multiset (the gate's staging +// premise): the no-occurrence embedding spelling (14.6 — `ghost` is a +// well-formed segment naming no section) and the invalid construct (14.16); +// the import resolves, the comment and the `{text(TGT.t)}` embedding are +// valid, and specs/tgt.mdx is finding-free. +const BCI_WORKSPACE_CONDITIONS: Readonly<Record<string, number>> = { + "14.6": 1, + "14.16": 1, +}; + +const BCI_TREE: ClassifyShape = { + identity: BCI_FILE, + range: BCI_ROOT_RANGE, + opening: null, + closing: null, + attributes: [], + children: [ + { + identity: `${BCI_FILE}#one`, + range: BCI_ONE_RANGE, + opening: BCI_ONE_OPEN, + closing: BCI_ONE_CLOSE, + attributes: [BCI_ONE_ID], + children: [], + }, + ], +}; +const BCI_IMPORTS: readonly ViewImportEntry[] = [ + { range: BCI_IMPORT, name: "TGT", target: BCT_FILE }, +]; +const BCI_OCCURRENCES: readonly OccurrenceRecord[] = [ + { + file: BCI_FILE, + range: BCI_EMBED_OK, + kind: "embeds", + source: { identity: `${BCI_FILE}#one`, range: BCI_ONE_RANGE }, + target: `${BCT_FILE}#t`, + }, +]; + +// Every removable construct of the imperfect file, positioned: the section's +// tag decomposition, the import, and the comment from the view; the +// recording container from its occurrence record; the ghost container from +// its finding's range. The `<em>` element is in NO span: a construct +// matching no removal rule's form is content (SPEC 11.2, 3). +const BCI_EXPECTED_SPANS: readonly AnnotationSpan[] = [ + { kind: "removal", range: BCI_IMPORT, target: null }, + { kind: "removal", range: BCI_ONE_OPEN, target: null }, + { kind: "removal", range: BCI_COMMENT, target: null }, + { kind: "embedding", range: BCI_EMBED_OK, target: `${BCT_FILE}#t` }, + { kind: "embedding", range: BCI_GHOST, target: null }, + { kind: "removal", range: BCI_ONE_CLOSE, target: null }, +]; + +const T11_4_6 = defineProductTest({ + id: "T11.4-6", + title: + "byte classification: on a finding-free file with imports, sections, tags, comments, and embeddings, the view's data alone — tag ranges (attribute ranges inside them), import ranges, comment ranges, embedding-occurrence container spans (5.7), the `d` reference occurrence subsumed by its tag — classifies every byte as annotation or content, and the P-2 oracle applied to those view-derived spans reproduces the compiled Markdown through the rules of 3 byte-equal to the emitted output of BOTH files, expansions chained two levels; on an imperfect file, jointly with the findings: the invalid construct gets NO view entry and the no-occurrence embedding spelling NO record — each located by its finding's range, the embedding form's finding spanning EXACTLY its full braced container (the span its occurrence would occupy, 14, T14-8) — so view plus findings again position every removable construct, the invalid element's bytes in no span (SPEC 11.4, 3, 1.6, 5.7, 11.2, 13.2, 14, 12.7)", + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline): composed ranges sliced back + // out of the staged bytes, and the oracle reproduction over the staged + // spans proven equal to the hand-derived compiled outputs — all before + // any product invocation; a failure here is a harness staging error, + // never a product failure. + sliceCheck(BCH_SOURCE, BCH_IMPORT, BCH_IMPORT_TEXT, "host's import"); + sliceCheck( + BCH_SOURCE, + BCH_COMMENT1, + BCH_COMMENT1_TEXT, + "host's lone comment", + ); + sliceCheck( + BCH_SOURCE, + BCH_TOP_OPEN, + '<S id="top" tags="tag.α mark" d={"top.kid"}>', + "top's opening tag", + ); + sliceCheck(BCH_SOURCE, BCH_D_REF, '"top.kid"', "top's d reference"); + sliceCheck( + BCH_SOURCE, + BCH_COMMENT2, + BCH_COMMENT2_TEXT, + "host's multi-line comment", + ); + sliceCheck( + BCH_SOURCE, + BCH_KID_RANGE, + '<S id="top.kid">Kid line.</S>', + "top.kid's whole construct", + ); + sliceCheck( + BCH_SOURCE, + BCH_EMBED_PIECE, + BCH_EMBED_PIECE_TEXT, + "host's external embedding container", + ); + sliceCheck( + BCH_SOURCE, + BCH_GAP_RANGE, + '<S id="top.gap" />', + "top.gap's self-closing tag", + ); + sliceCheck( + BCH_SOURCE, + BCH_SIDE_RANGE, + '<S id="side">Inline {text("top.kid")} run.</S>', + "side's whole construct", + ); + sliceCheck( + BCP_SOURCE, + BCP_EMBED, + BCP_EMBED_TEXT, + "parts' local embedding container", + ); + sliceCheck( + BCP_SOURCE, + BCP_LEAF_RANGE, + '<S id="piece.leaf">Leaf line.</S>', + "piece.leaf's whole construct", + ); + sliceCheck( + BCI_SOURCE, + BCI_GHOST, + BCI_GHOST_TEXT, + "imp's ghost embedding container", + ); + sliceCheck( + BCI_SOURCE, + BCI_EM_WINDOW, + "<em>stray content</em>", + "imp's invalid element", + ); + for (const [what, actual, expected] of [ + [ + "host reproduction", + reproduceMarkdown( + BCH_SOURCE, + BC_HOST_SPANS, + BC_EXPANSIONS, + "T11.4-6 fixture self-check (host)", + ), + BC_EXPECTED_HOST_MD, + ], + [ + "parts reproduction", + reproduceMarkdown( + BCP_SOURCE, + BC_PARTS_SPANS, + BC_EXPANSIONS, + "T11.4-6 fixture self-check (parts)", + ), + BC_EXPECTED_PARTS_MD, + ], + ] as const) { + if (actual !== expected) { + fail( + `T11.4-6 fixture self-check — ${what}: the oracle over the ` + + `staged spans must reproduce the hand-derived compiled output ` + + `(a harness staging error, not a product failure)\n` + + ` actual: ${JSON.stringify(actual)}\n` + + ` expected: ${JSON.stringify(expected)}`, + ); + } + } + + // --- The finding-free emission workspace: classification and + // reproduction from the view alone. + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": BC_EMIT_CONFIG, + [BC_HOST_FILE]: BCH_SOURCE, + [BC_PARTS_FILE]: BCP_SOURCE, + }, + }); + try { + await buildOk( + product, + workspace, + "T11.4-6 staging `build` (emission enabled): the workspace is " + + "finding-free, so build succeeds and emits specs/host.md and " + + "specs/parts.md next to their sources (SPEC 12.1, 13.2, 7.3)", + ); + + const context = + "T11.4-6 bare `view` (the finding-free emission workspace)"; + const result = await expectExit( + product, + workspace, + ["view"], + 0, + `${context} — complete and finding-free, so exit 0 (SPEC 11.2)`, + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form, ` + + `with or without --json (SPEC 11)`, + ), + { text: false }, + context, + ); + assertSameJson( + report.findings, + [], + `${context}: a finding-free domain — the findings member is [], ` + + `never null (SPEC 11.2, 12.7)`, + ); + assertSameJson( + report.views.map((view) => view.file), + [BC_HOST_FILE, BC_PARTS_FILE], + `${context} — every discovered spec source is viewed, per-file ` + + `views in byte order of workspace-relative path (SPEC 11.4)`, + ); + const hostView = report.views[0]!; + const partsView = report.views[1]!; + assertSameJson( + projectClassifyShape(hostView.root), + BC_HOST_TREE, + `${context} — host's positional tree byte-exact: construct ` + + `ranges, opening/closing decompositions (the whole self-closing ` + + `tag; neither on the root), and every attribute entry — the ` + + `classification's tag and attribute data (SPEC 11.4, 1.7)`, + ); + assertSameJson( + hostView.imports, + BC_HOST_IMPORTS, + `${context} — host's import declaration with byte-exact range ` + + `(SPEC 11.4)`, + ); + assertSameJson( + hostView.occurrences, + BC_HOST_OCCURRENCES, + `${context} — host's occurrence records in document order: the d ` + + `reference (spanning the string literal inside the tag) and ` + + `both embedding containers, each spanning the entire ` + + `{text(...)} expression (SPEC 5.7)`, + ); + assertSameJson( + hostView.comments, + BC_HOST_COMMENTS, + `${context} — both MDX comments' byte-exact ranges, the ` + + `multi-line one included (SPEC 11.4)`, + ); + assertSameJson( + projectClassifyShape(partsView.root), + BC_PARTS_TREE, + `${context} — parts' positional tree byte-exact (SPEC 11.4, 1.7)`, + ); + assertSameJson( + [partsView.imports, partsView.comments], + [[], []], + `${context} — parts stages no import and no comment: empty lists ` + + `are [], never null (SPEC 12.7)`, + ); + assertSameJson( + partsView.occurrences, + BC_PARTS_OCCURRENCES, + `${context} — parts' one local embedding records, spanning its ` + + `full braced container (SPEC 5.7)`, + ); + + // The classification, from the view alone: every byte annotation or + // content (SPEC 11.4). + const hostSpans = assembleAnnotationSpans( + hostView, + BCH_ROOT_RANGE.end, + [], + `${context} — specs/host.mdx classification`, + ); + assertSameJson( + classificationOf(hostSpans), + classificationOf(BC_HOST_SPANS), + `${context}: host's annotation spans — tag decompositions, ` + + `import, comments, embedding containers — are exactly the ` + + `staged constructs, disjoint, in document order; every other ` + + `byte is content (SPEC 11.4, 3, 5.7)`, + ); + const partsSpans = assembleAnnotationSpans( + partsView, + BCP_ROOT_RANGE.end, + [], + `${context} — specs/parts.mdx classification`, + ); + assertSameJson( + classificationOf(partsSpans), + classificationOf(BC_PARTS_SPANS), + `${context}: parts' annotation spans are exactly the staged ` + + `constructs (SPEC 11.4, 3, 5.7)`, + ); + + // The reproduction: the P-2 oracle over the view-derived spans, + // byte-equal to the emitted output (SPEC 11.4, 3, 13.2). + await assertFileBytes( + workspace.path("specs/host.md"), + reproduceMarkdown(BCH_SOURCE, hostSpans, BC_EXPANSIONS, context), + `${context}: the compiled Markdown reproduced from the view's ` + + `spans through the rules of 3 — removals deleted in place, ` + + `construct-only lines dropped with their terminators, the ` + + `multi-line comment merging its lines, embedding containers ` + + `replaced by the targets' chain-expanded subtree texts — is ` + + `byte-equal to the emitted specs/host.md (SPEC 11.4, 3, 13.2)`, + ); + await assertFileBytes( + workspace.path("specs/parts.md"), + reproduceMarkdown(BCP_SOURCE, partsSpans, BC_EXPANSIONS, context), + `${context}: the reproduction from parts' view spans is ` + + `byte-equal to the emitted specs/parts.md (SPEC 11.4, 3, 13.2)`, + ); + } finally { + await workspace.dispose(); + } + } + + // --- The imperfect file: classification joint with the findings. + { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [BCI_FILE]: BCI_SOURCE, + [BCT_FILE]: BCT_SOURCE, + }, + }); + try { + const gateContext = + "T11.4-6 staging gate (`build --json`, the imperfect workspace)"; + const gateFindings = await buildFindings( + product, + workspace, + gateContext, + ); + assertConditionCounts( + gateFindings, + BCI_WORKSPACE_CONDITIONS, + `${gateContext}: exactly the staged conditions — the ` + + `no-occurrence embedding spelling (14.6) and the invalid ` + + `construct (14.16); the import, comment, and resolving ` + + `embedding are valid and specs/tgt.mdx is finding-free (SPEC 14)`, + ); + assertUnresolvedEmbedding( + findingByCondition(gateFindings, "14.6", gateContext), + { file: BCI_FILE, range: BCI_GHOST }, + gateContext, + ); + assertFindingWindows( + findingByCondition(gateFindings, "14.16", gateContext), + [{ file: BCI_FILE, window: BCI_EM_WINDOW }], + `${gateContext} — the invalid construct is located within its ` + + `own element's construct window (SPEC 14)`, + ); + + const context = "T11.4-6 `view specs/imp.mdx` (the imperfect file)"; + const result = await expectExit( + product, + workspace, + ["view", BCI_FILE], + 1, + `${context} — the domain file's findings accompany, so exit 1 ` + + `with the full answer still emitted (SPEC 11.2)`, + ); + const report = decodeViewReport( + parseJsonStdout( + result, + `${context} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + { text: false }, + context, + ); + assertConditionCounts( + report.findings, + BCI_WORKSPACE_CONDITIONS, + `${context}: exactly the requested file's findings accompany ` + + `(SPEC 11.2, 14)`, + ); + const ghostFinding = findingByCondition( + report.findings, + "14.6", + context, + ); + assertUnresolvedEmbedding( + ghostFinding, + { file: BCI_FILE, range: BCI_GHOST }, + `${context} — what keeps the byte classification exact on ` + + `imperfect files (SPEC 14, T14-8)`, + ); + assertFindingWindows( + findingByCondition(report.findings, "14.16", context), + [{ file: BCI_FILE, window: BCI_EM_WINDOW }], + `${context} — the invalid construct gets NO view entry and is ` + + `located by its finding's range instead (SPEC 11.4, 14)`, + ); + assertSameJson( + report.views.map((view) => view.file), + [BCI_FILE], + `${context} — the requested file alone is viewed (SPEC 11.4)`, + ); + const impView = report.views[0]!; + assertSameJson( + projectClassifyShape(impView.root), + BCI_TREE, + `${context} — the positional tree holds the root and the ` + + `section alone: the invalid element contributes NO node ` + + `(SPEC 11.4)`, + ); + assertSameJson( + impView.imports, + BCI_IMPORTS, + `${context} — the import entry with byte-exact range and ` + + `resolved target (SPEC 11.4)`, + ); + assertSameJson( + impView.occurrences, + BCI_OCCURRENCES, + `${context} — the resolving embedding records; the ghost ` + + `spelling records NOTHING — no record, no unavailable target — ` + + `its position reaching consumers through its finding's range ` + + `alone (SPEC 5.7, 11.2)`, + ); + assertSameJson( + impView.comments, + [BCI_COMMENT], + `${context} — the comment's byte-exact range (SPEC 11.4)`, + ); + + // View plus findings position every removable construct (SPEC + // 11.4): the ghost container enters the classification from ITS + // FINDING's range — the decoded location, not the staged constant. + const impSpans = assembleAnnotationSpans( + impView, + BCI_ROOT_RANGE.end, + [ghostFinding.locations[0]!.range], + `${context} — classification joint with the findings`, + ); + assertSameJson( + classificationOf(impSpans), + classificationOf(BCI_EXPECTED_SPANS), + `${context}: view plus findings again position every removable ` + + `construct — the tag decomposition, import, and comment from ` + + `the view, the recording container from its occurrence record, ` + + `the no-occurrence container from its finding's range — ` + + `exactly the staged spans, disjoint, in document order; the ` + + `invalid element's bytes lie in NO span: a construct matching ` + + `no removal rule's form is content (SPEC 11.4, 11.2, 3)`, + ); + } finally { + await workspace.dispose(); + } + } + }, +}); + export const section114Tests: readonly ProductTestEntry[] = [ T11_4_1, T11_4_2, T11_4_3, T11_4_4, T11_4_5, + T11_4_6, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index f005e1c..fc4ad8b 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -313,6 +313,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.4-3": ["11.4"], "T11.4-4": ["11.4"], "T11.4-5": ["11.4"], + "T11.4-6": ["11.4"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From 2b65f41556070c8fb8587b8229c036eaacc9f43b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 20:33:33 +0000 Subject: [PATCH 111/226] sdg(phase-9): implement T11.5-1's total at resolution and view derivability (FP-066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New §11.5 registry module (SUITE-55) with T11.5-1: twelve pointwise resolution arms against precomputed byte offsets (imports, comments, deep content, between-section prose, opening/closing tags, post-child tail, the EOF caret), byte length + 1 as the exit-2 usage error, and the derivability sweep — for every offset 0..byte length, `at` must equal the resolution computed from the file's decoded `view` data alone via the exported resolveAtFromView comparator (the P-12 anchor), the view itself first anchored byte-exactly against the precomputed fixture. Traceability maps "11.5"; S-1's unmapped keys narrow to exactly {11.6, 12.6, 12.7}. Red-as-diagnosed at the first `at` invocation (unknown command — the T11.4-1-class product gap); self-tests otherwise green with the planned certification-document reds (FP-091). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 44 +- test/suite/registry/index.ts | 2 + test/suite/registry/section-11.5.ts | 600 ++++++++++++++++++++++++++++ test/suite/registry/traceability.ts | 1 + test/suite/section-11.5.test.ts | 9 + 5 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 test/suite/registry/section-11.5.ts create mode 100644 test/suite/section-11.5.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 2fabaf2..c7004ed 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2885,9 +2885,51 @@ certify against FP-091's fixtures once those land. FP-091; S-1's unmapped keys exactly {11.5, 11.6, 12.6, 12.7} → stage G); S-5, S-7, and certification green.] -- [ ] FP-066 — Implement T11.5-1: total `at` resolution incl. EOF offset +- [x] FP-066 — Implement T11.5-1: total `at` resolution incl. EOF offset and derivability from view data. [R2 #5; TEST-SPEC §11.5] New §11.5 registry module; map `"11.5"`. + [Done 2026-08-14: T11.5-1 registered in new section-11.5.ts (SUITE-55; + wrapper section-11.5.test.ts auto-declares; traceability + `"T11.5-1": ["11.5"]` — no numbered condition asserted, the T11.4-* + precedent; in NO certification scope: CONF-AVAIL's scope statement + expressly excludes `at`). One workspace: specs/total.mdx (295 bytes — + two resolving imports, a top-level and an in-section comment, sections + a ⊃ a.b ⊃ a.b.c beside top-level z, prose before/inside/between + constructs, multi-byte é/è/— shifting every later offset, SPEC 1.7) + beside prose-only specs/base.mdx, composed by the running-offset + builder. Twelve pointwise arms against precomputed constants (the P-12 + anchor CERTIFICATIONS.md names): offsets inside an import, both + comments, deep content, between-section prose, opening tags (a.b's, + a.b.c's — the innermost containing construct, never the parent), + closing tags (a's past a.b's close, z's), a.b's post-child tail + content, and the EOF caret (byte length → root) each pinned to the + exact {identity, range} resolution, occurrence null, findings [], exit + 0 through the form-exact decodeAtReport; byte length + 1 → exit 2 via + expectAvailabilityUsageError (section-11.2's shared T11.2-5 protocol). + Derivability: `view specs/total.mdx` first anchored byte-exactly + against the fixture (tree projection {identity, range, children}, both + import entries, both comment ranges, occurrences [], findings []), + then for EVERY offset 0..295 `at` must equal the exported comparator + `resolveAtFromView` over the DECODED view — a containment descent + realizing innermost resolution, the root-where-none rule, and the EOF + rule in one shape, plus the containing-occurrence pick (null here; + occurrence containment is T11.5-3's subject) — non-circular via the + anchor. FP-088's P-12 imports the comparator from the module. Verified: + typecheck/format clean; fixture self-checks (17 slice checks, a + composed-length pin, comparator ≡ every hand-stated arm on the + precomputed tree) pass before any product invocation; a scratch + cross-validation (deleted) proved the comparator ≡ an independent + brute-force narrowest-containing-range implementation over all 296 + offsets with boundary spot checks (start-inclusive/end-exclusive at + every construct edge, EOF → root); staging probed finding-free against + the built product (`build --json` findings [], `ids` exactly + a/a.b/a.b.c/z); suite red-as-diagnosed at the first arm's `at` + invocation (unknown command, exit 2 — the T11.4-1-class gap; every + self-check passes before it), timeoutMs 360_000 for the + ~310-invocation sweep. `npm run test:self`: the 3 planned mid-loop + reds — certification-document fixture-manifest ×2 → FP-091; S-1's + unmapped keys narrowed {11.5, 11.6, 12.6, 12.7} → exactly {11.6, 12.6, + 12.7} (stage G); S-5, S-7, and certification green.] - [ ] FP-067 — Implement T11.5-2: offset spelling matrix. [R2 #5; TEST-SPEC §11.5] - [ ] FP-068 — Implement T11.5-3: occurrence containment ends and imperfect diff --git a/test/suite/registry/index.ts b/test/suite/registry/index.ts index 620dcfc..1c3d562 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -59,6 +59,7 @@ import { section11Tests } from "./section-11.js"; import { section112Tests } from "./section-11.2.js"; import { section113Tests } from "./section-11.3.js"; import { section114Tests } from "./section-11.4.js"; +import { section115Tests } from "./section-11.5.js"; import { section120iTests } from "./section-12.0-i.js"; import { section120iiTests } from "./section-12.0-ii.js"; import { section121to122Tests } from "./section-12.1-12.2.js"; @@ -126,6 +127,7 @@ export const productTestSuite = new ProductTestSuite([ ...section112Tests, ...section113Tests, ...section114Tests, + ...section115Tests, ...section120iTests, ...section120iiTests, ...section121to122Tests, diff --git a/test/suite/registry/section-11.5.ts b/test/suite/registry/section-11.5.ts new file mode 100644 index 0000000..e49dadd --- /dev/null +++ b/test/suite/registry/section-11.5.ts @@ -0,0 +1,600 @@ +// TEST-SPEC §11.5 (`xspec at`) — SUITE-55: T11.5-1 (T11.5-2 and T11.5-3 +// follow in this module as they are implemented). +// +// Registered product-facing bodies (C-2 "one code path"): each builds its own +// fresh workspace (H-1), drives the product strictly as a subprocess (H-2), +// asserts exact exit codes (H-5), and rejects a product only via diagnosed +// assertion failures (H-8). SPEC 11: `at` is JSON-only — a single JSON +// document is its only output form, with or without `--json` — in the +// form-exact 12.7 document form (H-3), so every invocation below runs bare +// and its entire stdout decodes through `decodeAtReport`, which enforces the +// top level (`{"findings", "resolution"}` exactly), the resolution's +// `{"section", "occurrence"}` form with `section` `{"identity", "range"}`, +// the three-state datum rules (a plain identity or the unavailability +// marker, never `null`; `occurrence` an occurrence record or `null`), and +// the finding forms over whatever the product emits. +// +// T11.5-1 — total resolution and derivability from view data. One workspace, +// one file with imports, comments, nested sections, and between-section +// prose (specs/total.mdx, finding-free, composed by the running-offset +// builder behind multi-byte prose so byte offsets diverge from code-point +// and UTF-16 counts, SPEC 1.7), plus the prose-only import target: +// +// - Pointwise arms (precomputed constants — the anchor CERTIFICATIONS.md's +// P-12 note names): offsets inside an import declaration, inside a +// top-level comment, inside a comment within the deep section, in deep +// section content, in between-section prose, inside opening tags (a.b's +// and a.b.c's — the INNERMOST containing section construct wins, never +// the parent whose range also contains the tag), inside closing tags (a's, +// lying after a.b's close, and z's), and in content between a child's +// close and its parent's close each resolve to the innermost section +// construct whose range (1.7) contains the offset — the root where none +// does — reported as `{"identity", "range"}`: the construct range and the +// node identity per 11.2 (every staged identity is spelled, well-formed, +// structurally conformant, and unique, so each is the defined plain +// string; the root's identity is the bare path, its range the whole +// file). `occurrence` is `null` throughout: no reference spelling is +// staged (occurrence containment is T11.5-3's subject). +// - EOF caret: the offset equal to the file's byte length resolves to the +// root — outside the root's end-exclusive range, resolved by 11.5's +// explicit rule; byte length + 1 is a usage error, exit 2 with the single +// 12.7 error document as the entire stdout (SPEC 11.2, 12.0; the +// T11.2-5 protocol via section-11.2's shared helper). +// - Derivability: for EVERY offset 0…byte length, `at`'s resolution equals +// the resolution computed from the file's `view` data alone — +// `resolveAtFromView` below, walking the view's positional tree for the +// innermost containing section and its occurrence records for the +// containing occurrence (SPEC 11.5: `at` adds convenience, not +// information). The comparator is not circular: the view is first +// anchored byte-exactly against the precomputed fixture (tree +// identities/ranges, both import entries, both comment ranges, no +// occurrence, findings []), and a fixture self-check proves the +// comparator against the hand-stated pointwise expectations on the +// precomputed tree before any product invocation. Every answer of the +// sweep is finding-free at exit 0 (SPEC 11.2: the consulted domain is +// the named file alone; complete and finding-free → exit 0). +// +// Certification note: CONF-AVAIL's scope expressly excludes `at` ("no +// in-scope staging drives `at`" — CERTIFICATIONS.md), and T11.5-1 is in no +// other fixture's scope, so no certification executes this body; its +// answer-side decode rigor is certified through the CONF-AVAIL datum-form +// violators (the shared 12.7 machinery), per CERTIFICATIONS.md's +// negative-matrix note. P-12 generalizes the derivability equality to +// random workspaces, anchored by this test's precomputed fixture, and +// imports `resolveAtFromView` from here (FP-088). + +import { Buffer } from "node:buffer"; +import type { + AtResolution, + AtSection, + OccurrenceRecord, + SourceRange, + ViewNode, +} from "../../helpers/adapters/index.js"; +import { + decodeAtReport, + decodeViewReport, +} from "../../helpers/adapters/index.js"; +import { fail } from "../../helpers/assertions.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import type { ProductBinding } from "../../helpers/subprocess.js"; +import type { TestWorkspace as Workspace } from "../../helpers/workspace.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { + expectAvailabilityUsageError, + SPECS_ONLY_CONFIG, +} from "./section-11.2.js"; +import { assertSameJson, runJson } from "./support.js"; + +/** + * Running byte-offset fixture assembler (the T5.7-2/T11.2-1/T11.4-1 + * discipline): `add` appends a segment and returns its byte range, so every + * expected offset is composed from the same parts the staged file is. + */ +class ByteFixture { + private readonly parts: string[] = []; + private bytes = 0; + + get pos(): number { + return this.bytes; + } + + get source(): string { + return this.parts.join(""); + } + + add(segment: string): SourceRange { + const start = this.bytes; + this.parts.push(segment); + this.bytes += Buffer.byteLength(segment, "utf8"); + return { start, end: this.bytes }; + } +} + +/** + * Fixture self-check (harness-side, before any product invocation): a + * claimed byte range must slice the staged file's bytes to exactly the span + * it claims. A failure here is a staging-arithmetic defect of the harness, + * never a product failure. + */ +function sliceCheck( + source: string, + range: SourceRange, + span: string, + what: string, +): void { + const actual = Buffer.from(source, "utf8") + .subarray(range.start, range.end) + .toString("utf8"); + if (actual !== span) { + fail( + `§11.5 fixture self-check — ${what}: the claimed byte range ` + + `[${String(range.start)}, ${String(range.end)}) slices the staged ` + + `bytes to ${JSON.stringify(actual)}, expected ` + + `${JSON.stringify(span)} (a harness-side staging error, not a ` + + `product failure)`, + ); + } +} + +// --- specs/total.mdx — the total-resolution ground (finding-free) ------------- +// +// Imports (two, both resolving to the discovered specs/base.mdx — SPEC 2.1: +// several imports may bind one module under different names, and an unused +// binding is valid, so the file stays finding-free), comments (one at top +// level, one inside the deep section), nested sections at three depths +// (a ⊃ a.b ⊃ a.b.c) beside a second top-level section (z), and prose before +// any section, inside sections, between a child's close and its parent's +// close, and between the top-level sections. The multi-byte characters +// (é: 2 bytes; è: 2 bytes; —: 3 bytes) shift every later offset, so byte +// offsets diverge from code-point and UTF-16 counts (SPEC 1.7). Every +// segment's text is a named constant so construct-slice expectations are +// composed, never retyped. + +const AT_FILE = "specs/total.mdx"; +const BASE_FILE = "specs/base.mdx"; +const BASE_SOURCE = "Socle importé — cible des deux imports.\n"; + +const PROSE_HEAD_TEXT = "Début du fichier — préambule.\n"; +const IMPORT_ONE_TEXT = 'import BASE from "./base.xspec"'; +const IMPORT_TWO_TEXT = 'import AUSSI from "./base.xspec"'; +const COMMENT_TOP_TEXT = "{/* commentaire général */}"; +const A_OPEN_TEXT = '<S id="a">'; +const A_PROSE_TEXT = "Intro locale.\n"; +const AB_OPEN_TEXT = '<S id="a.b">'; +const ABC_OPEN_TEXT = '<S id="a.b.c">'; +const DEEP_PROSE_TEXT = "Contenu très profond.\n"; +const COMMENT_DEEP_TEXT = "{/* note interne */}"; +const CLOSE_TEXT = "</S>"; +const AB_TAIL_TEXT = "Après c.\n"; +const PROSE_BETWEEN_TEXT = "Entre les sections.\n"; +const Z_OPEN_TEXT = '<S id="z">'; +const Z_PROSE_TEXT = "Finale.\n"; + +const F = new ByteFixture(); +const PROSE_HEAD = F.add(PROSE_HEAD_TEXT); +const IMPORT_ONE = F.add(IMPORT_ONE_TEXT); +F.add("\n"); +const IMPORT_TWO = F.add(IMPORT_TWO_TEXT); +F.add("\n"); +const COMMENT_TOP = F.add(COMMENT_TOP_TEXT); +F.add("\n"); +const A_OPEN = F.add(A_OPEN_TEXT); +F.add("\n"); +F.add(A_PROSE_TEXT); +const AB_OPEN = F.add(AB_OPEN_TEXT); +F.add("\n"); +const ABC_OPEN = F.add(ABC_OPEN_TEXT); +F.add("\n"); +const DEEP_PROSE = F.add(DEEP_PROSE_TEXT); +const COMMENT_DEEP = F.add(COMMENT_DEEP_TEXT); +F.add("\n"); +F.add(CLOSE_TEXT); +const ABC_RANGE: SourceRange = { start: ABC_OPEN.start, end: F.pos }; +F.add("\n"); +const AB_TAIL = F.add(AB_TAIL_TEXT); +F.add(CLOSE_TEXT); +const AB_RANGE: SourceRange = { start: AB_OPEN.start, end: F.pos }; +F.add("\n"); +const A_CLOSE = F.add(CLOSE_TEXT); +const A_RANGE: SourceRange = { start: A_OPEN.start, end: F.pos }; +F.add("\n"); +const PROSE_BETWEEN = F.add(PROSE_BETWEEN_TEXT); +const Z_OPEN = F.add(Z_OPEN_TEXT); +F.add("\n"); +F.add(Z_PROSE_TEXT); +const Z_CLOSE = F.add(CLOSE_TEXT); +const Z_RANGE: SourceRange = { start: Z_OPEN.start, end: F.pos }; +F.add("\n"); +const AT_SOURCE = F.source; +const AT_LENGTH = F.pos; +const ROOT_RANGE: SourceRange = { start: 0, end: AT_LENGTH }; + +// Composed construct-slice expectations (never retyped): each paired +// section's construct spans its opening tag's first character through its +// closing tag's last (SPEC 1.7). +const ABC_CONSTRUCT_TEXT = `${ABC_OPEN_TEXT}\n${DEEP_PROSE_TEXT}${COMMENT_DEEP_TEXT}\n${CLOSE_TEXT}`; +const AB_CONSTRUCT_TEXT = `${AB_OPEN_TEXT}\n${ABC_CONSTRUCT_TEXT}\n${AB_TAIL_TEXT}${CLOSE_TEXT}`; +const A_CONSTRUCT_TEXT = `${A_OPEN_TEXT}\n${A_PROSE_TEXT}${AB_CONSTRUCT_TEXT}\n${CLOSE_TEXT}`; +const Z_CONSTRUCT_TEXT = `${Z_OPEN_TEXT}\n${Z_PROSE_TEXT}${CLOSE_TEXT}`; + +// --- the view-derived resolution comparator (SPEC 11.5) ----------------------- + +/** + * The resolution-relevant projection of a view's positional tree node: + * identity datum, construct range, children in document order (SPEC 11.4). + * `ViewNode` satisfies it structurally, so decoded view data and the + * precomputed fixture tree feed the same comparator. + */ +export interface ResolutionNode { + readonly identity: string | { readonly unavailable: true }; + readonly range: SourceRange; + readonly children: readonly ResolutionNode[]; +} + +/** The view data one file's `at` resolutions are computed from (11.5). */ +export interface ResolutionData { + readonly root: ResolutionNode; + readonly occurrences: readonly OccurrenceRecord[]; +} + +/** + * Compute one offset's `at` resolution from a file's `view` data alone + * (SPEC 11.5: the same resolution is derivable from the view's data — + * `at` adds convenience, not information; T11.5-1's derivability arm, P-12 + * generalizes). Resolution is by range containment (1.7: start-inclusive, + * end-exclusive) over the positional tree: descend into the child whose + * construct range contains the offset while one does — sections nest + * properly, so the descent's fixpoint is the innermost containing section + * construct — and the root remains where no section contains the offset, + * which also realizes 11.5's EOF rule (the offset equal to the byte length + * lies in no end-exclusive construct range and resolves to the root). The + * containing occurrence is the occurrence record whose range contains the + * offset, `null` when none does. Callers pass offsets in 0…byte length; + * greater offsets are usage errors answered by no resolution (11.5). + */ +export function resolveAtFromView( + data: ResolutionData, + offset: number, +): AtResolution { + let node: ResolutionNode = data.root; + let descended = true; + while (descended) { + descended = false; + for (const child of node.children) { + if (child.range.start <= offset && offset < child.range.end) { + node = child; + descended = true; + break; + } + } + } + const occurrence = + data.occurrences.find( + (record) => record.range.start <= offset && offset < record.range.end, + ) ?? null; + return { + section: { identity: node.identity, range: node.range }, + occurrence, + }; +} + +/** Project a decoded view node onto the resolution-relevant shape. */ +function projectResolution(node: ViewNode): ResolutionNode { + return { + identity: node.identity, + range: node.range, + children: node.children.map(projectResolution), + }; +} + +// --- expected values (precomputed constants) ---------------------------------- + +const ROOT_SECTION: AtSection = { identity: AT_FILE, range: ROOT_RANGE }; +const A_SECTION: AtSection = { identity: `${AT_FILE}#a`, range: A_RANGE }; +const AB_SECTION: AtSection = { identity: `${AT_FILE}#a.b`, range: AB_RANGE }; +const ABC_SECTION: AtSection = { + identity: `${AT_FILE}#a.b.c`, + range: ABC_RANGE, +}; +const Z_SECTION: AtSection = { identity: `${AT_FILE}#z`, range: Z_RANGE }; + +/** The precomputed positional tree — the sweep's non-circular anchor. */ +const FIXTURE_TREE: ResolutionNode = { + identity: AT_FILE, + range: ROOT_RANGE, + children: [ + { + identity: A_SECTION.identity, + range: A_RANGE, + children: [ + { + identity: AB_SECTION.identity, + range: AB_RANGE, + children: [ + { + identity: ABC_SECTION.identity, + range: ABC_RANGE, + children: [], + }, + ], + }, + ], + }, + { identity: Z_SECTION.identity, range: Z_RANGE, children: [] }, + ], +}; + +// Key order mirrors the decoded `{range, name, target}` entries (12.7). +const EXPECTED_IMPORTS = [ + { range: IMPORT_ONE, name: "BASE", target: BASE_FILE }, + { range: IMPORT_TWO, name: "AUSSI", target: BASE_FILE }, +] as const; + +const EXPECTED_COMMENTS: readonly SourceRange[] = [COMMENT_TOP, COMMENT_DEEP]; + +/** + * The pointwise arms — each offset composed from the fixture's own ranges, + * each expectation a hand-stated precomputed constant (the anchor role: + * independent of any product answer). + */ +const POINTWISE_ARMS: readonly { + readonly what: string; + readonly offset: number; + readonly section: AtSection; +}[] = [ + { + what: "prose before any section (no section construct contains it)", + offset: PROSE_HEAD.start + 3, + section: ROOT_SECTION, + }, + { + what: "inside the first import declaration (top level)", + offset: IMPORT_ONE.start + 7, + section: ROOT_SECTION, + }, + { + what: "inside the top-level comment", + offset: COMMENT_TOP.start + 4, + section: ROOT_SECTION, + }, + { + what: "inside the comment within a.b.c", + offset: COMMENT_DEEP.start + 4, + section: ABC_SECTION, + }, + { + what: "deep section content (inside a.b.c)", + offset: DEEP_PROSE.start + 8, + section: ABC_SECTION, + }, + { + what: "between-section prose (between a's close and z's open)", + offset: PROSE_BETWEEN.start + 6, + section: ROOT_SECTION, + }, + { + what: "inside a.b's opening tag (the innermost containing construct is a.b itself, never the enclosing a)", + offset: AB_OPEN.start + 1, + section: AB_SECTION, + }, + { + what: "inside a.b.c's opening tag", + offset: ABC_OPEN.start + 5, + section: ABC_SECTION, + }, + { + what: "inside a's closing tag (past a.b's close, a is the innermost containing construct)", + offset: A_CLOSE.start + 2, + section: A_SECTION, + }, + { + what: "inside z's closing tag", + offset: Z_CLOSE.start + 1, + section: Z_SECTION, + }, + { + what: "content between a.b.c's close and a.b's close (the parent a.b, never the closed child)", + offset: AB_TAIL.start + 2, + section: AB_SECTION, + }, + { + what: "the offset equal to the file's byte length (the EOF caret) — the root, by 11.5's explicit rule", + offset: AT_LENGTH, + section: ROOT_SECTION, + }, +]; + +/** + * Run `at` on the staged finding-free file: exit 0 exactly (SPEC 11.2: a + * complete, finding-free answer exits 0), the entire stdout one form-exact + * 12.7 at document (SPEC 11, H-3), its findings [] (the consulted domain is + * the named file alone, and it carries none). + */ +async function runAt( + product: ProductBinding, + workspace: Workspace, + offset: number, + context: string, +): Promise<AtResolution | { readonly unavailable: true }> { + const report = decodeAtReport( + await runJson( + product, + workspace, + ["at", AT_FILE, String(offset)], + `${context} — a single JSON document is the only output form, with ` + + `or without --json, and a complete, finding-free answer exits 0 ` + + `(SPEC 11, 11.2, 11.5)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context} — the consulted domain is the named file alone, and ` + + `specs/total.mdx is finding-free (SPEC 11.5, 11.2, 12.7)`, + ); + return report.resolution; +} + +const T11_5_1 = defineProductTest({ + id: "T11.5-1", + title: + "total resolution (JSON-only, the form-exact 12.7 at document, every answer finding-free at exit 0): on a file with imports, comments, nested sections (a ⊃ a.b ⊃ a.b.c beside top-level z), and between-section prose, offsets inside an import declaration, a top-level comment, a comment within the deep section, deep section content, between-section prose, opening tags (a.b's and a.b.c's — the INNERMOST containing section construct, never the enclosing parent), closing tags (a's, past a.b's close, and z's), and content between a child's close and its parent's close each resolve to the innermost section construct whose range contains the offset — the root where none does — reported as {identity, range}: construct range and node identity per 11.2, byte-asserted against precomputed offsets behind multi-byte prose (SPEC 1.7); the offset equal to the file's byte length (the EOF caret) resolves to the root and byte length + 1 exits 2 with the single 12.7 error document as the entire stdout; derivability: for EVERY offset 0…byte length, `at`'s resolution equals the resolution computed from the file's `view` data alone — the view first anchored byte-exactly against the precomputed fixture (tree, both imports, both comments, no occurrence, findings []), so the comparator is not circular (SPEC 11.5, 11.2, 1.7, 12.7; P-12 generalizes)", + timeoutMs: 360_000, + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline) — composed-range arithmetic + // proven against the staged bytes before any product invocation. + sliceCheck(AT_SOURCE, PROSE_HEAD, PROSE_HEAD_TEXT, "the head prose"); + sliceCheck(AT_SOURCE, IMPORT_ONE, IMPORT_ONE_TEXT, "import declaration 1"); + sliceCheck(AT_SOURCE, IMPORT_TWO, IMPORT_TWO_TEXT, "import declaration 2"); + sliceCheck(AT_SOURCE, COMMENT_TOP, COMMENT_TOP_TEXT, "the top comment"); + sliceCheck(AT_SOURCE, COMMENT_DEEP, COMMENT_DEEP_TEXT, "the deep comment"); + sliceCheck(AT_SOURCE, A_OPEN, A_OPEN_TEXT, "a's opening tag"); + sliceCheck(AT_SOURCE, AB_OPEN, AB_OPEN_TEXT, "a.b's opening tag"); + sliceCheck(AT_SOURCE, ABC_OPEN, ABC_OPEN_TEXT, "a.b.c's opening tag"); + sliceCheck(AT_SOURCE, DEEP_PROSE, DEEP_PROSE_TEXT, "the deep prose"); + sliceCheck(AT_SOURCE, AB_TAIL, AB_TAIL_TEXT, "a.b's tail prose"); + sliceCheck(AT_SOURCE, A_CLOSE, CLOSE_TEXT, "a's closing tag"); + sliceCheck(AT_SOURCE, Z_CLOSE, CLOSE_TEXT, "z's closing tag"); + sliceCheck(AT_SOURCE, PROSE_BETWEEN, PROSE_BETWEEN_TEXT, "between prose"); + sliceCheck(AT_SOURCE, ABC_RANGE, ABC_CONSTRUCT_TEXT, "a.b.c's construct"); + sliceCheck(AT_SOURCE, AB_RANGE, AB_CONSTRUCT_TEXT, "a.b's construct"); + sliceCheck(AT_SOURCE, A_RANGE, A_CONSTRUCT_TEXT, "a's construct"); + sliceCheck(AT_SOURCE, Z_RANGE, Z_CONSTRUCT_TEXT, "z's construct"); + if (Buffer.byteLength(AT_SOURCE, "utf8") !== AT_LENGTH) { + fail( + `§11.5 fixture self-check — the composed byte length ` + + `${String(AT_LENGTH)} must equal the staged file's byte length ` + + `(a harness-side staging error, not a product failure)`, + ); + } + + // Comparator self-check (harness-side, before any product invocation): + // the view-derived comparator applied to the PRECOMPUTED tree must agree + // with every hand-stated pointwise expectation — so the derivability + // sweep below rests on a comparator proven against independent + // constants, not on the product's own answers. + const fixtureData: ResolutionData = { root: FIXTURE_TREE, occurrences: [] }; + for (const arm of POINTWISE_ARMS) { + assertSameJson( + resolveAtFromView(fixtureData, arm.offset), + { section: arm.section, occurrence: null }, + `§11.5 fixture self-check — offset ${String(arm.offset)} (${arm.what}): ` + + `the view-derived comparator must reproduce the hand-stated ` + + `expectation on the precomputed tree (a harness-side defect, not ` + + `a product failure)`, + ); + } + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [AT_FILE]: AT_SOURCE, + [BASE_FILE]: BASE_SOURCE, + }, + }); + try { + // --- pointwise arms: precomputed constants (the P-12 anchor) -------- + for (const arm of POINTWISE_ARMS) { + const context = `T11.5-1 \`at ${AT_FILE} ${String(arm.offset)}\` — ${arm.what}`; + const resolution = await runAt(product, workspace, arm.offset, context); + assertSameJson( + resolution, + { section: arm.section, occurrence: null }, + `${context}: the innermost section construct whose range ` + + `contains the offset — the root where none does — with its ` + + `construct range and node identity per 11.2, and no containing ` + + `occurrence (none is staged) (SPEC 11.5, 1.7, 11.2, 12.7)`, + ); + } + + // --- byte length + 1: a usage error (SPEC 11.5, 12.0) --------------- + await expectAvailabilityUsageError( + product, + workspace, + ["at", AT_FILE, String(AT_LENGTH + 1)], + `T11.5-1 offset ${String(AT_LENGTH + 1)} (byte length + 1) — an ` + + `offset greater than the file's byte length is a usage error`, + ); + + // --- the view, anchored against the precomputed fixture ------------- + const viewContext = `T11.5-1 \`view ${AT_FILE}\` (the derivability ground)`; + const viewReport = decodeViewReport( + await runJson( + product, + workspace, + ["view", AT_FILE], + `${viewContext} — the requested file is finding-free, so the ` + + `answer exits 0 (SPEC 11.4, 11.2)`, + ), + { text: false }, + viewContext, + ); + assertSameJson( + viewReport.findings, + [], + `${viewContext} — the consulted domain is the requested file ` + + `alone, and it is finding-free (SPEC 11.4, 11.2, 12.7)`, + ); + assertSameJson( + viewReport.views.map((view) => view.file), + [AT_FILE], + `${viewContext} — exactly the requested file is viewed (SPEC 11.4)`, + ); + const view = viewReport.views[0]!; + assertSameJson( + projectResolution(view.root), + FIXTURE_TREE, + `${viewContext}: the positional tree — every identity the defined ` + + `plain string, every construct range byte-exact against the ` + + `precomputed offsets — anchors the derivability sweep to the ` + + `staged fixture, so the view-derived comparator is not circular ` + + `(SPEC 11.4, 11.2, 1.7)`, + ); + assertSameJson( + view.occurrences, + [], + `${viewContext} — no reference spelling is staged, so every ` + + `resolution's occurrence member is null (SPEC 11.4, 5.7, 12.7)`, + ); + assertSameJson( + view.imports, + EXPECTED_IMPORTS, + `${viewContext} — both import declarations, byte-exact, each ` + + `resolving to the discovered specs/base.mdx (SPEC 11.4, 2.1)`, + ); + assertSameJson( + view.comments, + EXPECTED_COMMENTS, + `${viewContext} — both MDX comments, byte-exact, in document ` + + `order (SPEC 11.4, 12.7)`, + ); + + // --- the derivability sweep: every offset of the file ---------------- + const viewData: ResolutionData = { + root: view.root, + occurrences: view.occurrences, + }; + for (let offset = 0; offset <= AT_LENGTH; offset += 1) { + const context = `T11.5-1 derivability — \`at ${AT_FILE} ${String(offset)}\``; + const resolution = await runAt(product, workspace, offset, context); + assertSameJson( + resolution, + resolveAtFromView(viewData, offset), + `${context}: for every offset of the file, \`at\`'s resolution ` + + `equals the resolution computed from the file's \`view\` data ` + + `alone — the innermost containing section construct by range ` + + `containment, the root where none contains it (the EOF caret ` + + `included), and the containing occurrence (none here) — \`at\` ` + + `adds convenience, not information (SPEC 11.5, 11.4, 1.7)`, + ); + } + } finally { + await workspace.dispose(); + } + }, +}); + +export const section115Tests: readonly ProductTestEntry[] = [T11_5_1]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index fc4ad8b..eb1bdcc 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -314,6 +314,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.4-4": ["11.4"], "T11.4-5": ["11.4"], "T11.4-6": ["11.4"], + "T11.5-1": ["11.5"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], diff --git a/test/suite/section-11.5.test.ts b/test/suite/section-11.5.test.ts new file mode 100644 index 0000000..78206a8 --- /dev/null +++ b/test/suite/section-11.5.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §11.5 (SUITE-55): thin Vitest wrapper over the registered +// bodies — the identical bodies the certification runner executes against +// fixture products (C-2 "one code path"). Expected to fail as diagnosed +// assertion failures until the product exists (H-8). + +import { declareProductTests } from "./declare.js"; +import { section115Tests } from "./registry/section-11.5.js"; + +declareProductTests(section115Tests); From b06bbdaba109feab40e600500c1c7f0d41a1daca Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 20:49:02 +0000 Subject: [PATCH 112/226] sdg(phase-9): implement T11.5-2's offset spelling and operand matrix (FP-067) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 32 ++- test/suite/registry/section-11.5.ts | 379 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 406 insertions(+), 6 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index c7004ed..6b78d53 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2930,8 +2930,38 @@ certify against FP-091's fixtures once those land. reds — certification-document fixture-manifest ×2 → FP-091; S-1's unmapped keys narrowed {11.5, 11.6, 12.6, 12.7} → exactly {11.6, 12.6, 12.7} (stage G); S-5, S-7, and certification green.] -- [ ] FP-067 — Implement T11.5-2: offset spelling matrix. [R2 #5; TEST-SPEC +- [x] FP-067 — Implement T11.5-2: offset spelling matrix. [R2 #5; TEST-SPEC §11.5] + [Done 2026-08-14: T11.5-2 registered in section-11.5.ts on the T11.4-2 + matrix ground (SPEC_AND_CODE_CONFIG; finding-free specs/ok.mdx whose one + section `sept` opens at byte 6 behind a multi-byte prose head — offset 7 + inside its opening tag, offset 0 in the prose, both containments + fixture-self-checked, so `007`-as-7 vs as-0 has teeth; specs/bad.mdx with + exactly one 14.3; src/app.ts with exactly one 14.8; undiscovered + docs/note.mdx decoy), all inside one whole-root assertLeavesUnchanged. + Arms: gate `build --json` pinning exactly {14.3, 14.8} located; + `at ok.mdx 007` and `7` each exit 0, findings [], resolution byte-exactly + the precomputed sept {identity, range} with occurrence null (leading + zeros, ASCII decimal); the six rejected spellings `+7`/`-1`/`" 7"`/ + `"7 "`/`0x7`/`""` each exit 2 via expectAvailabilityUsageError (T11.2-5 + protocol) on the finding-free file AND identically on the finding-laden + bad.mdx (checks precede answering); membership/wrong-kind as T11.4-2 — + nowhere-file, on-disk undiscovered decoy, discovered code source (its own + 14.8 notwithstanding) each exit 2; control `at bad.mdx 0` exit 1 with the + full answer (root resolution complete, exactly the one located 14.3). + Traceability "T11.5-2": ["11.5"] (the T11.4-2 precedent: gate staging + integrity adds no "14"); expressly in CERTIFICATIONS.md's Exclusions, so + no fixture scope. Verified: typecheck/format clean; probes against the + built product prove the staging (build --json reports exactly the two + old-shape findings in the right files; ok.mdx finding-free) and the argv + mechanics (empty-string operand passes through; `at` is unknown-command + exit 2, stdout empty, stderr message — so the exit-2 arms fail diagnosed + at the error-document decode until the surface exists); suite: + T11.5-2 red-as-diagnosed at the gate's FP-001-class form-exact decode + ("expected no member \"condition\""), every fixture self-check passing + before it, T11.5-1 unchanged; `npm run test:self` unchanged 3 planned + mid-loop reds (certification-document ×2 → FP-091; S-1 unmapped keys + {11.6, 12.6, 12.7} → stage G), S-5 and certification green.] - [ ] FP-068 — Implement T11.5-3: occurrence containment ends and imperfect files. [R2 #5; TEST-SPEC §11.5] diff --git a/test/suite/registry/section-11.5.ts b/test/suite/registry/section-11.5.ts index e49dadd..74987ef 100644 --- a/test/suite/registry/section-11.5.ts +++ b/test/suite/registry/section-11.5.ts @@ -1,5 +1,5 @@ -// TEST-SPEC §11.5 (`xspec at`) — SUITE-55: T11.5-1 (T11.5-2 and T11.5-3 -// follow in this module as they are implemented). +// TEST-SPEC §11.5 (`xspec at`) — SUITE-55: T11.5-1 and T11.5-2 (T11.5-3 +// follows in this module as it is implemented). // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -75,17 +75,26 @@ import { decodeAtReport, decodeViewReport, } from "../../helpers/adapters/index.js"; -import { fail } from "../../helpers/assertions.js"; +import { fail, parseJsonStdout } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import type { TestWorkspace as Workspace } from "../../helpers/workspace.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { expectAvailabilityUsageError, + SPEC_AND_CODE_CONFIG, SPECS_ONLY_CONFIG, } from "./section-11.2.js"; -import { assertSameJson, runJson } from "./support.js"; +import { + assertConditionCounts, + assertFindingLocated, + assertSameJson, + buildFindings, + expectExit, + runJson, +} from "./support.js"; /** * Running byte-offset fixture assembler (the T5.7-2/T11.2-1/T11.4-1 @@ -597,4 +606,364 @@ const T11_5_1 = defineProductTest({ }, }); -export const section115Tests: readonly ProductTestEntry[] = [T11_5_1]; +// --- T11.5-2 — offset spelling and operands (SPEC 11.5, 12.0) ----------------- +// +// The matrix ground (failing on purpose — the T11.4-2 discipline): a +// finding-free spec source whose one section opens BEFORE byte offset 7 +// behind a multi-byte prose head (so `007` read as decimal 7 resolves into +// the section while a product reading the spelling as 0 resolves to the +// root — the acceptance arm's teeth), a finding-laden spec source carrying +// exactly one 14.3 (the "same errors on a finding-laden file" ground), a +// discovered code source carrying exactly one 14.8 (the wrong-kind operand, +// its own finding notwithstanding), and an on-disk decoy no configured +// group discovers (membership is in the DISCOVERED set, SPEC 7 — a product +// resolving operands against the filesystem accepts it and answers, or +// surfaces its 14.20, instead of erring). +// +// Certification note: T11.5-2 is expressly in CERTIFICATIONS.md's +// Exclusions — the argument, spelling, and domain-and-exit matrices of the +// machine-interface surfaces (T11.2-5, T11.3-2/3, T11.4-2, T11.5-2) are +// certified representatively through the shared machinery — so, like +// T11.4-2, this body freely drives the gate-reference `build` and the +// whole-root snapshot compare. + +const OS_OK_FILE = "specs/ok.mdx"; +const OS_PROSE_TEXT = "Pré.\n"; // 6 bytes (é is 2): the head prose [0, 6) +const OS_SEPT_OPEN_TEXT = '<S id="sept">'; +const OS_SEPT_BODY_TEXT = "\nTexte visé.\n"; + +const OS = new ByteFixture(); +const OS_PROSE = OS.add(OS_PROSE_TEXT); +const OS_SEPT_OPEN = OS.add(OS_SEPT_OPEN_TEXT); +OS.add(OS_SEPT_BODY_TEXT); +OS.add(CLOSE_TEXT); +const OS_SEPT_RANGE: SourceRange = { start: OS_SEPT_OPEN.start, end: OS.pos }; +OS.add("\n"); +const OS_OK_SOURCE = OS.source; + +const OS_SEPT_CONSTRUCT_TEXT = `${OS_SEPT_OPEN_TEXT}${OS_SEPT_BODY_TEXT}${CLOSE_TEXT}`; + +/** Offset 7's precomputed resolution — the anchor `007` must reproduce. */ +const OS_SEPT_SECTION: AtSection = { + identity: `${OS_OK_FILE}#sept`, + range: OS_SEPT_RANGE, +}; + +// The finding-laden spec source: prose before any section (so offset 0 +// resolves to the root, its identity the defined path — the control arm's +// answer is complete, exit 1 riding on the finding alone), then two +// sections both spelling `twin` — exactly one 14.3, locating every bearer. +const OS_BAD_FILE = "specs/bad.mdx"; +const OS_BAD = new ByteFixture(); +OS_BAD.add("Préambule fautif — hors de toute section.\n"); +OS_BAD.add('<S id="twin">\nUn.\n</S>\n'); +OS_BAD.add('<S id="twin">\nDeux.\n</S>\n'); +const OS_BAD_SOURCE = OS_BAD.source; + +/** Offset 0's resolution in the finding-laden file: the root (SPEC 11.5). */ +const OS_BAD_ROOT: AtSection = { + identity: OS_BAD_FILE, + range: { start: 0, end: OS_BAD.pos }, +}; + +// The discovered code source (SPEC 7.2): one string-form `text(...)` marker +// — exactly one 14.8 (SPEC 4.3) — beside a resolving reference, so the +// wrong-kind operand is itself finding-laden and the argument check's +// precedence over answering is sharp (T11.4-2's discipline). +const OS_CODE_FILE = "src/app.ts"; +const OS_CODE_SOURCE = [ + 'import SPEC, { text } from "../specs/ok.xspec";', + "", + "export function grab(): void {", + " SPEC.sept;", + "}", + "", + "export function bad(): string {", + ' return text("sept");', + "}", + "", +].join("\n"); + +// On disk but in no configured group (SPEC 7): unknown as an operand. Its +// unclosed tag makes a filesystem-resolving product's acceptance loud — it +// answers or surfaces a spurious 14.20 instead of the usage error. +const OS_DECOY_FILE = "docs/note.mdx"; +const OS_DECOY_SOURCE = '<S id="piège">\nJamais fermé.\n'; + +/** The workspace's complete finding multiset (the `build --json` gate). */ +const OS_WORKSPACE_CONDITIONS: Readonly<Record<string, number>> = { + "14.3": 1, + "14.8": 1, +}; + +/** + * The rejected `<offset>` spellings (SPEC 11.5): anything but one or more + * ASCII decimal digits — a sign, whitespace, or any other character is not + * a non-negative integer's spelling. Each runs twice: on the finding-free + * file and on the finding-laden one (the argument checks precede answering, + * SPEC 11.2, T11.2-5). + */ +const OS_REJECTED_SPELLINGS: readonly { + readonly spelling: string; + readonly what: string; +}[] = [ + { spelling: "+7", what: "a plus sign is not a digit" }, + { + spelling: "-1", + what: "a minus sign is not a digit (no negative offset has a spelling)", + }, + { spelling: " 7", what: "leading whitespace is not a digit" }, + { spelling: "7 ", what: "trailing whitespace is not a digit" }, + { + spelling: "0x7", + what: "a hexadecimal prefix is not a digits-only decimal spelling", + }, + { spelling: "", what: "an empty value spells no non-negative integer" }, +]; + +const T11_5_2 = defineProductTest({ + id: "T11.5-2", + title: + '`007` is accepted as 7 — leading zeros permitted, the value read in ASCII decimal: on a file whose one section opens before byte 7 behind a multi-byte prose head, `at specs/ok.mdx 007` answers exit 0, findings [], with byte-exactly offset 7\'s precomputed resolution (the section whose opening tag contains it — a product reading the spelling as 0 resolves to the root and fails), equal to the plain-`7` invocation\'s answer — while `+7`, `-1`, `" 7"`, `"7 "`, `0x7`, and an empty value are each not a digits-only spelling: exit 2 with the single 12.7 error document as the entire stdout, the same six spellings on the finding-laden specs/bad.mdx exiting 2 identically (the argument checks precede answering, never exit 1 with the domain\'s findings); `<file>` membership and wrong-kind checks as T11.4-2: an operand existing nowhere, an on-disk docs/note.mdx no configured group discovers, and a discovered code source — its own staged 14.8 notwithstanding — each exit 2; and the finding-laden file still answers when the arguments are valid: `at specs/bad.mdx 0` exits 1 with the full answer, the root resolution complete beside exactly its one 14.3, no invocation of the sweep modifying anything (SPEC 11.5, 11.2, 12.0, 12.7, 7)', + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline) — the staging arithmetic the + // acceptance arm's teeth rest on, proven before any product invocation. + sliceCheck(OS_OK_SOURCE, OS_PROSE, OS_PROSE_TEXT, "T11.5-2's head prose"); + sliceCheck( + OS_OK_SOURCE, + OS_SEPT_OPEN, + OS_SEPT_OPEN_TEXT, + "T11.5-2 sept's opening tag", + ); + sliceCheck( + OS_OK_SOURCE, + OS_SEPT_RANGE, + OS_SEPT_CONSTRUCT_TEXT, + "T11.5-2 sept's construct", + ); + if (!(OS_SEPT_OPEN.start <= 7 && 7 < OS_SEPT_OPEN.end)) { + fail( + `§11.5 fixture self-check — byte offset 7 must fall inside sept's ` + + `opening tag [${String(OS_SEPT_OPEN.start)}, ` + + `${String(OS_SEPT_OPEN.end)}) so \`007\` read as decimal 7 ` + + `resolves into the section (a harness-side staging error, not a ` + + `product failure)`, + ); + } + if (!(OS_PROSE.start <= 0 && 0 < OS_PROSE.end)) { + fail( + `§11.5 fixture self-check — byte offset 0 must fall inside the ` + + `head prose so a product reading \`007\` as 0 resolves to the ` + + `root, not to sept (a harness-side staging error, not a product ` + + `failure)`, + ); + } + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPEC_AND_CODE_CONFIG, + [OS_OK_FILE]: OS_OK_SOURCE, + [OS_BAD_FILE]: OS_BAD_SOURCE, + [OS_CODE_FILE]: OS_CODE_SOURCE, + [OS_DECOY_FILE]: OS_DECOY_SOURCE, + }, + }); + try { + await assertLeavesUnchanged( + workspace.root, + async () => { + // Gate reference and staging integrity (SPEC 12.1, 14): exactly + // one 14.3 in bad.mdx and one 14.8 in the discovered code + // source, nothing else — ok.mdx is finding-free and the decoy is + // in no configured group, contributing nothing (SPEC 7). + const gateContext = + "T11.5-2 `build --json` (staging integrity: one 14.3 in " + + "specs/bad.mdx, one 14.8 in src/app.ts; specs/ok.mdx " + + "finding-free; the undiscovered docs/note.mdx contributes " + + "nothing)"; + const gateFindings = await buildFindings( + product, + workspace, + gateContext, + ); + assertConditionCounts( + gateFindings, + OS_WORKSPACE_CONDITIONS, + `${gateContext} — exactly the staged conditions (SPEC 14)`, + ); + assertFindingLocated( + gateFindings.find((finding) => finding.condition === "14.3")!, + { file: OS_BAD_FILE }, + `${gateContext} — the duplicate \`twin\` pair locates every ` + + `bearer, both in specs/bad.mdx (SPEC 14)`, + ); + assertFindingLocated( + gateFindings.find((finding) => finding.condition === "14.8")!, + { file: OS_CODE_FILE }, + `${gateContext} — the string-form \`text("sept")\` call ` + + `locates in the code source (SPEC 4.3, 14)`, + ); + + // --- `007` is accepted as 7 (SPEC 11.5): leading zeros are + // permitted and the value is read in decimal, so the answer is + // byte-exactly offset 7's — the section whose opening tag + // contains byte 7, never offset 0's root — and equals the + // plain-`7` invocation's, both pinned to the same precomputed + // constant. Findings [] beside: the consulted domain is the + // named file alone, and ok.mdx is finding-free — the + // workspace's staged 14.3/14.8 are no domain file's findings + // (SPEC 11.2), so exit 0. + const expectedSeven = { + section: OS_SEPT_SECTION, + occurrence: null, + }; + for (const spelling of ["007", "7"] as const) { + const context = `T11.5-2 \`at ${OS_OK_FILE} ${spelling}\``; + const report = decodeAtReport( + await runJson( + product, + workspace, + ["at", OS_OK_FILE, spelling], + `${context} — \`${spelling}\` is one-or-more ASCII decimal ` + + `digits, read in decimal as 7 (leading zeros permitted), ` + + `and the named file's domain is finding-free, so the ` + + `answer exits 0 (SPEC 11.5, 11.2)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context} — the consulted domain is the named file alone ` + + `and specs/ok.mdx is finding-free: the workspace's staged ` + + `14.3/14.8 are no domain file's findings (SPEC 11.2, 11.5)`, + ); + assertSameJson( + report.resolution, + expectedSeven, + `${context} — the spelling is read in ASCII decimal as ` + + `offset 7, which lies inside sept's opening tag: the ` + + `innermost containing section construct, byte-exactly ` + + `{identity, range}, occurrence null — a product reading ` + + `\`007\` as 0 resolves to the root instead (SPEC 11.5, ` + + `1.7, 11.2, 12.7)`, + ); + } + + // --- The rejected spellings (SPEC 11.5, 12.0): each exits 2 + // with the single 12.7 error document — on the finding-free + // file, and identically on the finding-laden one: the argument + // checks precede answering, never exit 1 with the domain's + // findings (SPEC 11.2, T11.2-5's protocol). + for (const { spelling, what } of OS_REJECTED_SPELLINGS) { + await expectAvailabilityUsageError( + product, + workspace, + ["at", OS_OK_FILE, spelling], + `T11.5-2 offset value ${JSON.stringify(spelling)} on the ` + + `finding-free file (${what} — not one-or-more ASCII ` + + `decimal digits, SPEC 11.5)`, + ); + await expectAvailabilityUsageError( + product, + workspace, + ["at", OS_BAD_FILE, spelling], + `T11.5-2 offset value ${JSON.stringify(spelling)} on the ` + + `FINDING-LADEN specs/bad.mdx (${what}): the argument ` + + `checks precede answering, so the usage error exits 2 ` + + `whatever findings the named file carries — never exit 1 ` + + `with its 14.3 (SPEC 11.2, 11.5)`, + ); + } + + // --- `<file>` membership and wrong-kind checks as T11.4-2 + // (SPEC 11.5: `<file>` asserts domain membership exactly as a + // `view` operand does; 11.4, 12.0) — each with a well-formed + // offset, so the operand is each arm's sole defect. + await expectAvailabilityUsageError( + product, + workspace, + ["at", "specs/Nope.mdx", "0"], + "T11.5-2 unknown `<file>` operand (a file existing nowhere) " + + "on the failing workspace", + ); + await expectAvailabilityUsageError( + product, + workspace, + ["at", OS_DECOY_FILE, "0"], + "T11.5-2 unknown `<file>` operand (docs/note.mdx exists on " + + "disk but no configured group discovers it — membership is " + + "in the DISCOVERED set, SPEC 7) on the failing workspace", + ); + await expectAvailabilityUsageError( + product, + workspace, + ["at", OS_CODE_FILE, "0"], + "T11.5-2 wrong-kind `<file>` operand (src/app.ts is a " + + "discovered CODE source, and `at` resolves positions in " + + "spec sources — SPEC 11.5, 11.4, 12.0), its own staged " + + "14.8 notwithstanding: the argument checks precede " + + "answering, never exit 1 with the file's findings", + ); + + // --- Control: the finding-laden file ANSWERS when the + // arguments are valid (SPEC 11.2: exit 1 signals imperfection + // and never withholds the answer) — pinning that the exit-2s + // above are the argument checks' doing, not a product erring on + // every invocation that names bad.mdx. + { + const context = `T11.5-2 \`at ${OS_BAD_FILE} 0\` (the control: valid arguments on the finding-laden file)`; + const result = await expectExit( + product, + workspace, + ["at", OS_BAD_FILE, "0"], + 1, + `${context} — the domain file's 14.3 accompanies the ` + + `answer, so exit 1 with the full answer still emitted ` + + `(SPEC 11.2, 11.5)`, + ); + const report = decodeAtReport( + parseJsonStdout( + result, + `${context} — the full answer document is still emitted, ` + + `complete and parseable (SPEC 11.2, H-5)`, + ), + context, + ); + assertConditionCounts( + report.findings, + { "14.3": 1 }, + `${context} — exactly the named file's one finding ` + + `accompanies; the code source's 14.8 is no domain file's ` + + `finding (SPEC 11.2, 14)`, + ); + assertFindingLocated( + report.findings[0]!, + { file: OS_BAD_FILE }, + `${context} — the duplicate \`twin\` finding locates every ` + + `bearer in the named file (SPEC 14)`, + ); + assertSameJson( + report.resolution, + { section: OS_BAD_ROOT, occurrence: null }, + `${context} — offset 0 lies in the head prose, so the ` + + `resolution is the root, complete: identity the defined ` + + `path, range the whole file, occurrence null — the ` + + `duplicate bearers' undefined identities are never ` + + `consulted here (SPEC 11.5, 11.2, 1.5)`, + ); + } + }, + "T11.5-2 — no invocation of the sweep modifies anything: the gate " + + "build fails writing nothing (SPEC 12.1) and on a failing " + + "workspace these surfaces answer from current sources and write " + + "nothing (SPEC 11.2; the no-write contract clauses live at " + + "T11.2-1/T11.2-6)", + ); + } finally { + await workspace.dispose(); + } + }, +}); + +export const section115Tests: readonly ProductTestEntry[] = [T11_5_1, T11_5_2]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index eb1bdcc..a821d3c 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -315,6 +315,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.4-5": ["11.4"], "T11.4-6": ["11.4"], "T11.5-1": ["11.5"], + "T11.5-2": ["11.5"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From 2d79e1a81c30a553fd162b4e76a58911e5be1214 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 21:12:05 +0000 Subject: [PATCH 113/226] sdg(phase-9): implement T11.5-3's occurrence containment and imperfect files (FP-068) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 71 +++- test/suite/registry/section-11.2.ts | 25 +- test/suite/registry/section-11.5.ts | 608 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 4 files changed, 691 insertions(+), 14 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 6b78d53..1124f09 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -2962,8 +2962,47 @@ certify against FP-091's fixtures once those land. before it, T11.5-1 unchanged; `npm run test:self` unchanged 3 planned mid-loop reds (certification-document ×2 → FP-091; S-1 unmapped keys {11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [ ] FP-068 — Implement T11.5-3: occurrence containment ends and imperfect +- [x] FP-068 — Implement T11.5-3: occurrence containment ends and imperfect files. [R2 #5; TEST-SPEC §11.5] + [Done 2026-08-14: T11.5-3 registered in section-11.5.ts. One workspace + (SPECS_ONLY_CONFIG), one whole-root assertLeavesUnchanged: finding-free + specs/occ.mdx (multi-byte prose head; blank-line-separated import binding + CIBLE — load-bearing: MDX block grammar makes a paragraph-glued import + prose, see FP-094; section `host` bearing `d={CIBLE.but}` and embedding + `{text(CIBLE.but)}`, both resolving into specs/cible.mdx#but) plus + unparseable specs/casse.mdx (14.20) and, staged where file names are byte + strings (Linux, the T11.2-3 precedent), non-UTF-8-named + specs/nu<0xFF>.mdx (14.19). Arms: gate `build --json` pinning exactly + {14.20 located, 14.19 code/locations-[]/marked-byte path}; eight + containment offsets — each occurrence's start and end−1 → the full 12.7 + record (file, byte-exact range, kind depends/embeds, source node + {identity, range} = host, resolved target), its end and start−1 → none + (start-inclusive, end-exclusive, 1.7), every answer findings [] exit 0 + (per-file domain on the failing workspace), section pinned to host + throughout, arm-table containment fixture-self-checked; `at casse.mdx` at + 0 AND the EOF caret → resolution exactly the unavailability marker (no + root fallback bypasses the mask) beside exactly the located 14.20, exit + 1; four non-UTF-8 spellings — exact path bytes as raw argv (the widened + expectAvailabilityUsageError now takes ArgvValue, runProduct-backed, the + T6.5-5 trampoline), lossy U+FFFD, marked-byte-form JSON round-trip, + percent-encoded — each exit 2 via the T11.2-5 protocol, and + `view --file specs/nu*.mdx` (byte-wise glob) as the one route to its + positions: exit 1, exactly the 14.19 projection, one view with `file` in + marked byte form and the tree byte-exact, every identity unavailable + (projectResolution reuse). Traceability "T11.5-3": ["11.5"] (the §11 + precedent: accompanying-finding assertions add no "14"); expressly + outside CONF-AVAIL ("no in-scope staging drives `at`"), no fixture scope. + Verified: typecheck/format clean; probes against the built product prove + the staging (build --json exactly the two old-shape findings in the right + files, occ/cible contributing nothing — resolution proven by a control + probe where the unseparated import left CIBLE unbound and 14.8 fired at + both spellings) and the channel (`at` unknown-command exit 2 on every + spelling incl. the sh-trampoline raw-bytes leg; `view` likewise); suite: + T11.5-3 red-as-diagnosed at the gate's FP-001-class form-exact decode + ("expected no member \"column\""), every fixture self-check passing + before it, T11.5-1/T11.5-2 unchanged; `npm run test:self` unchanged 3 + planned mid-loop reds (certification-document ×2 → FP-091; S-1 unmapped + keys {11.6, 12.6, 12.7} → stage G), S-5, S-7, and certification green.] - [ ] FP-069 — Implement T11.6-1: `inventory` anchoring byte-exact, incl. the E-6 drive-mismatch arm (Linux side; the Windows-subset arm is @@ -3109,3 +3148,33 @@ certify against FP-091's fixtures once those land. T11.6-1. [R2 #43; TEST-SPEC §11.6 (E-6 arm)] After FP-069. `test/windows/` (beside `e6-subset.test.ts`): the drive-mismatch anchoring arm runs on the Windows leg only. + +## Stage K — findings discovered mid-loop + +- [ ] FP-094 — Restage T11.5-1's fixture so its imports (and top-level + comment) are real MDX blocks, not paragraph prose. [Found 2026-08-14 + during FP-068; TEST-SPEC §11.5 T11.5-1, SPEC 1 ("an MDX document"), 2.1] + `test/suite/registry/section-11.5.ts` (FP-066's fixture, ~lines 184-230): + specs/total.mdx separates its head prose, both import declarations, and + the top-level comment by SINGLE newlines — under MDX block grammar that + whole run is one paragraph, so the two `import` lines are paragraph + prose, not import declarations. Proven against the built product: with + IMPORT_ONE's specifier changed to `"./typo.xspec"` in the exact staged + layout, `build --json` stays finding-free (a parsed import would report + 14.15), while the same import blank-line-separated binds (and its + unbound references report 14.8). Consequences when a conforming product + lands: the `view.imports` anchor (EXPECTED_IMPORTS: two entries with + resolved targets) fails as a harness staging defect — the product will + report no import declarations — and the "inside the first import + declaration" pointwise arm probes prose (its root resolution stays + correct, its label wrong); EXPECTED_COMMENTS' top entry rides the same + paragraph (whether an inline `{/* */}` inside a paragraph is a + comment-range entry is unverifiable until `view` exists). Fix: insert + blank-line segments so the imports and the top comment start their own + blocks (composed constants recompute every offset; the derivability + comparator and self-checks adapt), keep the deep comment's in-section + placement only if the landed product classifies it as a comment range, + and re-verify: fixture self-checks pass, `build --json` finding-free, + a typo-specifier control probe now DOES report 14.15 on the restaged + layout. No other §11 fixture shares the hazard (11.2/11.3/11.4 + layouts checked 2026-08-14: all imports start a block). diff --git a/test/suite/registry/section-11.2.ts b/test/suite/registry/section-11.2.ts index d9ca027..8a5c55f 100644 --- a/test/suite/registry/section-11.2.ts +++ b/test/suite/registry/section-11.2.ts @@ -95,7 +95,8 @@ import { import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; -import type { ProductBinding } from "../../helpers/subprocess.js"; +import { runProduct } from "../../helpers/subprocess.js"; +import type { ArgvValue, ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { assertConditionCounts, @@ -3270,19 +3271,27 @@ function assertCycleFindingsWhole( * report and no answer beside it), and the usage message on stderr (12.0). * Exported: the per-surface spelling matrices (T11.3-2/3, T11.4-2, T11.5-2) * assert their exit-2 arms through this same protocol - * (registry/section-11.3.ts imports, never copies). + * (registry/section-11.3.ts imports, never copies). Accepts raw-byte argv + * elements (`ArgvValue`) for T11.5-3's Linux-leg non-UTF-8 `at` spellings + * (the T6.5-5/T12.0-5 precedent: argv is a byte channel there, carried by + * the subprocess driver's raw-byte argv support). */ export async function expectAvailabilityUsageError( product: ProductBinding, workspace: TestWorkspace, - argv: readonly string[], + argv: readonly ArgvValue[], context: string, ): Promise<void> { - const command = `xspec ${argv.join(" ")}`; - const result = await expectExit( - product, - workspace, - argv, + const command = `xspec ${argv + .map((arg) => + typeof arg === "string" + ? arg + : `<bytes 0x${Buffer.from(arg).toString("hex")}>`, + ) + .join(" ")}`; + const result = await runProduct(product, { cwd: workspace.root, argv }); + assertExitCode( + result, 2, `${context}: \`${command}\` — the argument checks of 11.3–11.5 precede ` + `answering, so the usage error exits 2 whatever findings the ` + diff --git a/test/suite/registry/section-11.5.ts b/test/suite/registry/section-11.5.ts index 74987ef..212a471 100644 --- a/test/suite/registry/section-11.5.ts +++ b/test/suite/registry/section-11.5.ts @@ -1,5 +1,4 @@ -// TEST-SPEC §11.5 (`xspec at`) — SUITE-55: T11.5-1 and T11.5-2 (T11.5-3 -// follows in this module as it is implemented). +// TEST-SPEC §11.5 (`xspec at`) — SUITE-55: T11.5-1, T11.5-2, and T11.5-3. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -67,6 +66,7 @@ import { Buffer } from "node:buffer"; import type { AtResolution, AtSection, + Finding, OccurrenceRecord, SourceRange, ViewNode, @@ -75,11 +75,15 @@ import { decodeAtReport, decodeViewReport, } from "../../helpers/adapters/index.js"; -import { fail, parseJsonStdout } from "../../helpers/assertions.js"; +import { + assertExitCode, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; -import type { ProductBinding } from "../../helpers/subprocess.js"; +import type { ArgvValue, ProductBinding } from "../../helpers/subprocess.js"; import type { TestWorkspace as Workspace } from "../../helpers/workspace.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { @@ -93,6 +97,7 @@ import { assertSameJson, buildFindings, expectExit, + runCli, runJson, } from "./support.js"; @@ -966,4 +971,597 @@ const T11_5_2 = defineProductTest({ }, }); -export const section115Tests: readonly ProductTestEntry[] = [T11_5_1, T11_5_2]; +// --- T11.5-3 — occurrence containment and imperfect files --------------------- +// +// SPEC 11.5: "when the offset lies within a reference occurrence's range, +// that occurrence and its resolved target (5.7)" — containment under the one +// range convention of 1.7 (start-inclusive, end-exclusive); "on an +// unparseable file the resolution is reported explicitly unavailable, the +// parse-failure finding accompanying it (11.2)"; and "a discovered spec +// source whose path is not valid UTF-8 is nameable by no argument value +// (12.0), so `at` cannot address it: for such a file (14.19) the view, +// reached by glob (11.4), is the one route to position data". +// +// One workspace (SPECS_ONLY_CONFIG), four spec sources: +// +// - specs/occ.mdx — the containment ground, finding-free: behind a +// multi-byte prose head (SPEC 1.7), a blank-line-separated import binding +// CIBLE (MDX block grammar: an import glued to a paragraph is prose, so +// the separation is load-bearing), then one section `host` bearing +// `d={CIBLE.but}` on its opening tag and an MDX embedding +// `{text(CIBLE.but)}` in its body — both spellings resolve into +// specs/cible.mdx#but, so both record occurrences (SPEC 5.7): the `d` +// occurrence spans that one reference's own expression (`CIBLE.but`), the +// embedding occurrence the entire braced container, opening brace through +// closing brace. Offsets at each range's start and at end − 1 report the +// containing occurrence's full 12.7 record — file, byte-exact range, kind +// (`depends` / `embeds`), source graph node {identity, range} (`host` and +// its construct range for both spellings, SPEC 2.2, 2.3), and resolved +// target — while the end offset and the byte immediately before the start +// report none (`occurrence` null), realizing 1.7's start-inclusive, +// end-exclusive convention on both edges. Every probed offset lies inside +// `host`'s construct and inside no other section, so `section` is pinned +// to the same {identity, range} constant throughout, and each answer is +// findings [] at exit 0: the consulted domain is the named file alone +// (SPEC 11.2) — the workspace's other findings (below) never attach, the +// sharpest per-file contrast on a failing workspace. +// - specs/cible.mdx — the finding-free reference target. +// - specs/casse.mdx — unparseable (unclosed section tag, 14.20): `at` at +// offset 0 AND at the EOF caret (the byte-length offset — an offset the +// argument checks accept, byte length being a property of the bytes, not +// the parse) each answer with `resolution` exactly the unavailability +// marker — never a root fallback bypassing the mask — beside exactly the +// file's one parse-failure finding, exit 1 (SPEC 11.5, 11.2, 12.7). +// - specs/nu<0xFF>.mdx — non-UTF-8-named (14.19), staged exactly when the +// platform's file names are byte strings (`process.platform === "linux"`, +// the T11.2-3/T6.5-5 precedent for the entry's "Linux leg" note; every +// expectation is parameterized on that staging, so the Linux CI leg runs +// the whole entry and no platform skips the test, H-9). The file is +// nameable by no argument value (SPEC 12.0: argument values are UTF-8): +// representative `at` spellings — the exact on-disk path bytes as raw +// argv (the sharpest: a product resolving byte argv against the +// filesystem finds the file and answers), the lossy U+FFFD decode, the +// marked-byte-form JSON rendering (the product's OWN output spelling for +// the path, 12.7 — still no argument value), and a percent-encoded +// rendering — each an unknown file, exit 2 with the single 12.7 error +// document, via the shared T11.2-5 protocol. The glob-reached view stays +// the one route to its positions: `view --file specs/nu*.mdx` (the +// byte-wise glob rules of SPEC 7 match the 0xFF byte; the pattern admits +// no other staged file) answers exit 1 with exactly the file's +// condition-19 finding (stable code `invalid-source-path`, no locations, +// the marked-byte-form concerned path) and its one view — `file` in the +// marked byte form, the full positional tree byte-exact with every node +// identity, root included, explicitly unavailable (SPEC 11.2, 11.4, +// 12.0, 12.7; T11.2-3 owns the whole-domain sweep). +// +// The gate `build --json` doubles as staging integrity (exactly casse's +// 14.20 plus — where staged — nu's 14.19, so occ.mdx and cible.mdx are +// proven finding-free on pinned ground), and the whole sweep rides one +// whole-root snapshot compare: the failing build writes nothing (SPEC 12.1) +// and on a failing workspace these surfaces answer from current sources and +// write nothing (SPEC 11.2; the no-write contract clauses live at +// T11.2-1/T11.2-6). +// +// Certification note: CONF-AVAIL's scope expressly excludes `at` ("no +// in-scope staging drives `at`" — CERTIFICATIONS.md), and T11.5-3 is in no +// other fixture's scope; its answer-side decode rigor is certified through +// the CONF-AVAIL datum-form violators (the shared 12.7 machinery) and its +// exit-2 arms ride the Exclusions-certified shared protocol. + +const UNAVAILABLE = { unavailable: true } as const; + +const OC_FILE = "specs/occ.mdx"; +const OC_TGT_FILE = "specs/cible.mdx"; +const OC_CASSE_FILE = "specs/casse.mdx"; + +const OC_HEAD_TEXT = "Tête — préambule multi-octets.\n"; +const OC_IMPORT_TEXT = 'import CIBLE from "./cible.xspec"'; +const OC_HOST_PRE_TEXT = '<S id="host" d={'; +const OC_DREF_TEXT = "CIBLE.but"; +const OC_HOST_POST_TEXT = "}>"; +const OC_BODY_TEXT = "Corps local.\n"; +const OC_EMB_TEXT = "{text(CIBLE.but)}"; +const OC_TAIL_TEXT = "Queue après l’ancre.\n"; + +const OC = new ByteFixture(); +OC.add(OC_HEAD_TEXT); +OC.add("\n"); // blank line: the import must start its own MDX block +OC.add(OC_IMPORT_TEXT); +OC.add("\n\n"); +const OC_HOST_START = OC.pos; +OC.add(OC_HOST_PRE_TEXT); +const OC_DREF = OC.add(OC_DREF_TEXT); +OC.add(OC_HOST_POST_TEXT); +const OC_HOST_OPEN: SourceRange = { start: OC_HOST_START, end: OC.pos }; +OC.add("\n"); +OC.add(OC_BODY_TEXT); +const OC_EMB = OC.add(OC_EMB_TEXT); +OC.add("\n"); +OC.add(OC_TAIL_TEXT); +OC.add(CLOSE_TEXT); +const OC_HOST_RANGE: SourceRange = { start: OC_HOST_START, end: OC.pos }; +OC.add("\n"); +const OC_SOURCE = OC.source; + +const OC_HOST_OPEN_TEXT = `${OC_HOST_PRE_TEXT}${OC_DREF_TEXT}${OC_HOST_POST_TEXT}`; +const OC_HOST_CONSTRUCT_TEXT = `${OC_HOST_OPEN_TEXT}\n${OC_BODY_TEXT}${OC_EMB_TEXT}\n${OC_TAIL_TEXT}${CLOSE_TEXT}`; + +const OC_TGT_SOURCE = 'Cible du dossier.\n\n<S id="but">\nTexte visé.\n</S>\n'; + +/** Every probed offset resolves to `host` (no section nests inside it). */ +const OC_HOST_SECTION: AtSection = { + identity: `${OC_FILE}#host`, + range: OC_HOST_RANGE, +}; + +/** Both spellings' source graph node: `host` (SPEC 2.2, 2.3, 5.7). */ +const OC_SOURCE_NODE = { + identity: `${OC_FILE}#host`, + range: OC_HOST_RANGE, +} as const; +const OC_TARGET = `${OC_TGT_FILE}#but`; + +/** The `d` occurrence: that one reference's own expression (SPEC 5.7). */ +const OC_D_RECORD: OccurrenceRecord = { + file: OC_FILE, + range: OC_DREF, + kind: "depends", + source: OC_SOURCE_NODE, + target: OC_TARGET, +}; + +/** The embedding occurrence: the entire braced container (SPEC 5.7). */ +const OC_EMB_RECORD: OccurrenceRecord = { + file: OC_FILE, + range: OC_EMB, + kind: "embeds", + source: OC_SOURCE_NODE, + target: OC_TARGET, +}; + +/** + * The containment arms (SPEC 11.5, 1.7): per occurrence, its start and its + * end − 1 lie within — the record reported with its resolved target — while + * its end and the byte immediately before its start lie outside — none + * reported. A fixture self-check proves each arm's offset against the + * claimed ranges before any product invocation. + */ +const OC_CONTAINMENT_ARMS: readonly { + readonly what: string; + readonly offset: number; + readonly occurrence: OccurrenceRecord | null; +}[] = [ + { + what: "the d reference expression's start (start-inclusive, SPEC 1.7)", + offset: OC_DREF.start, + occurrence: OC_D_RECORD, + }, + { + what: "the d reference expression's end − 1 (the last within-range byte)", + offset: OC_DREF.end - 1, + occurrence: OC_D_RECORD, + }, + { + what: "the d reference expression's end (end-exclusive: outside, SPEC 1.7)", + offset: OC_DREF.end, + occurrence: null, + }, + { + what: "the byte immediately before the d reference expression (outside)", + offset: OC_DREF.start - 1, + occurrence: null, + }, + { + what: "the embedding container's start — its opening brace (SPEC 5.7)", + offset: OC_EMB.start, + occurrence: OC_EMB_RECORD, + }, + { + what: "the embedding container's end − 1 — its closing brace, within range", + offset: OC_EMB.end - 1, + occurrence: OC_EMB_RECORD, + }, + { + what: "the embedding container's end (end-exclusive: outside, SPEC 1.7)", + offset: OC_EMB.end, + occurrence: null, + }, + { + what: "the byte immediately before the embedding container (outside)", + offset: OC_EMB.start - 1, + occurrence: null, + }, +]; + +// The unparseable file (14.20: unclosed section tag; the T11.2-1 shape). +// Composed through ByteFixture so the EOF-caret offset is the same +// arithmetic the staged bytes are. +const OC_CASSE = new ByteFixture(); +OC_CASSE.add("Cassé dès l’ouverture.\n\n"); +OC_CASSE.add('<S id="seul">\nJamais fermé.\n'); +const OC_CASSE_SOURCE = OC_CASSE.source; +const OC_CASSE_LENGTH = OC_CASSE.pos; + +// --- specs/nu<0xFF>.mdx — non-UTF-8-named spec source (14.19, Linux leg) ----- +// 0xFF can occur in no valid UTF-8 sequence, so the workspace-relative path +// is not valid UTF-8; the byte-wise glob rules of SPEC 7 still discover it. +// The marked byte form is composed from the SAME bytes that stage the file +// (never measured from product output). +const NU3_STAGED = process.platform === "linux"; +const NU3_PATH_BYTES = Buffer.concat([ + Buffer.from("specs/nu", "utf8"), + Buffer.from([0xff]), + Buffer.from(".mdx", "utf8"), +]); +const NU3_MARKED_PATH = { bytes: NU3_PATH_BYTES.toString("hex") } as const; +const NU3 = new ByteFixture(); +NU3.add("Prólogo — chemin invalide.\n\n"); +const NU3_SEC_START = NU3.pos; +NU3.add('<S id="solo">\nTexte positionné.\n</S>'); +const NU3_SEC_RANGE: SourceRange = { start: NU3_SEC_START, end: NU3.pos }; +NU3.add("\n"); +const NU3_SOURCE = NU3.source; +const NU3_ROOT_RANGE: SourceRange = { start: 0, end: NU3.pos }; + +/** + * Representative `at` spellings for the non-UTF-8-pathed source (SPEC 12.0: + * argument values are UTF-8, so NO value names it — each is an unknown + * file, exit 2, whatever the spelling's provenance). + */ +const NU3_AT_SPELLINGS: readonly { + readonly value: ArgvValue; + readonly what: string; +}[] = [ + { + value: NU3_PATH_BYTES, + what: + "the exact on-disk path bytes as raw argv — argument values are " + + "UTF-8 (SPEC 12.0), so the byte string names no discovered file; a " + + "product resolving byte argv against the filesystem finds the file " + + "and answers instead", + }, + { + value: "specs/nu�.mdx", + what: + "the lossy UTF-8 decode (U+FFFD replacing the invalid byte) names a " + + "different, undiscovered path", + }, + { + value: JSON.stringify(NU3_MARKED_PATH), + what: + "the marked byte form — the product's own 12.7 output spelling for " + + "the path — is itself no argument value naming the file (SPEC 12.0)", + }, + { + value: "specs/nu%ff.mdx", + what: "a percent-encoded rendering names a different, undiscovered path", + }, +]; + +/** + * The asserted projection of the condition-19 finding (the T11.2-3 + * discipline): stable code token, the empty locations of a path-level + * condition, the concerned path in the marked byte form (SPEC 14, 12.7). + * Message and identities stay unpinned. + */ +function projectNu3Finding(finding: Finding): { + readonly code: string | null; + readonly locations: readonly unknown[]; + readonly path: unknown; +} { + return { + code: finding.code, + locations: finding.locations, + path: finding.path, + }; +} + +/** Range containment under SPEC 1.7 (start-inclusive, end-exclusive). */ +function containsOffset(range: SourceRange, offset: number): boolean { + return range.start <= offset && offset < range.end; +} + +const T11_5_3 = defineProductTest({ + id: "T11.5-3", + title: + "occurrence containment ends and imperfect files: on a finding-free file whose section `host` bears `d={CIBLE.but}` and embeds `{text(CIBLE.but)}` — both resolving into specs/cible.mdx#but — offsets at the d reference expression's start and end − 1 report the containing occurrence's full 12.7 record (file, byte-exact range, kind `depends`, source graph node {identity, range} = host, resolved target) while the end offset and the byte before the start report none, and likewise for the embedding container (opening brace through closing brace, kind `embeds`) — start-inclusive, end-exclusive (SPEC 1.7) — every answer findings [] at exit 0, the consulted domain being the named file alone whatever the workspace's other findings; the unparseable specs/casse.mdx (unclosed section tag) answers `at` offset 0 AND the EOF caret with `resolution` exactly the unavailability marker — no root fallback bypasses the mask — beside exactly its one located 14.20, exit 1; and — staged where file names are byte strings (Linux leg) — the non-UTF-8-pathed specs/nu<0xFF>.mdx is nameable by no argument value: the exact on-disk path bytes as raw argv, the lossy U+FFFD decode, the marked-byte-form JSON rendering, and a percent-encoded rendering each exit 2 as an unknown file with the single 12.7 error document, while the glob-reached view (`view --file specs/nu*.mdx`, byte-wise glob) stays the one route to its positions: exit 1 with exactly its condition-19 finding (stable code `invalid-source-path`, locations [], the marked-byte-form concerned path) and its full positional tree byte-exact, every node identity — root included — explicitly unavailable; no invocation of the sweep modifies anything (SPEC 11.5, 11.2, 5.7, 1.7, 12.0, 12.7; T11.2-3, T11.2-5)", + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline) — composed-range arithmetic + // proven against the staged bytes before any product invocation. + sliceCheck(OC_SOURCE, OC_DREF, OC_DREF_TEXT, "the d reference expression"); + sliceCheck(OC_SOURCE, OC_EMB, OC_EMB_TEXT, "the embedding container"); + sliceCheck(OC_SOURCE, OC_HOST_OPEN, OC_HOST_OPEN_TEXT, "host's open tag"); + sliceCheck( + OC_SOURCE, + OC_HOST_RANGE, + OC_HOST_CONSTRUCT_TEXT, + "host's construct", + ); + sliceCheck( + NU3_SOURCE, + NU3_SEC_RANGE, + '<S id="solo">\nTexte positionné.\n</S>', + "the non-UTF-8-named file's section construct", + ); + if (Buffer.byteLength(OC_CASSE_SOURCE, "utf8") !== OC_CASSE_LENGTH) { + fail( + `§11.5 fixture self-check — the composed byte length ` + + `${String(OC_CASSE_LENGTH)} must equal specs/casse.mdx's staged ` + + `byte length (a harness-side staging error, not a product failure)`, + ); + } + // Both occurrence ranges lie within host's construct and are disjoint; + // each arm's offset lies inside host, and inside its expected record's + // range or inside NEITHER record's range — so the arm table's section + // and occurrence expectations rest on proven staging arithmetic. + for (const arm of OC_CONTAINMENT_ARMS) { + if (!containsOffset(OC_HOST_RANGE, arm.offset)) { + fail( + `§11.5 fixture self-check — offset ${String(arm.offset)} ` + + `(${arm.what}) must lie within host's construct range ` + + `[${String(OC_HOST_RANGE.start)}, ${String(OC_HOST_RANGE.end)}) ` + + `(a harness-side staging error, not a product failure)`, + ); + } + const inD = containsOffset(OC_DREF, arm.offset); + const inEmb = containsOffset(OC_EMB, arm.offset); + const expected = + arm.occurrence === null + ? !inD && !inEmb + : arm.occurrence === OC_D_RECORD + ? inD && !inEmb + : inEmb && !inD; + if (!expected) { + fail( + `§11.5 fixture self-check — offset ${String(arm.offset)} ` + + `(${arm.what}): the arm's expected occurrence disagrees with ` + + `range containment over the staged fixture (in d: ` + + `${String(inD)}, in embedding: ${String(inEmb)}) — a ` + + `harness-side staging error, not a product failure`, + ); + } + } + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [OC_FILE]: OC_SOURCE, + [OC_TGT_FILE]: OC_TGT_SOURCE, + [OC_CASSE_FILE]: OC_CASSE_SOURCE, + }, + }); + try { + if (NU3_STAGED) { + await workspace.file(NU3_PATH_BYTES, NU3_SOURCE); + } + await assertLeavesUnchanged( + workspace.root, + async () => { + // Gate reference and staging integrity (SPEC 12.1, 14): exactly + // casse's 14.20 plus — where staged — nu's 14.19, nothing else, + // so occ.mdx and cible.mdx are finding-free on pinned ground + // (the d reference and the embedding both resolve: an unresolved + // or unparsed spelling would surface here as 14.5/14.8). + const gateContext = + "T11.5-3 `build --json` (staging integrity: one 14.20 in " + + "specs/casse.mdx" + + (NU3_STAGED + ? ", one 14.19 for the non-UTF-8-named specs/nu<0xFF>.mdx" + : "") + + "; specs/occ.mdx and specs/cible.mdx finding-free)"; + const gateFindings = await buildFindings( + product, + workspace, + gateContext, + ); + assertConditionCounts( + gateFindings, + NU3_STAGED ? { "14.20": 1, "14.19": 1 } : { "14.20": 1 }, + `${gateContext} — exactly the staged conditions (SPEC 14)`, + ); + assertFindingLocated( + gateFindings.find((finding) => finding.condition === "14.20")!, + { file: OC_CASSE_FILE }, + `${gateContext} — the parse failure locates in the ` + + `unparseable file (SPEC 14.20, 14)`, + ); + if (NU3_STAGED) { + assertSameJson( + projectNu3Finding( + gateFindings.find((finding) => finding.condition === "14.19")!, + ), + { + code: "invalid-source-path", + locations: [], + path: NU3_MARKED_PATH, + }, + `${gateContext} — the condition-19 finding carries the ` + + `stable code, no in-source locations, and the non-UTF-8 ` + + `concerned path in the marked byte form (SPEC 14, 12.0, ` + + `12.7)`, + ); + } + + // --- Occurrence containment (SPEC 11.5, 5.7, 1.7): within-range + // offsets report the containing occurrence's record and resolved + // target; the end offset and other outside offsets report none. + for (const arm of OC_CONTAINMENT_ARMS) { + const context = `T11.5-3 \`at ${OC_FILE} ${String(arm.offset)}\` — ${arm.what}`; + const report = decodeAtReport( + await runJson( + product, + workspace, + ["at", OC_FILE, String(arm.offset)], + `${context} — a single JSON document is the only output ` + + `form, and the named file's domain is finding-free, so ` + + `the complete answer exits 0 (SPEC 11, 11.2, 11.5)`, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context} — the consulted domain is the named file alone ` + + `and specs/occ.mdx is finding-free: the workspace's staged ` + + `14.20/14.19 are no domain file's findings (SPEC 11.2, ` + + `11.5)`, + ); + assertSameJson( + report.resolution, + { section: OC_HOST_SECTION, occurrence: arm.occurrence }, + `${context}: the innermost containing section construct is ` + + `host ({identity, range} byte-exact), and the containing ` + + `occurrence — reported as the full 12.7 record with its ` + + `file, byte-exact range, kind, source graph node, and ` + + `resolved target — is determined by range containment, ` + + `start-inclusive and end-exclusive (SPEC 11.5, 5.7, 1.7, ` + + `12.7)`, + ); + } + + // --- The unparseable file (SPEC 11.5, 11.2): resolution + // explicitly unavailable — at offset 0 AND at the EOF caret, so + // no root fallback bypasses the mask — the parse-failure finding + // accompanying, exit 1 with the full answer still emitted. + for (const offset of [0, OC_CASSE_LENGTH]) { + const context = `T11.5-3 \`at ${OC_CASSE_FILE} ${String(offset)}\` (the unparseable file${offset === 0 ? "" : ", the EOF caret"})`; + const result = await expectExit( + product, + workspace, + ["at", OC_CASSE_FILE, String(offset)], + 1, + `${context} — the answer carries the parse-failure finding ` + + `and an explicitly-unavailable resolution, so exit 1 with ` + + `the full answer document still emitted (SPEC 11.2, 11.5)`, + ); + const report = decodeAtReport( + parseJsonStdout( + result, + `${context} — the full answer document is still emitted, ` + + `complete and parseable (SPEC 11.2, H-5)`, + ), + context, + ); + assertSameJson( + report.resolution, + UNAVAILABLE, + `${context} — on an unparseable file the resolution is ` + + `reported explicitly unavailable: exactly the ` + + `unavailability marker, never null, never a fabricated ` + + `root resolution (SPEC 11.5, 11.2, 12.7)`, + ); + assertConditionCounts( + report.findings, + { "14.20": 1 }, + `${context} — exactly the named file's parse-failure ` + + `finding accompanies (SPEC 11.2, 14.20)`, + ); + assertFindingLocated( + report.findings[0]!, + { file: OC_CASSE_FILE }, + `${context} — the parse failure locates in the named file ` + + `(SPEC 14)`, + ); + } + + // --- The non-UTF-8-pathed source (SPEC 12.0, 11.5; Linux leg): + // nameable by no argument value — every `at` spelling for it is + // an unknown file, exit 2 — while the glob-reached view is the + // one route to its positions. + if (NU3_STAGED) { + for (const spelling of NU3_AT_SPELLINGS) { + await expectAvailabilityUsageError( + product, + workspace, + ["at", spelling.value, "0"], + `T11.5-3 non-UTF-8-pathed source, \`at\` spelling: ` + + `${spelling.what} — an unknown file, the usage error of ` + + `12.0 (SPEC 11.5, 11.4, 12.0)`, + ); + } + + const viewContext = + "T11.5-3 `view --file specs/nu*.mdx` (the glob-reached " + + "view: the one route to the non-UTF-8-pathed file's " + + "positions)"; + const viewResult = await runCli(product, workspace, [ + "view", + "--file", + "specs/nu*.mdx", + ]); + assertExitCode( + viewResult, + 1, + `${viewContext} — the answer carries the file's ` + + `condition-19 finding and explicitly-unavailable ` + + `identities, so exit 1 with the full document still ` + + `emitted (SPEC 11.2, 11.4)`, + ); + const viewReport = decodeViewReport( + parseJsonStdout( + viewResult, + `${viewContext} — a single JSON document is the only ` + + `output form (SPEC 11)`, + ), + { text: false }, + viewContext, + ); + assertSameJson( + viewReport.findings.map(projectNu3Finding), + [ + { + code: "invalid-source-path", + locations: [], + path: NU3_MARKED_PATH, + }, + ], + `${viewContext} — exactly the admitted file's condition-19 ` + + `finding accompanies: stable code, no in-source ` + + `locations, the concerned path in the marked byte form ` + + `(SPEC 11.2, 14, 12.0, 12.7)`, + ); + assertSameJson( + viewReport.views.map((view) => view.file), + [NU3_MARKED_PATH], + `${viewContext} — the byte-wise glob admits exactly the ` + + `non-UTF-8-named file, its \`file\` member presented in ` + + `the marked byte form (SPEC 7, 11.4, 12.0, 12.7)`, + ); + assertSameJson( + projectResolution(viewReport.views[0]!.root), + { + identity: UNAVAILABLE, + range: NU3_ROOT_RANGE, + children: [ + { + identity: UNAVAILABLE, + range: NU3_SEC_RANGE, + children: [], + }, + ], + }, + `${viewContext} — the view serves the file's full ` + + `positional tree with byte-exact construct ranges — the ` + + `position data \`at\` cannot address — while every node ` + + `identity, root included, is explicitly unavailable ` + + `(SPEC 11.2, 11.4, 1.7)`, + ); + } + }, + "T11.5-3 — no invocation of the sweep modifies anything: the gate " + + "build fails writing nothing (SPEC 12.1) and on a failing " + + "workspace these surfaces answer from current sources and write " + + "nothing (SPEC 11.2; the no-write contract clauses live at " + + "T11.2-1/T11.2-6)", + ); + } finally { + await workspace.dispose(); + } + }, +}); + +export const section115Tests: readonly ProductTestEntry[] = [ + T11_5_1, + T11_5_2, + T11_5_3, +]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index a821d3c..d702367 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -316,6 +316,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.4-6": ["11.4"], "T11.5-1": ["11.5"], "T11.5-2": ["11.5"], + "T11.5-3": ["11.5"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From 5f9a14ebdbdebf3d7f6d09c97acddd73be5252ad Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 21:30:27 +0000 Subject: [PATCH 114/226] sdg(phase-9): implement T11.6-1's inventory anchoring with the E-6 Linux side (FP-069) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 44 ++- test/helpers/adapters/forms.ts | 34 +++ test/helpers/adapters/model.ts | 13 + test/self/s5-output-adapters.test.ts | 62 ++++ test/suite/registry/index.ts | 2 + test/suite/registry/section-11.6.ts | 424 +++++++++++++++++++++++++++ test/suite/registry/traceability.ts | 1 + test/suite/section-11.6.test.ts | 9 + 8 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 test/suite/registry/section-11.6.ts create mode 100644 test/suite/section-11.6.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 1124f09..179a506 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3004,10 +3004,52 @@ certify against FP-091's fixtures once those land. planned mid-loop reds (certification-document ×2 → FP-091; S-1 unmapped keys {11.6, 12.6, 12.7} → stage G), S-5, S-7, and certification green.] -- [ ] FP-069 — Implement T11.6-1: `inventory` anchoring byte-exact, incl. +- [x] FP-069 — Implement T11.6-1: `inventory` anchoring byte-exact, incl. the E-6 drive-mismatch arm (Linux side; the Windows-subset arm is FP-093). [R2 #6; TEST-SPEC §11.6, E-6] New §11.6 registry module; map `"11.6"`. + [Done 2026-08-14: T11.6-1 registered in the new section-11.6.ts + (SUITE-56; wrapper + manifest spread), decoding through a new scoped + form-exact decoder `decodeInventoryAnchoring` (forms.ts/model.ts — the + `decodeInventoryRecordedDatum` scoping precedent: exactly `root` and + `config` as 12.7 path values, every other member unread until FP-070+ + pin the full form) plus the existing `decodeInventoryFindings`; every + arm asserts exit 0 with findings [] (complete finding-free answer, + 12.0/11.6). Arms: workspace root — `.` / `xspec.config.ts`, flag-less + AND `--json` against the same byte-exact expectation (the §11 JSON-only + same-information parity); nested `a/b` — `../..` / + `../../xspec.config.ts` (upward search); sibling and deeper-sibling + directories beside the builder's `work/` root with relative `--config` + — `../work/…` and `../../work/…` (multi-`..` ascent then descent, + joined `/`); the same deep cwd with an ABSOLUTE `--config` spelling — + anchoring unchanged (pure invocation input: cwd + identified file, + never an argument echo); E-6 Linux side — cwd in an unrelated temp tree + (nearest common ancestor outside both trees, the closest Linux staging + to a cross-drive invocation): still the pure relative + ascent-then-descent form, no absolute form ever appears, expectation + computed by a harness-side implementation of 11.6's own spelling rule + over the realpath'd pair (fixed-vector + shape self-checks before any + product invocation), invocation repeated with byte-identical stdout + (deterministic per invocation, 12.0; product-to-itself, H-4). + Traceability "T11.6-1": ["11.6"] (12.0/E-6 carriage context, the §11 + precedent); expressly in CERTIFICATIONS.md's Exclusions ("`inventory` + and `version`"), no fixture scope. Verified: typecheck/format clean; + suite red-as-diagnosed at the first arm's exit-0 assert (the stub has + no `inventory`: unknown-command exit 2); soundness proven by a scratch + mock product implementing the anchoring independently via + path.relative (body green — the two independent rule implementations + agree); teeth probed via seven mock deviations (absolute output, + trailing separator, `./`-prefixed spelling, `--config` echo, omitted + `root` member, phantom finding + exit 1, nondeterministic member), each + failing at exactly the intended assertion (the echo lands on the + absolute-`--config` arm, the omitted member on the form-exact decode, + the nondeterminism on the repeat byte-compare). `npm run test:self`: + planned mid-loop reds now certification-document ×2 (→ FP-091) and S-1 + with its unmapped set narrowed {11.6, 12.6, 12.7} → {12.6, 12.7} + (→ stage G); S-5 88/88 green incl. the new anchoring guards; + certification green (violators failing as certified). FP-093 stays + pending for the Windows-subset drive-mismatch arm; FP-070..072 build + T11.6-2..-4 on this module.] - [ ] FP-070 — Implement T11.6-2: resolved configuration/sources/derived map. [R2 #6; TEST-SPEC §11.6] - [ ] FP-071 — Implement T11.6-3: record, area, durables, orders. [R2 #6; diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts index a3c0779..62d62e1 100644 --- a/test/helpers/adapters/forms.ts +++ b/test/helpers/adapters/forms.ts @@ -22,6 +22,9 @@ // - the exit-2 error document {"error": …} holding one finding form (12.0) // - the three-state datum decode: plain value / `null` / // {"unavailable": true} (11.4, 12.7) +// - the scoped inventory decodes: the `recorded` datum, the `findings` +// member, and the `root`/`config` anchoring (11.6; the full inventory +// form is T11.6-*'s subject) // - the occurrence-record form {"file","range","kind","source","target"} // and the occurrences document {"findings","occurrences"} (5.7, 11.3) // - the at document {"findings","resolution"} (11.5) @@ -46,6 +49,7 @@ import type { Finding, FindingLocation, FindingsReport, + InventoryAnchoring, MarkedBytePath, OccurrenceRecord, OccurrenceSource, @@ -578,6 +582,36 @@ export function decodeInventoryFindings( ); } +/** + * Scoped decode of the inventory document's anchoring members (SPEC 11.6, + * 12.7): exactly `root` and `config` — the workspace root and the + * configuration file identified relative to the invocation working + * directory — each a 12.7 path value (`decodePathValue`: a plain string + * where the bytes are valid UTF-8, the marked byte form otherwise, never the + * byte form for a valid-UTF-8 path). The canonical relative spelling (`.`, + * ascent-`..`-then-descent joined with `/`) and the platform-absolute + * drive-mismatch form are value contracts the caller asserts byte-exactly + * (T11.6-1); the decoder's job is that neither member is ever absent (`null` + * is never omission, 12.7) or mis-formed. Deliberately scoped exactly as + * `decodeInventoryRecordedDatum` is: SPEC 12.7 fixes the whole inventory + * form and the T11.6-* tests pin it entirely; every other member stays + * unread here. Form-exact (H-3): never adjustable to a product's shape. + */ +export function decodeInventoryAnchoring( + doc: unknown, + context?: string, +): InventoryAnchoring { + const site = rootSite("11.6 inventory (anchoring)", context); + const obj = expectObject(doc, site); + return { + root: decodePathValue(requiredKey(obj, "root", site), at(site, "root")), + config: decodePathValue( + requiredKey(obj, "config", site), + at(site, "config"), + ), + }; +} + // --- the occurrences document (5.7, 11.3, 12.7) ------------------------------- const OCCURRENCE_RECORD_MEMBERS = [ diff --git a/test/helpers/adapters/model.ts b/test/helpers/adapters/model.ts index eb9b108..a4ab881 100644 --- a/test/helpers/adapters/model.ts +++ b/test/helpers/adapters/model.ts @@ -478,6 +478,19 @@ export interface ViewReport { readonly views: readonly FileView[]; } +/** + * The inventory document's anchoring members (SPEC.md 11.6, 12.7 — decoded + * by `decodeInventoryAnchoring`): the workspace root and the configuration + * file, each identified relative to the invocation working directory in + * 11.6's canonical spelling and carried as a 12.7 path value. The document's + * other members are outside this scoped projection (the full inventory form + * is T11.6-*'s subject). + */ +export interface InventoryAnchoring { + readonly root: PathValue; + readonly config: PathValue; +} + /** `coverage` (T8.2-1): all profiles by default, one when named. */ export interface CoverageReport { readonly profiles: readonly CoverageProfileReport[]; diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index ba3bb67..c279121 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -45,6 +45,7 @@ import { decodeIdsReport, decodeIdsTreeReport, decodeImpactReport, + decodeInventoryAnchoring, decodeInventoryFindings, decodeInventoryRecordedDatum, decodeItemReport, @@ -2709,6 +2710,67 @@ const DECODERS: readonly DecoderSpec[] = [ }, ], }, + { + // The scoped inventory anchoring decode (SPEC 11.6, 12.7): exactly the + // `root` and `config` members, each a 12.7 path value, with every other + // member unread (the full inventory form is T11.6-*'s subject; T11.6-1 + // pins the canonical relative spellings byte-exactly as its value + // assertions — the decoder's job is that neither member is ever absent + // or mis-formed). + name: "11.6 inventory (anchoring)", + decode: decodeInventoryAnchoring, + good: { + findings: [], + root: ".", + config: "xspec.config.ts", + graphData: ".xspec", + }, + verify: (decoded: ReturnType<typeof decodeInventoryAnchoring>) => { + expect(decoded).toEqual({ root: ".", config: "xspec.config.ts" }); + }, + alsoGood: [ + { + label: + "ascent-then-descent relative spellings decode as plain path " + + "strings (SPEC 11.6)", + doc: { root: "../../work", config: "../../work/xspec.config.ts" }, + verify: ( + decoded: ReturnType<typeof decodeInventoryAnchoring>, + ): void => { + expect(decoded).toEqual({ + root: "../../work", + config: "../../work/xspec.config.ts", + }); + }, + }, + ], + bad: [ + { + label: + "absent root member (12.7: each object carries exactly the " + + "members its form names — null is never omission)", + doc: { findings: [], config: "xspec.config.ts" }, + }, + { + label: "absent config member", + doc: { findings: [], root: "." }, + }, + { + label: "null root (a path value is a string or the byte form)", + doc: { root: null, config: "xspec.config.ts" }, + }, + { + label: "a non-path root", + doc: { root: 42, config: "xspec.config.ts" }, + }, + { + label: + "a valid-UTF-8 anchoring path in the marked byte form (SPEC 12.7 " + + "forbids the byte form for a valid-UTF-8 path)", + doc: { root: { bytes: "2e" }, config: "xspec.config.ts" }, + }, + ], + }, { name: "coverage", decode: decodeCoverageReport, diff --git a/test/suite/registry/index.ts b/test/suite/registry/index.ts index 1c3d562..708c34b 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -60,6 +60,7 @@ import { section112Tests } from "./section-11.2.js"; import { section113Tests } from "./section-11.3.js"; import { section114Tests } from "./section-11.4.js"; import { section115Tests } from "./section-11.5.js"; +import { section116Tests } from "./section-11.6.js"; import { section120iTests } from "./section-12.0-i.js"; import { section120iiTests } from "./section-12.0-ii.js"; import { section121to122Tests } from "./section-12.1-12.2.js"; @@ -128,6 +129,7 @@ export const productTestSuite = new ProductTestSuite([ ...section113Tests, ...section114Tests, ...section115Tests, + ...section116Tests, ...section120iTests, ...section120iiTests, ...section121to122Tests, diff --git a/test/suite/registry/section-11.6.ts b/test/suite/registry/section-11.6.ts new file mode 100644 index 0000000..dd74307 --- /dev/null +++ b/test/suite/registry/section-11.6.ts @@ -0,0 +1,424 @@ +// TEST-SPEC §11.6 (`xspec inventory`) — SUITE-56: T11.6-1 (T11.6-2..T11.6-4 +// register here as they are implemented). +// +// Registered product-facing bodies (C-2 "one code path"): each builds its own +// fresh workspace (H-1), drives the product strictly as a subprocess (H-2), +// asserts exact exit codes (H-5), and rejects a product only via diagnosed +// assertion failures (H-8). SPEC 11: `inventory` is JSON-only — a single JSON +// document is its only output form, with or without `--json` — in the +// form-exact 12.7 inventory document form (H-3), so every invocation below +// runs bare (one arm additionally with `--json`, asserting the two forms +// carry the same information, SPEC 11) and its stdout decodes through the +// scoped form-exact decoders `decodeInventoryAnchoring` and +// `decodeInventoryFindings` (the full inventory form is pinned across the +// T11.6-* tests as they land). +// +// T11.6-1 — anchoring (SPEC 11.6, 12.0). The workspace root and the +// configuration file are identified relative to the invocation working +// directory — pure invocation input — in the canonical spelling: ascent +// segments each spelled `..`, then descent segments, joined with `/` on +// every platform, no `.` segments, no trailing separator, the working +// directory itself spelled `.`. Asserted byte-exactly: +// +// - from the workspace root: `root` `.`, `config` `xspec.config.ts`, in the +// flag-less and the `--json` form alike (same information, SPEC 11); +// - from nested `a/b`: `root` `../..`, `config` `../../xspec.config.ts` +// (upward search, SPEC 7); +// - from a sibling directory with `--config`: ascent-then-descent +// (`../work/…`), and from a deeper sibling multi-`..` ascent then descent +// (`../../work/…`) — under a relative and under an absolute `--config` +// spelling alike: the anchoring is a function of the working directory and +// the identified file, never an echo of the argument's spelling (SPEC +// 11.6, 12.0); +// - drive-mismatch arm, Linux side (E-6): from a working directory in an +// unrelated temporary tree — the nearest common ancestor lies outside both +// trees, the closest Linux staging to a cross-drive invocation — the +// anchoring is still the pure relative ascent-then-descent form: on the +// Linux leg no absolute form ever appears (the platform admits a relative +// path between any two directories; the absolute, drive-qualified form is +// the Windows leg's sole case, staged by the Windows-subset arm in +// test/windows/). The expected spelling is computed harness-side by 11.6's +// own rule over the realpath'd directory pair (self-checked against fixed +// vectors before any product invocation), and the invocation is repeated: +// byte-identical stdout, deterministic per invocation (SPEC 12.0; a +// product-to-itself comparison, H-4). +// +// Every answer here is complete and finding-free on a valid workspace — +// `findings` decodes to [] and the exit code is 0 (SPEC 12.0, 11.6). +// +// Certification note: CERTIFICATIONS.md's Exclusions list T11.6-1 through +// T11.6-4 ("`inventory` and `version`"), so no fixture executes these +// bodies; the anchoring arms are positive and byte-asserted per that entry. + +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import type { PathValue } from "../../helpers/adapters/index.js"; +import { + decodeInventoryAnchoring, + decodeInventoryFindings, + renderPathValue, +} from "../../helpers/adapters/index.js"; +import { + assertBytesEqual, + assertExitCode, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; +import { runProduct } from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; + +// --- fixture ------------------------------------------------------------------ +// +// A minimal valid workspace: one spec group, one well-formed source. The +// inventory parses no sources (SPEC 11.6), so the anchoring depends on none +// of this — the staging keeps the workspace valid so every answer is the +// complete, finding-free, exit-0 case (T11.6-4 owns the imperfect-workspace +// arms). + +const ANCHOR_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`; + +const ANCHOR_SOURCE = '<S id="racine">\nAncrage — contenu stable.\n</S>\n'; + +const CONFIG_FILE = "xspec.config.ts"; + +// --- SPEC 11.6's canonical relative spelling (harness-side) ------------------- + +/** + * SPEC 11.6's canonical relative spelling from an absolute working directory + * to an absolute target: the segments ascending to the nearest common + * ancestor, each spelled `..`, then the segments descending to the target, + * joined with `/` — no `.` segments, no trailing separator — and the working + * directory itself spelled `.`. Both inputs must be absolute, symlink-free + * paths (the caller realpaths them): the product observes its physical + * working directory, so the harness computes expectations from the same + * physical pair. + */ +function canonicalRelativeSpelling(fromDir: string, target: string): string { + const split = (abs: string): string[] => + abs.split(path.sep).filter((segment) => segment !== ""); + const fromParts = split(fromDir); + const toParts = split(target); + let common = 0; + while ( + common < fromParts.length && + common < toParts.length && + fromParts[common] === toParts[common] + ) { + common += 1; + } + const segments = [ + ...Array<string>(fromParts.length - common).fill(".."), + ...toParts.slice(common), + ]; + return segments.length === 0 ? "." : segments.join("/"); +} + +/** + * Fixture self-check (harness-side, before any product invocation): the + * spelling rule above must reproduce SPEC 11.6's stated forms on fixed + * vectors, and a computed expectation must be a pure relative + * ascent-then-descent spelling — never absolute, no `.` segments, no + * trailing separator. A failure here is a harness-arithmetic defect, never a + * product failure. + */ +function selfCheckSpellingRule(): void { + const vectors: readonly [string, string, string][] = [ + ["/t/ws", "/t/ws", "."], + ["/t/ws/a/b", "/t/ws", "../.."], + ["/t/ws/a/b", "/t/ws/xspec.config.ts", "../../xspec.config.ts"], + ["/t/side", "/t/work", "../work"], + ["/t/side/deep", "/t/work/xspec.config.ts", "../../work/xspec.config.ts"], + ["/t/ws", "/t/ws/xspec.config.ts", "xspec.config.ts"], + ]; + for (const [from, to, expected] of vectors) { + const actual = canonicalRelativeSpelling(from, to); + if (actual !== expected) { + fail( + `§11.6 fixture self-check — the harness-side 11.6 spelling rule ` + + `computes ${JSON.stringify(actual)} from ${JSON.stringify(from)} ` + + `to ${JSON.stringify(to)}, expected ${JSON.stringify(expected)} ` + + `(a harness-arithmetic defect, not a product failure)`, + ); + } + } +} + +/** Self-check a computed expectation's shape (see selfCheckSpellingRule). */ +function selfCheckComputedSpelling(spelling: string, what: string): void { + const segments = spelling.split("/"); + const pure = + spelling !== "" && + !path.isAbsolute(spelling) && + !spelling.endsWith("/") && + segments.every((segment) => segment !== "" && segment !== ".") && + // Ascent before descent: no `..` may follow a non-`..` segment. + segments.every( + (segment, index) => + segment !== ".." || segments.slice(0, index).every((s) => s === ".."), + ); + if (!pure) { + fail( + `§11.6 fixture self-check — ${what}: the computed expected spelling ` + + `${JSON.stringify(spelling)} is not a pure relative ` + + `ascent-then-descent form (a harness-arithmetic defect, not a ` + + `product failure)`, + ); + } +} + +// --- shared assertion --------------------------------------------------------- + +interface AnchoringExpectation { + /** Expected `root` member, byte-exact (SPEC 11.6). */ + readonly root: string; + /** Expected `config` member, byte-exact (SPEC 11.6). */ + readonly config: string; +} + +function assertAnchoringMember( + actual: PathValue, + expected: string, + member: string, + context: string, +): void { + if (actual === expected) return; + fail( + `${context}: the inventory's ${member} anchoring must be exactly ` + + `${JSON.stringify(expected)} — the canonical relative spelling from ` + + `the invocation working directory: ascent \`..\` segments then ` + + `descent segments joined with "/", no "." segments, no trailing ` + + `separator, the working directory itself "."; on the Linux leg no ` + + `absolute form ever appears (SPEC 11.6, 12.7, E-6); got ` + + `${renderPathValue(actual)}`, + ); +} + +/** + * Run `inventory` from `cwd` and assert the T11.6-1 contract: exit 0 exactly + * (a complete, finding-free answer, SPEC 12.0/11.6; H-5); exactly one JSON + * document as the entire stdout (JSON-only, SPEC 11); `findings` decoding to + * [] (form-exact, 12.7); and the `root`/`config` anchoring byte-exact. + */ +async function expectAnchoredInventory( + product: ProductBinding, + cwd: string, + argv: readonly string[], + expected: AnchoringExpectation, + context: string, +): Promise<RunResult> { + const result = await runProduct(product, { cwd, argv }); + assertExitCode( + result, + 0, + `${context} — a complete, finding-free inventory answer exits 0 ` + + `(SPEC 12.0, 11.6)`, + ); + const doc = parseJsonStdout( + result, + `${context} — inventory is JSON-only: a single JSON document is its ` + + `only output form, with or without --json (SPEC 11, 12.0)`, + ); + const findings = decodeInventoryFindings(doc, context); + if (findings.length !== 0) { + fail( + `${context}: the staged workspace is valid and the inventory parses ` + + `no sources, so the answer is finding-free — findings [] (SPEC ` + + `11.6, 12.7); got ${String(findings.length)} finding(s), first: ` + + `${JSON.stringify(findings[0]?.message)}`, + ); + } + const anchoring = decodeInventoryAnchoring(doc, context); + assertAnchoringMember(anchoring.root, expected.root, "`root`", context); + assertAnchoringMember(anchoring.config, expected.config, "`config`", context); + return result; +} + +// --- T11.6-1 ------------------------------------------------------------------ + +const T11_6_1 = defineProductTest({ + id: "T11.6-1", + title: + "inventory anchoring: `root` and `config` are identified relative to the invocation working directory in the canonical spelling — from the workspace root `.` and `xspec.config.ts` (flag-less and `--json` forms carrying the same information, JSON-only), from nested `a/b` `../..` and `../../xspec.config.ts`, from sibling directories with `--config` the ascent-`..`-then-descent form joined with `/` (multi-segment ascent and descent included), no `.` segments, no trailing separator — byte-exact, a pure function of invocation input whatever the `--config` spelling (relative or absolute); drive-mismatch arm, Linux side (E-6): from an unrelated directory tree the anchoring is still the pure relative form — no absolute form ever appears on the Linux leg — and repeated invocations are byte-identical, deterministic per invocation; every answer complete and finding-free at exit 0 (SPEC 11.6, 12.7, 12.0, 11)", + run: async (product) => { + selfCheckSpellingRule(); + const workspace = await TestWorkspace.create({ + files: { + [CONFIG_FILE]: ANCHOR_CONFIG, + "specs/a.mdx": ANCHOR_SOURCE, + }, + }); + try { + // --- from the workspace root: `.` / `xspec.config.ts`, both forms. + // SPEC 11: inventory is JSON-only — the flag-less and `--json` + // invocations carry the same information; asserting both byte-exactly + // against the same expected anchoring realizes that parity for the + // anchoring members (byte-identity of the two stdouts is not asserted, + // SPEC.md not requiring it). + const atRoot: AnchoringExpectation = { + root: ".", + config: CONFIG_FILE, + }; + await expectAnchoredInventory( + product, + workspace.root, + ["inventory"], + atRoot, + "T11.6-1 — `inventory` from the workspace root (flag-less): the " + + "working directory itself is spelled `.` and the configuration " + + "file is the pure descent `xspec.config.ts` (SPEC 11.6)", + ); + await expectAnchoredInventory( + product, + workspace.root, + ["inventory", "--json"], + atRoot, + "T11.6-1 — `inventory --json` from the workspace root: the same " + + "anchoring information as the flag-less form (JSON-only, SPEC 11, " + + "11.6)", + ); + + // --- from nested `a/b`: `../..` / `../../xspec.config.ts` (the + // configuration located by upward search from the working directory, + // SPEC 7; working-directory-dependence is pure invocation input, 12.0). + await workspace.dir("a/b"); + await expectAnchoredInventory( + product, + workspace.path("a/b"), + ["inventory"], + { root: "../..", config: "../../xspec.config.ts" }, + "T11.6-1 — `inventory` from the nested working directory a/b: pure " + + "ascent, each segment spelled `..`, joined with `/` (SPEC 11.6, 7)", + ); + + // --- from sibling directories with `--config`: ascent `..` segments + // then descent segments. The siblings live beside the workspace root + // in the fixture's own temporary directory (the builder's layout: + // root is a `work/` subdirectory of tempRoot), so the expected + // spellings are composed from the root's real basename. The physical + // root anchors the absolute `--config` spelling below, so every + // product-side path resolution agrees with the harness's expectation + // arithmetic whatever symlinks the temp prefix holds. + const rootBase = path.basename(workspace.root); + const physicalRoot = await fsp.realpath(workspace.root); + const absoluteConfig = path.join(physicalRoot, CONFIG_FILE); + const side = path.join(workspace.tempRoot, "side"); + const deep = path.join(side, "creuse"); + await fsp.mkdir(deep, { recursive: true }); + + await expectAnchoredInventory( + product, + side, + ["inventory", "--config", `../${rootBase}/${CONFIG_FILE}`], + { + root: `../${rootBase}`, + config: `../${rootBase}/${CONFIG_FILE}`, + }, + "T11.6-1 — `inventory --config` from a sibling directory: one " + + "ascent segment then the descent segments, joined with `/`, no " + + "`.` segments, no trailing separator (SPEC 11.6)", + ); + await expectAnchoredInventory( + product, + deep, + ["inventory", "--config", `../../${rootBase}/${CONFIG_FILE}`], + { + root: `../../${rootBase}`, + config: `../../${rootBase}/${CONFIG_FILE}`, + }, + "T11.6-1 — `inventory --config` from a deeper sibling directory: a " + + "multi-segment `..` ascent run then descent, joined with `/` " + + "(SPEC 11.6)", + ); + // The same working directory with the `--config` value spelled + // absolutely: the anchoring identifies the same file relative to the + // same working directory, so the spelling is unchanged — pure + // invocation input (working directory + identified file), never an + // echo of the argument (SPEC 11.6, 12.0: `--config` is a filesystem + // path resolved against the working directory). + await expectAnchoredInventory( + product, + deep, + ["inventory", "--config", absoluteConfig], + { + root: `../../${rootBase}`, + config: `../../${rootBase}/${CONFIG_FILE}`, + }, + "T11.6-1 — `inventory --config <absolute path>` from the deeper " + + "sibling: the anchoring stays the canonical relative spelling — " + + "a function of the working directory and the identified file, " + + "not of the argument's spelling (SPEC 11.6, 12.0)", + ); + + // --- drive-mismatch arm, Linux side (E-6): an unrelated temporary + // tree as the working directory — the nearest common ancestor lies + // outside both trees. The platform admits a relative path between any + // two directories, so the anchoring is still the pure + // ascent-then-descent relative form: no absolute form ever appears on + // the Linux leg (the absolute, drive-qualified spelling is the + // Windows leg's sole case, test/windows/). The expectation is + // computed by 11.6's own rule over the realpath'd pair (self-checked + // above and shape-checked here), and the invocation is repeated + // byte-identically: the anchoring is deterministic per invocation + // (SPEC 12.0; product-to-itself, H-4). + const farTree = await TestWorkspace.create({}); + try { + const farCwd = await fsp.realpath(farTree.root); + const expectedFarRoot = canonicalRelativeSpelling(farCwd, physicalRoot); + selfCheckComputedSpelling( + expectedFarRoot, + "the unrelated-tree arm's expected `root`", + ); + const farExpectation: AnchoringExpectation = { + root: expectedFarRoot, + config: `${expectedFarRoot}/${CONFIG_FILE}`, + }; + const farArgv = ["inventory", "--config", absoluteConfig]; + const farContext = + "T11.6-1 — `inventory` from an unrelated directory tree (the " + + "E-6 drive-mismatch arm's Linux side): the nearest common " + + "ancestor lies outside both trees, and the anchoring is still " + + "the pure relative ascent-then-descent form — no absolute form " + + "ever appears on the Linux leg (SPEC 11.6, 12.0, E-6)"; + const first = await expectAnchoredInventory( + product, + farCwd, + farArgv, + farExpectation, + farContext, + ); + const second = await expectAnchoredInventory( + product, + farCwd, + farArgv, + farExpectation, + `${farContext} — repeated invocation`, + ); + assertBytesEqual( + second.stdoutBytes, + first.stdoutBytes, + "T11.6-1 — the anchoring is invocation-anchored content: a pure " + + "function of invocation input, deterministic per invocation, so " + + "repeating the identical invocation from the identical working " + + "directory yields byte-identical stdout (SPEC 12.0, 11.6; a " + + "product-to-itself comparison, H-4)", + ); + } finally { + await farTree.dispose(); + } + } finally { + await workspace.dispose(); + } + }, +}); + +export const section116Tests: readonly ProductTestEntry[] = [T11_6_1]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index d702367..d42ec5d 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -317,6 +317,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.5-1": ["11.5"], "T11.5-2": ["11.5"], "T11.5-3": ["11.5"], + "T11.6-1": ["11.6"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], diff --git a/test/suite/section-11.6.test.ts b/test/suite/section-11.6.test.ts new file mode 100644 index 0000000..960341d --- /dev/null +++ b/test/suite/section-11.6.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §11.6 (SUITE-56): thin Vitest wrapper over the registered +// bodies — the identical bodies the certification runner executes against +// fixture products (C-2 "one code path"). Expected to fail as diagnosed +// assertion failures until the product exists (H-8). + +import { declareProductTests } from "./declare.js"; +import { section116Tests } from "./registry/section-11.6.js"; + +declareProductTests(section116Tests); From 2c81f28941892f60a5371042bac5546cb8382828 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 21:53:34 +0000 Subject: [PATCH 115/226] sdg(phase-9): implement T11.6-2's resolved configuration/sources/derived map (FP-070) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 52 ++- test/helpers/adapters/forms.ts | 399 ++++++++++++++++- test/helpers/adapters/model.ts | 119 ++++++ test/self/s5-output-adapters.test.ts | 329 ++++++++++++++ test/suite/registry/section-11.6.ts | 616 ++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 6 files changed, 1501 insertions(+), 15 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 179a506..c607583 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3050,8 +3050,58 @@ certify against FP-091's fixtures once those land. certification green (violators failing as certified). FP-093 stays pending for the Windows-subset drive-mismatch arm; FP-070..072 build T11.6-2..-4 on this module.] -- [ ] FP-070 — Implement T11.6-2: resolved configuration/sources/derived +- [x] FP-070 — Implement T11.6-2: resolved configuration/sources/derived map. [R2 #6; TEST-SPEC §11.6] + [Done 2026-08-14: T11.6-2 registered in section-11.6.ts (SUITE-56), + decoding through the new scoped form-exact decoder + `decodeInventoryResolvedMap` (forms.ts/model.ts — the + `decodeInventoryRecordedDatum` scoping precedent: exactly + `configuration`/`sources`/`derived` in the full 12.7 member forms — + expectOnlyMembers per object, `null`-never-omission on + `outDir`/`targetTags`/`module`/`markdown`, selector exactly one of + {"group","kind"}/{"files"}/{"tags"}, sources/derived byte order and + uniqueness decoder-enforced — every other member unread until FP-071/072 + pin the rest). Four workspaces, no arm ever running `build`: defaults — + `markdown` key absent → view {"emit":false,"outDir":null} and derived + `markdown` null everywhere; profile/rule spelling only required fields → + `targetTags` null, `targets` "leaves", `boundaryKind` and selector kinds + explicit-though-inferred, `edgeKinds`/`kinds` all three (compared as + sets: 11.6 pins no element order for them; every other list exact — + groups/profiles/rules configuration order with `core` before `aux` + chosen so byte-ordering products fail, sources/derived path byte order); + group references asserted twice (exact configured names in the compare + plus a resolve-against-reported-lists walk); the two-group `shared` + file carrying both memberships in configuration order; a code group so + `sources` spans kinds while `derived` holds spec sources only; flag-less + AND `--json` decoded against one expectation (§11 same-information). + Emission-enabled workspace — module + next-to-source destination both + present pre-build; extension-free glob `specs/*` discovers + `specs/note.txt` (the 14.19 staging beside) listed in `sources` with + membership, `module`/`markdown` the stated null, answer finding-free + exit 0 (the 14.19 is build/check's, never inventory's). outDir + workspace — view echoes "mdout", destinations `mdout/specs/…` preserving + workspace-relative paths, nested source included. Disabled-explicit + workspace — `{emit:false, outDir:"docsout"}` echoed whole while derived + `markdown` stays null (7.3: destinations exist exactly while emission + is enabled). Traceability "T11.6-2": ["11.6"] (7.3/12.7/13.1 + parentheticals context, the §11/FP-014 precedent; no numbered condition + asserted — the 14.19 is staged, not asserted). In CERTIFICATIONS.md's + Exclusions ("`inventory` and `version`"), no fixture scope. Verified: + typecheck/format clean; suite red-as-diagnosed at the first arm's + exit-0 assert (stub: unknown-command exit 2); soundness proven by a + scratch mock implementing the projection independently (config eval + + own glob matcher + own derived arithmetic; body green — the two + implementations agree); teeth probed via ten mock deviations + (omit-boundaryKind/targetTags/selector-kind → the decode, byte-ordered + groups, bare-name groups, byte-ordered memberships, + destinations-while-disabled, non-.mdx skipped from sources, + outDir-ignored, phantom finding + exit 1), each failing at exactly the + intended assertion. `npm run test:self`: unchanged 3 planned mid-loop + reds (certification-document ×2 → FP-091; S-1 unmapped {12.6, 12.7} → + stage G); S-5 90/90 green incl. the new resolved-map guards; + certification green 17/17 (violators failing as certified). FP-071/072 + build T11.6-3/-4 on this module and may widen the scoped decode to the + full top-level form.] - [ ] FP-071 — Implement T11.6-3: record, area, durables, orders. [R2 #6; TEST-SPEC §11.6] - [ ] FP-072 — Implement T11.6-4: no-parse/no-write/one-finding diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts index 62d62e1..8188772 100644 --- a/test/helpers/adapters/forms.ts +++ b/test/helpers/adapters/forms.ts @@ -23,8 +23,9 @@ // - the three-state datum decode: plain value / `null` / // {"unavailable": true} (11.4, 12.7) // - the scoped inventory decodes: the `recorded` datum, the `findings` -// member, and the `root`/`config` anchoring (11.6; the full inventory -// form is T11.6-*'s subject) +// member, the `root`/`config` anchoring, and the resolved +// configuration/sources/derived map (11.6; the full inventory form is +// T11.6-*'s subject) // - the occurrence-record form {"file","range","kind","source","target"} // and the occurrences document {"findings","occurrences"} (5.7, 11.3) // - the at document {"findings","resolution"} (11.5) @@ -44,12 +45,21 @@ import type { AtReport, AtResolution, AtSection, + DependencyEdgeKind, ErrorDocument, FileView, Finding, FindingLocation, FindingsReport, InventoryAnchoring, + InventoryConfigurationView, + InventoryCoverageProfileView, + InventoryDerivedEntry, + InventoryGroupDef, + InventoryPolicyRuleView, + InventoryPolicySelector, + InventoryResolvedMap, + InventorySourceEntry, MarkedBytePath, OccurrenceRecord, OccurrenceSource, @@ -71,7 +81,11 @@ import type { import { CONDITION_CODE_TOKENS, COVERAGE_ATTRIBUTE_VALUES, + COVERAGE_MODES, + COVERAGE_TARGETS_VALUES, DEPENDENCY_EDGE_KINDS, + GROUP_KINDS, + POLICY_RULE_TYPES, PREVIEW_EDIT_CLASSES, REFUSAL_CODE_TOKENS, conditionIdentityOf, @@ -81,7 +95,9 @@ import { at, describeJsonValue, expectArray, + expectBoolean, expectNonEmptyString, + expectNonEmptyStringArray, expectNonNegativeInteger, expectObject, expectString, @@ -612,6 +628,385 @@ export function decodeInventoryAnchoring( }; } +// --- scoped inventory decode: configuration, sources, derived (11.6, 12.7) ---- + +/** + * A member that is the stated `null` or a 12.7 path value (`markdown.outDir` + * unset; a non-generating source's `module`/`markdown`). The member must be + * present — `null` is never omission (12.7) — and a present value must be a + * well-formed path value; the unavailability marker is no path value and + * rejects (these members are configuration- and discovery-determined, never + * record-supplied, 11.6). + */ +function decodeNullablePathMember( + obj: Record<string, unknown>, + key: string, + site: DecodeSite, +): PathValue | null { + const value = requiredMember(obj, key, site); + if (value === null) return null; + return decodePathValue(value, at(site, key)); +} + +/** One group of the view: `{"name", "globs"}` exactly (12.7). */ +function decodeInventoryGroupDef( + value: unknown, + site: DecodeSite, +): InventoryGroupDef { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["name", "globs"], site); + return { + name: expectNonEmptyString( + requiredKey(obj, "name", site), + at(site, "name"), + ), + globs: expectNonEmptyStringArray( + requiredKey(obj, "globs", site), + at(site, "globs"), + ), + }; +} + +/** A group list of the view (`specs`/`code`), each entry `{"name","globs"}`. */ +function decodeInventoryGroupList( + value: unknown, + site: DecodeSite, +): InventoryGroupDef[] { + return expectArray(value, site).map((element, index) => + decodeInventoryGroupDef(element, at(site, index)), + ); +} + +/** An edge-kind list member (`edgeKinds`/`kinds`): 5.2's tokens only. */ +function decodeEdgeKindList( + value: unknown, + site: DecodeSite, +): DependencyEdgeKind[] { + return expectArray(value, site).map((element, index) => + expectToken(element, DEPENDENCY_EDGE_KINDS, at(site, index)), + ); +} + +const COVERAGE_PROFILE_VIEW_MEMBERS = [ + "name", + "target", + "targetTags", + "targets", + "boundary", + "boundaryKind", + "mode", + "edgeKinds", +] as const; + +/** + * One resolved coverage profile (12.7): all eight members present — every + * default and inferred kind explicit (11.6) — `targetTags` `null` where + * absent, never omitted. + */ +function decodeCoverageProfileView( + value: unknown, + site: DecodeSite, +): InventoryCoverageProfileView { + const obj = expectObject(value, site); + expectOnlyMembers(obj, COVERAGE_PROFILE_VIEW_MEMBERS, site); + const targetTagsValue = requiredMember(obj, "targetTags", site); + return { + name: expectNonEmptyString( + requiredKey(obj, "name", site), + at(site, "name"), + ), + target: expectNonEmptyString( + requiredKey(obj, "target", site), + at(site, "target"), + ), + targetTags: + targetTagsValue === null + ? null + : expectNonEmptyStringArray(targetTagsValue, at(site, "targetTags")), + targets: expectToken( + requiredKey(obj, "targets", site), + COVERAGE_TARGETS_VALUES, + at(site, "targets"), + ), + boundary: expectNonEmptyString( + requiredKey(obj, "boundary", site), + at(site, "boundary"), + ), + boundaryKind: expectToken( + requiredKey(obj, "boundaryKind", site), + GROUP_KINDS, + at(site, "boundaryKind"), + ), + mode: expectToken( + requiredKey(obj, "mode", site), + COVERAGE_MODES, + at(site, "mode"), + ), + edgeKinds: decodeEdgeKindList( + requiredKey(obj, "edgeKinds", site), + at(site, "edgeKinds"), + ), + }; +} + +/** + * A resolved policy selector (7.5, 12.7): exactly one of `{"group","kind"}` + * (the kind explicit though inferred), `{"files"}`, or `{"tags"}`. + */ +function decodePolicySelectorView( + value: unknown, + site: DecodeSite, +): InventoryPolicySelector { + const obj = expectObject(value, site); + if (Object.hasOwn(obj, "group")) { + expectOnlyMembers(obj, ["group", "kind"], site); + return { + group: expectNonEmptyString( + requiredKey(obj, "group", site), + at(site, "group"), + ), + kind: expectToken( + requiredKey(obj, "kind", site), + GROUP_KINDS, + at(site, "kind"), + ), + }; + } + if (Object.hasOwn(obj, "files")) { + expectOnlyMembers(obj, ["files"], site); + return { + files: expectNonEmptyString( + requiredKey(obj, "files", site), + at(site, "files"), + ), + }; + } + if (Object.hasOwn(obj, "tags")) { + expectOnlyMembers(obj, ["tags"], site); + return { + tags: expectNonEmptyStringArray( + requiredKey(obj, "tags", site), + at(site, "tags"), + ), + }; + } + formFail( + site, + 'a selector in exactly one of the forms {"group", "kind"}, {"files"}, ' + + 'or {"tags"} (SPEC 7.5, 12.7)', + value, + ); +} + +/** One resolved policy rule (12.7): `{"name","type","from","to","kinds"}`. */ +function decodePolicyRuleView( + value: unknown, + site: DecodeSite, +): InventoryPolicyRuleView { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["name", "type", "from", "to", "kinds"], site); + return { + name: expectNonEmptyString( + requiredKey(obj, "name", site), + at(site, "name"), + ), + type: expectToken( + requiredKey(obj, "type", site), + POLICY_RULE_TYPES, + at(site, "type"), + ), + from: decodePolicySelectorView( + requiredKey(obj, "from", site), + at(site, "from"), + ), + to: decodePolicySelectorView(requiredKey(obj, "to", site), at(site, "to")), + kinds: decodeEdgeKindList( + requiredKey(obj, "kinds", site), + at(site, "kinds"), + ), + }; +} + +/** One `sources` entry: `{"path", "groups"}` exactly (12.7). */ +function decodeInventorySourceEntry( + value: unknown, + site: DecodeSite, +): InventorySourceEntry { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["path", "groups"], site); + const groupsSite = at(site, "groups"); + return { + path: decodePathValue(requiredKey(obj, "path", site), at(site, "path")), + groups: expectArray(requiredKey(obj, "groups", site), groupsSite).map( + (element, index) => { + const membershipSite = at(groupsSite, index); + const membership = expectObject(element, membershipSite); + expectOnlyMembers(membership, ["name", "kind"], membershipSite); + return { + name: expectNonEmptyString( + requiredKey(membership, "name", membershipSite), + at(membershipSite, "name"), + ), + kind: expectToken( + requiredKey(membership, "kind", membershipSite), + GROUP_KINDS, + at(membershipSite, "kind"), + ), + }; + }, + ), + }; +} + +/** One `derived` entry: `{"source", "module", "markdown"}` exactly (12.7). */ +function decodeInventoryDerivedEntry( + value: unknown, + site: DecodeSite, +): InventoryDerivedEntry { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["source", "module", "markdown"], site); + return { + source: decodePathValue( + requiredKey(obj, "source", site), + at(site, "source"), + ), + module: decodeNullablePathMember(obj, "module", site), + markdown: decodeNullablePathMember(obj, "markdown", site), + }; +} + +/** + * Scoped decode of the inventory document's `configuration`, `sources`, and + * `derived` members (SPEC 11.6, 12.7; T11.6-2's subject): the resolved + * configuration view `{"specs", "code", "markdown", "coverage", "policy"}` — + * every member present, every default and inferred kind explicit, each + * group/profile/rule carried with its complete definition in the 12.7 member + * forms — one `{"path", "groups"}` per discovered file, and one `{"source", + * "module", "markdown"}` per discovered spec source. The `sources` and + * `derived` lists must arrive in byte order of workspace-relative path with + * one entry per file (11.6/12.7 pin that order; configuration order for + * groups, profiles, and rules is the caller's value assertion — this decoder + * cannot know the configuration). Deliberately scoped exactly as + * `decodeInventoryRecordedDatum` is: SPEC 12.7 fixes the whole inventory + * form and the T11.6-* tests pin it entirely; every other member stays + * unread here. Form-exact (H-3): never adjustable to a product's shape. + */ +export function decodeInventoryResolvedMap( + doc: unknown, + context?: string, +): InventoryResolvedMap { + const site = rootSite("11.6 inventory (resolved map)", context); + const obj = expectObject(doc, site); + + const configurationSite = at(site, "configuration"); + const configurationObj = expectObject( + requiredKey(obj, "configuration", site), + configurationSite, + ); + expectOnlyMembers( + configurationObj, + ["specs", "code", "markdown", "coverage", "policy"], + configurationSite, + ); + const markdownSite = at(configurationSite, "markdown"); + const markdownObj = expectObject( + requiredKey(configurationObj, "markdown", configurationSite), + markdownSite, + ); + expectOnlyMembers(markdownObj, ["emit", "outDir"], markdownSite); + const coverageSite = at(configurationSite, "coverage"); + const policySite = at(configurationSite, "policy"); + const configuration: InventoryConfigurationView = { + specs: decodeInventoryGroupList( + requiredKey(configurationObj, "specs", configurationSite), + at(configurationSite, "specs"), + ), + code: decodeInventoryGroupList( + requiredKey(configurationObj, "code", configurationSite), + at(configurationSite, "code"), + ), + markdown: { + emit: expectBoolean( + requiredKey(markdownObj, "emit", markdownSite), + at(markdownSite, "emit"), + ), + outDir: decodeNullablePathMember(markdownObj, "outDir", markdownSite), + }, + coverage: expectArray( + requiredKey(configurationObj, "coverage", configurationSite), + coverageSite, + ).map((element, index) => + decodeCoverageProfileView(element, at(coverageSite, index)), + ), + policy: expectArray( + requiredKey(configurationObj, "policy", configurationSite), + policySite, + ).map((element, index) => + decodePolicyRuleView(element, at(policySite, index)), + ), + }; + + const sourcesSite = at(site, "sources"); + const sources = expectArray( + requiredKey(obj, "sources", site), + sourcesSite, + ).map((element, index) => + decodeInventorySourceEntry(element, at(sourcesSite, index)), + ); + for (let i = 1; i < sources.length; i += 1) { + const order = Buffer.compare( + pathValueBytes(sources[i - 1]!.path), + pathValueBytes(sources[i]!.path), + ); + if (order === 0) { + formFail( + at(sourcesSite, i), + 'one {"path", "groups"} entry per discovered file (SPEC 12.7)', + obj["sources"], + ); + } + if (order > 0) { + formFail( + at(sourcesSite, i), + "source entries in byte order of workspace-relative path (SPEC 11.6)", + obj["sources"], + ); + } + } + + const derivedSite = at(site, "derived"); + const derived = expectArray( + requiredKey(obj, "derived", site), + derivedSite, + ).map((element, index) => + decodeInventoryDerivedEntry(element, at(derivedSite, index)), + ); + for (let i = 1; i < derived.length; i += 1) { + const order = Buffer.compare( + pathValueBytes(derived[i - 1]!.source), + pathValueBytes(derived[i]!.source), + ); + if (order === 0) { + formFail( + at(derivedSite, i), + 'one {"source", "module", "markdown"} entry per discovered spec ' + + "source (SPEC 12.7)", + obj["derived"], + ); + } + if (order > 0) { + formFail( + at(derivedSite, i), + "derived entries in byte order of workspace-relative source path " + + "(SPEC 11.6)", + obj["derived"], + ); + } + } + + return { configuration, sources, derived }; +} + // --- the occurrences document (5.7, 11.3, 12.7) ------------------------------- const OCCURRENCE_RECORD_MEMBERS = [ diff --git a/test/helpers/adapters/model.ts b/test/helpers/adapters/model.ts index a4ab881..608fb2a 100644 --- a/test/helpers/adapters/model.ts +++ b/test/helpers/adapters/model.ts @@ -491,6 +491,125 @@ export interface InventoryAnchoring { readonly config: PathValue; } +/** Group kinds (SPEC.md 7.1, 7.2): every group is a spec or a code group. */ +export const GROUP_KINDS = ["spec", "code"] as const; +export type GroupKind = (typeof GROUP_KINDS)[number]; + +/** `targets` values of a coverage profile (SPEC.md 7.4). */ +export const COVERAGE_TARGETS_VALUES = ["leaves", "all"] as const; +export type CoverageTargetsValue = (typeof COVERAGE_TARGETS_VALUES)[number]; + +/** `mode` values of a coverage profile (SPEC.md 7.4). */ +export const COVERAGE_MODES = ["direct", "transitive"] as const; +export type CoverageMode = (typeof COVERAGE_MODES)[number]; + +/** Policy rule types (SPEC.md 7.5). */ +export const POLICY_RULE_TYPES = ["forbidden", "allowedOnly"] as const; +export type PolicyRuleType = (typeof POLICY_RULE_TYPES)[number]; + +/** One group of the resolved configuration view: `{"name", "globs"}` (12.7). */ +export interface InventoryGroupDef { + readonly name: string; + readonly globs: readonly string[]; +} + +/** + * The resolved `markdown` view — `{"emit", "outDir"}` exactly: `outDir` + * `null` where unset, and an absent configuration key resolving to + * `{"emit": false, "outDir": null}` (SPEC.md 7.3, 11.6, 12.7). + */ +export interface InventoryMarkdownView { + readonly emit: boolean; + readonly outDir: PathValue | null; +} + +/** + * One resolved coverage profile — every default and inferred kind explicit + * (SPEC.md 7.4, 11.6, 12.7): `targetTags` `null` where absent, `targets` + * `"leaves"` where defaulted, `boundaryKind` explicit though inferred, + * `edgeKinds` all three where defaulted. `target` and `boundary` stay + * configured group names, resolving against the view's own group lists. + */ +export interface InventoryCoverageProfileView { + readonly name: string; + readonly target: string; + readonly targetTags: readonly string[] | null; + readonly targets: CoverageTargetsValue; + readonly boundary: string; + readonly boundaryKind: GroupKind; + readonly mode: CoverageMode; + readonly edgeKinds: readonly DependencyEdgeKind[]; +} + +/** + * A resolved policy selector (SPEC.md 7.5, 12.7): exactly one of the three + * forms — a group selector `{"group", "kind"}` with the kind explicit though + * inferred, `{"files"}`, or `{"tags"}`. + */ +export type InventoryPolicySelector = + | { readonly group: string; readonly kind: GroupKind } + | { readonly files: string } + | { readonly tags: readonly string[] }; + +/** One resolved policy rule — `kinds` all three where defaulted (7.5, 12.7). */ +export interface InventoryPolicyRuleView { + readonly name: string; + readonly type: PolicyRuleType; + readonly from: InventoryPolicySelector; + readonly to: InventoryPolicySelector; + readonly kinds: readonly DependencyEdgeKind[]; +} + +/** + * The inventory document's `configuration` member (SPEC.md 11.6, 12.7): the + * resolved configuration view — `{"specs", "code", "markdown", "coverage", + * "policy"}` exactly, groups/profiles/rules each carried with its complete + * definition, never as a bare name. + */ +export interface InventoryConfigurationView { + readonly specs: readonly InventoryGroupDef[]; + readonly code: readonly InventoryGroupDef[]; + readonly markdown: InventoryMarkdownView; + readonly coverage: readonly InventoryCoverageProfileView[]; + readonly policy: readonly InventoryPolicyRuleView[]; +} + +/** One group membership of a discovered source: `{"name", "kind"}` (12.7). */ +export interface InventoryGroupMembership { + readonly name: string; + readonly kind: GroupKind; +} + +/** One `sources` entry: `{"path", "groups"}` per discovered file (12.7). */ +export interface InventorySourceEntry { + readonly path: PathValue; + readonly groups: readonly InventoryGroupMembership[]; +} + +/** + * One `derived` entry — `{"source", "module", "markdown"}` per discovered + * spec source (SPEC.md 11.6, 13.1, 12.7): `module` and `markdown` `null` for + * a spec-group file without the `.mdx` extension, `markdown` `null` also + * while emission is disabled (7.3). + */ +export interface InventoryDerivedEntry { + readonly source: PathValue; + readonly module: PathValue | null; + readonly markdown: PathValue | null; +} + +/** + * The inventory document's resolved configuration/sources/derived projection + * (SPEC.md 11.6, 12.7 — decoded by `decodeInventoryResolvedMap`; T11.6-2's + * subject). The document's other members are outside this scoped projection + * (the full inventory form is pinned across the T11.6-* tests). + */ +export interface InventoryResolvedMap { + readonly configuration: InventoryConfigurationView; + readonly sources: readonly InventorySourceEntry[]; + readonly derived: readonly InventoryDerivedEntry[]; +} + /** `coverage` (T8.2-1): all profiles by default, one when named. */ export interface CoverageReport { readonly profiles: readonly CoverageProfileReport[]; diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index c279121..ad2c0c4 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -48,6 +48,7 @@ import { decodeInventoryAnchoring, decodeInventoryFindings, decodeInventoryRecordedDatum, + decodeInventoryResolvedMap, decodeItemReport, decodeNextReport, decodeNodeMetadataSummary, @@ -687,6 +688,72 @@ const GOOD_EXPORT = { items: [GOOD_ITEM], }; +// An inventory document's configuration/sources/derived projection in the +// literal SPEC 12.7 member forms (11.6): the resolved view with every +// default and inferred kind explicit — `markdown` unset resolving to +// emit-false/outDir-null, `targetTags` the stated null, `boundaryKind` and +// selector kinds explicit — one `{"path","groups"}` per discovered file in +// byte order, one `{"source","module","markdown"}` per discovered spec +// source. The two selector forms beyond the group form and the derived-map +// nulls appear so the positive control spans the shape space T11.6-2 +// asserts. +const GOOD_RESOLVED_INVENTORY = { + findings: [], + root: ".", + config: "xspec.config.ts", + configuration: { + specs: [ + { + name: "core", + globs: ["specs/core/**/*.mdx", "specs/shared/**/*.mdx"], + }, + { name: "aux", globs: ["specs/aux/**/*.mdx"] }, + ], + code: [{ name: "impl", globs: ["src/**/*.ts"] }], + markdown: { emit: false, outDir: null }, + coverage: [ + { + name: "socle", + target: "core", + targetTags: null, + targets: "leaves", + boundary: "impl", + boundaryKind: "code", + mode: "direct", + edgeKinds: ["depends", "embeds", "references"], + }, + ], + policy: [ + { + name: "cloison", + type: "forbidden", + from: { group: "aux", kind: "spec" }, + to: { files: "specs/core/**" }, + kinds: ["depends"], + }, + ], + }, + sources: [ + { path: "specs/aux/b.mdx", groups: [{ name: "aux", kind: "spec" }] }, + { path: "specs/core/a.mdx", groups: [{ name: "core", kind: "spec" }] }, + { path: "src/app.ts", groups: [{ name: "impl", kind: "code" }] }, + ], + derived: [ + { + source: "specs/aux/b.mdx", + module: "specs/aux/b.xspec.ts", + markdown: null, + }, + { + source: "specs/core/a.mdx", + module: "specs/core/a.xspec.ts", + markdown: "specs/core/a.md", + }, + ], + recorded: [], + graphData: ".xspec", +}; + // --- decoder table ----------------------------------------------------------- interface BadCase { @@ -2771,6 +2838,268 @@ const DECODERS: readonly DecoderSpec[] = [ }, ], }, + { + // The scoped inventory resolved-map decode (SPEC 11.6, 12.7; T11.6-2's + // subject): exactly the `configuration`, `sources`, and `derived` + // members in the 12.7 member forms — every default and inferred kind + // explicit, `null` never omission, sources/derived in byte order — with + // every other member unread (the full inventory form is T11.6-*'s + // subject). + name: "11.6 inventory (resolved map)", + decode: decodeInventoryResolvedMap, + good: GOOD_RESOLVED_INVENTORY, + verify: (decoded: ReturnType<typeof decodeInventoryResolvedMap>) => { + expect(decoded.configuration.specs.map((g) => g.name)).toEqual([ + "core", + "aux", + ]); + expect(decoded.configuration.markdown).toEqual({ + emit: false, + outDir: null, + }); + const profile = decoded.configuration.coverage[0]!; + expect(profile.targetTags).toBeNull(); + expect(profile.targets).toBe("leaves"); + expect(profile.boundaryKind).toBe("code"); + expect(profile.edgeKinds).toEqual(["depends", "embeds", "references"]); + const rule = decoded.configuration.policy[0]!; + expect(rule.from).toEqual({ group: "aux", kind: "spec" }); + expect(rule.to).toEqual({ files: "specs/core/**" }); + expect(decoded.sources.map((s) => s.path)).toEqual([ + "specs/aux/b.mdx", + "specs/core/a.mdx", + "src/app.ts", + ]); + expect(decoded.sources[2]!.groups).toEqual([ + { name: "impl", kind: "code" }, + ]); + expect(decoded.derived[0]!.markdown).toBeNull(); + expect(decoded.derived[1]!).toEqual({ + source: "specs/core/a.mdx", + module: "specs/core/a.xspec.ts", + markdown: "specs/core/a.md", + }); + }, + alsoGood: [ + { + label: + "a tags selector, a non-UTF-8 source path in the marked byte " + + "form, and a non-generating source's null module/markdown all " + + "decode as stated (SPEC 12.7, 11.6)", + doc: put( + put( + put( + GOOD_RESOLVED_INVENTORY, + { tags: ["stable", "v2"] }, + "configuration", + "policy", + 0, + "to", + ), + [ + ...GOOD_RESOLVED_INVENTORY.sources, + // 0xff… sorts after every ASCII path: byte order holds. + { + path: { bytes: "ff2e6d64" }, + groups: [{ name: "core", kind: "spec" }], + }, + ], + "sources", + ), + [ + ...GOOD_RESOLVED_INVENTORY.derived, + { source: { bytes: "ff2e6d64" }, module: null, markdown: null }, + ], + "derived", + ), + verify: ( + decoded: ReturnType<typeof decodeInventoryResolvedMap>, + ): void => { + expect(decoded.configuration.policy[0]!.to).toEqual({ + tags: ["stable", "v2"], + }); + expect(decoded.sources[3]!.path).toEqual({ bytes: "ff2e6d64" }); + expect(decoded.derived[2]!).toEqual({ + source: { bytes: "ff2e6d64" }, + module: null, + markdown: null, + }); + }, + }, + ], + bad: [ + { + label: + "absent configuration member (null is never omission, SPEC 12.7)", + doc: omit(GOOD_RESOLVED_INVENTORY, "configuration"), + }, + { + label: + "an extra member inside configuration (the form carries exactly " + + "specs/code/markdown/coverage/policy, SPEC 12.7)", + doc: put(GOOD_RESOLVED_INVENTORY, true, "configuration", "extra"), + }, + { + label: + "markdown without outDir (unset is the stated null, never " + + "omission, SPEC 12.7)", + doc: omit( + GOOD_RESOLVED_INVENTORY, + "configuration", + "markdown", + "outDir", + ), + }, + { + label: "a non-boolean emit", + doc: put( + GOOD_RESOLVED_INVENTORY, + "false", + "configuration", + "markdown", + "emit", + ), + }, + { + label: + "a group carried as a bare name instead of its complete " + + "definition (SPEC 11.6: never as a bare name)", + doc: put(GOOD_RESOLVED_INVENTORY, ["core"], "configuration", "specs"), + }, + { + label: + "a profile without targetTags (an absent targetTags is the " + + "stated null — every default explicit, SPEC 11.6, 12.7)", + doc: omit( + GOOD_RESOLVED_INVENTORY, + "configuration", + "coverage", + 0, + "targetTags", + ), + }, + { + label: + "a profile without boundaryKind (explicit though inferred, SPEC " + + "11.6, 7.4)", + doc: omit( + GOOD_RESOLVED_INVENTORY, + "configuration", + "coverage", + 0, + "boundaryKind", + ), + }, + { + label: + 'edgeKinds carrying "contains" (no dependency edge kind, SPEC ' + + "5.2, 7.4)", + doc: put( + GOOD_RESOLVED_INVENTORY, + ["contains"], + "configuration", + "coverage", + 0, + "edgeKinds", + ), + }, + { + label: + "a group selector without kind (explicit though inferred, SPEC " + + "7.5, 12.7)", + doc: omit( + GOOD_RESOLVED_INVENTORY, + "configuration", + "policy", + 0, + "from", + "kind", + ), + }, + { + label: "a selector of no 7.5 form", + doc: put( + GOOD_RESOLVED_INVENTORY, + { unit: "x" }, + "configuration", + "policy", + 0, + "from", + ), + }, + { + label: "a selector mixing the group and files forms", + doc: put( + GOOD_RESOLVED_INVENTORY, + { group: "aux", kind: "spec", files: "src/**" }, + "configuration", + "policy", + 0, + "from", + ), + }, + { + label: + "source entries out of byte order (SPEC 11.6: files and paths " + + "in byte order of workspace-relative path)", + doc: put( + GOOD_RESOLVED_INVENTORY, + [ + GOOD_RESOLVED_INVENTORY.sources[1], + GOOD_RESOLVED_INVENTORY.sources[0], + GOOD_RESOLVED_INVENTORY.sources[2], + ], + "sources", + ), + }, + { + label: "duplicate source entries (one entry per discovered file)", + doc: put( + GOOD_RESOLVED_INVENTORY, + [ + GOOD_RESOLVED_INVENTORY.sources[0], + GOOD_RESOLVED_INVENTORY.sources[0], + ], + "sources", + ), + }, + { + label: "a membership without kind", + doc: omit(GOOD_RESOLVED_INVENTORY, "sources", 0, "groups", 0, "kind"), + }, + { + label: + "a valid-UTF-8 source path in the marked byte form (SPEC 12.7 " + + "forbids it)", + doc: put( + GOOD_RESOLVED_INVENTORY, + { bytes: "612e6d64" }, + "sources", + 0, + "path", + ), + }, + { + label: + "a derived entry without markdown (structural absence is the " + + "stated null, never omission, SPEC 11.6, 12.7)", + doc: omit(GOOD_RESOLVED_INVENTORY, "derived", 0, "markdown"), + }, + { + label: + "a derived module as the unavailability marker (the projection " + + "is configuration- and discovery-determined, never " + + "record-supplied, SPEC 11.6)", + doc: put( + GOOD_RESOLVED_INVENTORY, + { unavailable: true }, + "derived", + 0, + "module", + ), + }, + ], + }, { name: "coverage", decode: decodeCoverageReport, diff --git a/test/suite/registry/section-11.6.ts b/test/suite/registry/section-11.6.ts index dd74307..695865b 100644 --- a/test/suite/registry/section-11.6.ts +++ b/test/suite/registry/section-11.6.ts @@ -1,5 +1,5 @@ -// TEST-SPEC §11.6 (`xspec inventory`) — SUITE-56: T11.6-1 (T11.6-2..T11.6-4 -// register here as they are implemented). +// TEST-SPEC §11.6 (`xspec inventory`) — SUITE-56: T11.6-1, T11.6-2 +// (T11.6-3..T11.6-4 register here as they are implemented). // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -7,11 +7,11 @@ // assertion failures (H-8). SPEC 11: `inventory` is JSON-only — a single JSON // document is its only output form, with or without `--json` — in the // form-exact 12.7 inventory document form (H-3), so every invocation below -// runs bare (one arm additionally with `--json`, asserting the two forms -// carry the same information, SPEC 11) and its stdout decodes through the -// scoped form-exact decoders `decodeInventoryAnchoring` and -// `decodeInventoryFindings` (the full inventory form is pinned across the -// T11.6-* tests as they land). +// runs bare (per-test arms additionally with `--json`, asserting the two +// forms carry the same information, SPEC 11) and its stdout decodes through +// the scoped form-exact decoders `decodeInventoryAnchoring`, +// `decodeInventoryFindings`, and `decodeInventoryResolvedMap` (the full +// inventory form is pinned across the T11.6-* tests as they land). // // T11.6-1 — anchoring (SPEC 11.6, 12.0). The workspace root and the // configuration file are identified relative to the invocation working @@ -43,19 +43,68 @@ // byte-identical stdout, deterministic per invocation (SPEC 12.0; a // product-to-itself comparison, H-4). // -// Every answer here is complete and finding-free on a valid workspace — -// `findings` decodes to [] and the exit code is 0 (SPEC 12.0, 11.6). +// T11.6-2 — configuration, sources, derived map (SPEC 11.6, 12.7, 7.3, +// 13.1). The resolved configuration view with every default and inferred +// kind explicit, every discovered source with its group memberships, and the +// per-spec-source derived map — all determined by configuration and +// discovery, asserted before any build has ever run. Four workspaces: +// +// - defaults: `markdown` key absent → the view reports `{"emit": false, +// "outDir": null}` (7.3) and `derived[*].markdown` null for every source +// (emission disabled by absence); a profile spelling only its required +// fields → `targets` "leaves", `edgeKinds` all three, `boundaryKind` +// explicit though inferred (the boundary group name is unambiguous), +// `targetTags` null; a rule spelling only its required fields → `kinds` +// all three, each group selector's `kind` explicit though inferred; group +// references inside the profile and rule stay configured names resolving +// against the reported group list; a file matched by two spec groups +// carries both memberships (7.1) in configuration order (11.6); the whole +// document asserted exactly, flag-less and `--json` forms against the same +// expectation (same information, SPEC 11); +// - emission enabled, default destinations: `module` and `markdown` both +// present for every `.mdx` source before any build has run (13.1/7.3 — +// determined by configuration and discovery, never by what exists on +// disk); beside them a spec-group file without the `.mdx` extension +// (14.19 staged beside it, SPEC 7.1) is listed in `sources` with its +// membership while its `module` and `markdown` are the stated +// structural-absence null (11.6/13.1/12.7) — and the answer stays +// complete, finding-free, exit 0: the 14.19 finding is reported where its +// condition assigns it, never here (11.6); +// - emission redirected: `markdown.outDir` echoes in the view and every +// emit destination lies under it, preserving workspace-relative paths +// (7.3), nested source included; +// - emission disabled explicitly: `emit` false with `outDir` configured — +// the view reports both, and `derived[*].markdown` is null for every +// source (destinations exist exactly while emission is enabled, 7.3). +// +// `edgeKinds`/`kinds` element order is no pinned order (11.6 orders files/ +// paths, groups, profiles, rules, and session files only), so those two +// members are compared as sets (sorted before the exact compare); every +// other list is asserted in its pinned order — sources/derived in byte order +// of workspace-relative path, groups/profiles/rules in configuration order. +// +// Every answer here is complete and finding-free — `findings` decodes to [] +// and the exit code is 0 (SPEC 12.0, 11.6) — T11.6-1's workspaces being +// valid, and T11.6-2's 14.19 staging never being the inventory's finding. // // Certification note: CERTIFICATIONS.md's Exclusions list T11.6-1 through // T11.6-4 ("`inventory` and `version`"), so no fixture executes these -// bodies; the anchoring arms are positive and byte-asserted per that entry. +// bodies; the anchoring, resolved-configuration, and derived-map arms are +// positive and byte-asserted per that entry. import * as fsp from "node:fs/promises"; import * as path from "node:path"; -import type { PathValue } from "../../helpers/adapters/index.js"; +import type { + DependencyEdgeKind, + GroupKind, + InventoryConfigurationView, + InventoryResolvedMap, + PathValue, +} from "../../helpers/adapters/index.js"; import { decodeInventoryAnchoring, decodeInventoryFindings, + decodeInventoryResolvedMap, renderPathValue, } from "../../helpers/adapters/index.js"; import { @@ -69,6 +118,7 @@ import type { ProductTestEntry } from "../../helpers/registry.js"; import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; import { runProduct } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import { assertSameJson } from "./support.js"; // --- fixture ------------------------------------------------------------------ // @@ -421,4 +471,546 @@ const T11_6_1 = defineProductTest({ }, }); -export const section116Tests: readonly ProductTestEntry[] = [T11_6_1]; +// --- T11.6-2 ------------------------------------------------------------------ +// +// Fixtures. Every configuration is statically literal (SPEC 7) and valid — +// a configuration error would preempt the inventory (14.14) — and no arm +// ever runs `build`: the configuration/sources/derived projection is +// determined by configuration and discovery alone (SPEC 11.6). + +/** + * Defaults workspace: `markdown` absent; two spec groups declared in an + * order (`core` before `aux`) that differs from name byte order, so the + * configuration-order contract has teeth; a profile and a rule spelling + * only their required fields (SPEC 7.4, 7.5) so every default and inferred + * kind must be made explicit in the view; `boundary`/selector group names + * unambiguous, so their kinds MUST be inferred (7.4, 7.5). + */ +const RESOLVED_DEFAULTS_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + core: ["specs/core/**/*.mdx", "specs/shared/**/*.mdx"], + aux: ["specs/aux/**/*.mdx", "specs/shared/**/*.mdx"] + }, + code: { + impl: ["src/**/*.ts"] + }, + coverage: [ + { + name: "socle", + target: "core", + boundary: "impl", + mode: "direct" + } + ], + policy: [ + { + name: "cloison", + type: "forbidden", + from: { group: "aux" }, + to: { group: "core" } + } + ] +}) +`; + +/** + * Emission enabled with the default next-to-source destinations (SPEC 7.3), + * and the glob `specs/*` written extension-free so `specs/note.txt` is a + * discovered spec-group file without the `.mdx` extension — the 14.19 + * staging beside the valid source (SPEC 7.1). + */ +const RESOLVED_EMIT_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*"] + }, + markdown: { emit: true } +}) +`; + +/** Emission redirected under `markdown.outDir` (SPEC 7.3). */ +const RESOLVED_OUTDIR_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + docs: ["specs/**/*.mdx"] + }, + markdown: { emit: true, outDir: "mdout" } +}) +`; + +/** + * Emission disabled explicitly — `emit` false with `outDir` configured: the + * view reports the complete definition while no path is a Markdown emit + * destination (SPEC 7.3). + */ +const RESOLVED_DISABLED_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + markdown: { emit: false, outDir: "docsout" } +}) +`; + +/** + * The three dependency edge kinds in byte order — the shape `edgeKinds` and + * `kinds` normalize to for the set compare (SPEC 7.4/7.5: both default to + * all three; 11.6 pins no element order for them). + */ +const ALL_EDGE_KINDS_SORTED: readonly DependencyEdgeKind[] = [ + "depends", + "embeds", + "references", +]; + +/** + * Normalize the two set-valued members (`edgeKinds`, `kinds`) to byte-sorted + * copies so `assertSameJson` compares them as sets: SPEC 11.6 orders files/ + * paths, groups, profiles, rules, and session files — not edge-kind lists — + * so element order there is no contract. A duplicated or missing kind still + * fails the exact compare (the normalized list's length changes). Every + * other list is left exactly as reported: sources/derived arrive byte- + * ordered (decoder-enforced) and groups/profiles/rules must arrive in + * configuration order (asserted by the exact compare). + */ +function normalizeKindSets(map: InventoryResolvedMap): InventoryResolvedMap { + return { + ...map, + configuration: { + ...map.configuration, + coverage: map.configuration.coverage.map((profile) => ({ + ...profile, + edgeKinds: [...profile.edgeKinds].sort(), + })), + policy: map.configuration.policy.map((rule) => ({ + ...rule, + kinds: [...rule.kinds].sort(), + })), + }, + }; +} + +/** + * SPEC 11.6: "A group reference inside a profile or rule stays the + * configured group name, resolving against the group list this same view + * reports." Assert every profile's `target` (a spec group, 7.4) and + * `boundary` (per its explicit `boundaryKind`) and every group selector + * (per its explicit `kind`) name a group the view's own lists report. + */ +function assertGroupReferencesResolve( + view: InventoryConfigurationView, + context: string, +): void { + const names: Record<GroupKind, ReadonlySet<string>> = { + spec: new Set(view.specs.map((group) => group.name)), + code: new Set(view.code.map((group) => group.name)), + }; + const resolve = (name: string, kind: GroupKind, what: string): void => { + if (names[kind].has(name)) return; + fail( + `${context}: ${what} is the configured group name ` + + `${JSON.stringify(name)} and must resolve against the ${kind} group ` + + `list this same view reports (SPEC 11.6) — reported ${kind} groups: ` + + `${[...names[kind]].map((n) => JSON.stringify(n)).join(", ") || "none"}`, + ); + }; + for (const profile of view.coverage) { + resolve(profile.target, "spec", `profile "${profile.name}"'s target`); + resolve( + profile.boundary, + profile.boundaryKind, + `profile "${profile.name}"'s boundary`, + ); + } + for (const rule of view.policy) { + for (const [side, selector] of [ + ["from", rule.from], + ["to", rule.to], + ] as const) { + if ("group" in selector) { + resolve( + selector.group, + selector.kind, + `rule "${rule.name}"'s ${side} selector`, + ); + } + } + } +} + +/** + * Run `inventory` from the workspace root and assert the T11.6-2 frame: + * exit 0 exactly (a complete, finding-free answer — the findings a listed + * file may bear, 14.19 included, are reported where their conditions assign + * them, never here; SPEC 11.6, 12.0; H-5); exactly one JSON document as the + * entire stdout (JSON-only, SPEC 11); `findings` decoding to [] (form-exact, + * 12.7); the configuration/sources/derived projection decoding in the 12.7 + * member forms; and every group reference resolving against the reported + * group list. Returns the decoded projection for the caller's exact-value + * assertion. + */ +async function expectResolvedInventory( + product: ProductBinding, + cwd: string, + argv: readonly string[], + context: string, +): Promise<InventoryResolvedMap> { + const result = await runProduct(product, { cwd, argv }); + assertExitCode( + result, + 0, + `${context} — a complete, finding-free inventory answer exits 0: the ` + + `inventory parses no sources and meets no condition on these ` + + `workspaces, and the findings a listed file may bear (14.19) are ` + + `reported where their conditions assign them, never here (SPEC 11.6, ` + + `12.0)`, + ); + const doc = parseJsonStdout( + result, + `${context} — inventory is JSON-only: a single JSON document is its ` + + `only output form, with or without --json (SPEC 11, 12.0)`, + ); + const findings = decodeInventoryFindings(doc, context); + if (findings.length !== 0) { + fail( + `${context}: the inventory answer is finding-free — findings [] ` + + `(SPEC 11.6, 12.7: the only finding an inventory ever carries is ` + + `condition 23, and no arm here corrupts the record); got ` + + `${String(findings.length)} finding(s), first: ` + + `${JSON.stringify(findings[0]?.message)}`, + ); + } + const map = decodeInventoryResolvedMap(doc, context); + assertGroupReferencesResolve(map.configuration, context); + return map; +} + +const T11_6_2 = defineProductTest({ + id: "T11.6-2", + title: + 'inventory configuration, sources, derived map: the resolved configuration view with every default and inferred kind explicit — `markdown` key absent resolving to {"emit": false, "outDir": null}; a defaulted profile reporting `targets` "leaves", `edgeKinds` all three, `boundaryKind` explicit though inferred, `targetTags` null; a defaulted rule reporting `kinds` all three with each group selector\'s `kind` explicit though inferred; group references inside profiles and rules staying configured names resolving against the reported group list — every discovered source with its group memberships (a two-group file carrying both, in configuration order); the derived map per spec source: generated-module path (13.1) and Markdown emit destination exactly while emission is enabled (default next-to-source and `markdown.outDir`-redirected placements alike), both present before any build has run — determined by configuration and discovery; a spec-group file without the `.mdx` extension (14.19 staged beside it) listed in `sources` while `module` and `markdown` are the stated structural-absence null; with emission disabled — the key absent, or `emit` false with `outDir` configured — `markdown` null for every source; every answer complete and finding-free at exit 0, the defaults workspace asserted in the flag-less and `--json` forms against one expectation (SPEC 11.6, 12.7, 7.3, 7.4, 7.5, 13.1, 12.0, 11)', + run: async (product) => { + // --- defaults workspace: every default and inferred kind explicit ------ + const defaults = await TestWorkspace.create({ + files: { + [CONFIG_FILE]: RESOLVED_DEFAULTS_CONFIG, + "specs/core/a.mdx": '<S id="alpha">\nNoyau.\n</S>\n', + "specs/aux/b.mdx": '<S id="beta">\nAnnexe.\n</S>\n', + "specs/shared/deux.mdx": '<S id="gamma">\nPartagé.\n</S>\n', + "src/app.ts": "export const rien = 0;\n", + }, + }); + try { + const expected: InventoryResolvedMap = { + configuration: { + // Groups in configuration order (`core` before `aux` — byte order + // would invert them), each with its complete glob list (11.6). + specs: [ + { + name: "core", + globs: ["specs/core/**/*.mdx", "specs/shared/**/*.mdx"], + }, + { + name: "aux", + globs: ["specs/aux/**/*.mdx", "specs/shared/**/*.mdx"], + }, + ], + code: [{ name: "impl", globs: ["src/**/*.ts"] }], + // `markdown` key absent → {"emit": false, "outDir": null} (7.3, + // 12.7). + markdown: { emit: false, outDir: null }, + coverage: [ + { + name: "socle", + target: "core", + // Every default and inferred kind explicit (11.6, 7.4): + targetTags: null, + targets: "leaves", + boundary: "impl", + boundaryKind: "code", + mode: "direct", + edgeKinds: ALL_EDGE_KINDS_SORTED, + }, + ], + policy: [ + { + name: "cloison", + type: "forbidden", + // Group selectors with the inferred kind explicit (7.5, 12.7). + from: { group: "aux", kind: "spec" }, + to: { group: "core", kind: "spec" }, + kinds: ALL_EDGE_KINDS_SORTED, + }, + ], + }, + // Every discovered source with its group memberships, in byte order + // of workspace-relative path; the two-group file carries both + // memberships in configuration order (7.1, 11.6). + sources: [ + { path: "specs/aux/b.mdx", groups: [{ name: "aux", kind: "spec" }] }, + { + path: "specs/core/a.mdx", + groups: [{ name: "core", kind: "spec" }], + }, + { + path: "specs/shared/deux.mdx", + groups: [ + { name: "core", kind: "spec" }, + { name: "aux", kind: "spec" }, + ], + }, + { path: "src/app.ts", groups: [{ name: "impl", kind: "code" }] }, + ], + // One entry per discovered spec source — the code source contributes + // none — module path per 13.1; `markdown` null for every source + // while emission is disabled by the absent key (7.3, 12.7). + derived: [ + { + source: "specs/aux/b.mdx", + module: "specs/aux/b.xspec.ts", + markdown: null, + }, + { + source: "specs/core/a.mdx", + module: "specs/core/a.xspec.ts", + markdown: null, + }, + { + source: "specs/shared/deux.mdx", + module: "specs/shared/deux.xspec.ts", + markdown: null, + }, + ], + }; + // Flag-less and `--json` forms against the same expectation: inventory + // is JSON-only, the two invocations carrying the same information + // (SPEC 11; byte-identity of the two stdouts is not asserted, SPEC.md + // not requiring it). + const flagless = await expectResolvedInventory( + product, + defaults.root, + ["inventory"], + "T11.6-2 — `inventory` (flag-less) on the defaults workspace: the " + + "resolved view with every default and inferred kind explicit " + + "(SPEC 11.6)", + ); + assertSameJson( + normalizeKindSets(flagless), + expected, + "T11.6-2 — the defaults workspace's configuration/sources/derived " + + "projection: `markdown` absent resolving to emit-false/outDir-" + + "null, the defaulted profile and rule fully explicit " + + '(targetTags null, targets "leaves", boundaryKind and selector ' + + "kinds inferred-but-explicit, edgeKinds/kinds all three), group " + + "references staying configured names, the two-group file " + + "carrying both memberships, and the derived map with `markdown` " + + "null for every source (SPEC 11.6, 7.3, 7.4, 7.5, 13.1, 12.7)", + ); + const withJson = await expectResolvedInventory( + product, + defaults.root, + ["inventory", "--json"], + "T11.6-2 — `inventory --json` on the defaults workspace: the same " + + "information as the flag-less form (JSON-only, SPEC 11, 11.6)", + ); + assertSameJson( + normalizeKindSets(withJson), + expected, + "T11.6-2 — the `--json` form carries the same configuration/" + + "sources/derived information as the flag-less form (SPEC 11, " + + "11.6)", + ); + } finally { + await defaults.dispose(); + } + + // --- emission enabled, default destinations; 14.19 staged beside ------ + const emit = await TestWorkspace.create({ + files: { + [CONFIG_FILE]: RESOLVED_EMIT_CONFIG, + "specs/a.mdx": '<S id="seule">\nÉmise.\n</S>\n', + // A spec-group file without the `.mdx` extension: discovered (the + // extension-free glob matches it), invalid (14.19, SPEC 7.1) — a + // finding of build/check, never of the inventory (11.6). + "specs/note.txt": "pas une source xspec\n", + }, + }); + try { + const map = await expectResolvedInventory( + product, + emit.root, + ["inventory"], + "T11.6-2 — `inventory` with emission enabled (default destinations) " + + "and a non-`.mdx` spec-group file staged beside the valid source " + + "(SPEC 11.6, 7.3)", + ); + assertSameJson( + normalizeKindSets(map), + { + configuration: { + specs: [{ name: "main", globs: ["specs/*"] }], + // Absent `code`/`coverage`/`policy` keys mean no code groups, + // no profiles, no rules: empty lists are [], never null (SPEC + // 7, 12.7). + code: [], + markdown: { emit: true, outDir: null }, + coverage: [], + policy: [], + }, + sources: [ + { + path: "specs/a.mdx", + groups: [{ name: "main", kind: "spec" }], + }, + // The non-`.mdx` file IS a discovered spec-group source: listed + // with its membership (11.6 "every discovered source file"). + { + path: "specs/note.txt", + groups: [{ name: "main", kind: "spec" }], + }, + ], + derived: [ + // Module path and Markdown destination both present before any + // build has run — determined by configuration and discovery + // (11.6, 13.1); the default placement emits next to the source + // (7.3, 13.2). + { + source: "specs/a.mdx", + module: "specs/a.xspec.ts", + markdown: "specs/a.md", + }, + // The spec-group file without `.mdx` generates and emits + // nothing (13.1): both structurally absent — the stated null, + // never omission (11.6, 12.7). + { source: "specs/note.txt", module: null, markdown: null }, + ], + } satisfies InventoryResolvedMap, + "T11.6-2 — emission enabled: per spec source the generated-module " + + "path and the next-to-source Markdown destination, both present " + + "before any build has run; the non-`.mdx` spec-group file listed " + + "in `sources` with `module` and `markdown` null (SPEC 11.6, 7.3, " + + "13.1, 12.7)", + ); + } finally { + await emit.dispose(); + } + + // --- emission redirected under markdown.outDir ------------------------- + const outDir = await TestWorkspace.create({ + files: { + [CONFIG_FILE]: RESOLVED_OUTDIR_CONFIG, + "specs/g.mdx": '<S id="haut">\nRacine.\n</S>\n', + "specs/sub/h.mdx": '<S id="bas">\nNichée.\n</S>\n', + }, + }); + try { + const map = await expectResolvedInventory( + product, + outDir.root, + ["inventory"], + "T11.6-2 — `inventory` with emission redirected under " + + "`markdown.outDir` (SPEC 7.3, 11.6)", + ); + assertSameJson( + normalizeKindSets(map), + { + configuration: { + specs: [{ name: "docs", globs: ["specs/**/*.mdx"] }], + code: [], + markdown: { emit: true, outDir: "mdout" }, + coverage: [], + policy: [], + }, + sources: [ + { path: "specs/g.mdx", groups: [{ name: "docs", kind: "spec" }] }, + { + path: "specs/sub/h.mdx", + groups: [{ name: "docs", kind: "spec" }], + }, + ], + derived: [ + // outDir redirects emitted files into the directory, + // preserving workspace-relative paths (7.3) — the nested + // source's destination keeps its whole relative path. + { + source: "specs/g.mdx", + module: "specs/g.xspec.ts", + markdown: "mdout/specs/g.md", + }, + { + source: "specs/sub/h.mdx", + module: "specs/sub/h.xspec.ts", + markdown: "mdout/specs/sub/h.md", + }, + ], + } satisfies InventoryResolvedMap, + "T11.6-2 — `markdown.outDir` echoes in the resolved view and every " + + "emit destination lies under it, preserving workspace-relative " + + "paths, before any build has run (SPEC 7.3, 11.6, 12.7)", + ); + } finally { + await outDir.dispose(); + } + + // --- emission disabled explicitly (emit false, outDir configured) ------ + const disabled = await TestWorkspace.create({ + files: { + [CONFIG_FILE]: RESOLVED_DISABLED_CONFIG, + "specs/seul.mdx": '<S id="seul">\nInerte.\n</S>\n', + }, + }); + try { + const map = await expectResolvedInventory( + product, + disabled.root, + ["inventory"], + "T11.6-2 — `inventory` with emission disabled explicitly (`emit` " + + "false, `outDir` configured) (SPEC 7.3, 11.6)", + ); + assertSameJson( + normalizeKindSets(map), + { + configuration: { + specs: [{ name: "main", globs: ["specs/**/*.mdx"] }], + code: [], + // The complete definition is reported — `emit` false AND the + // configured `outDir` — while no path is a Markdown emit + // destination (7.3). + markdown: { emit: false, outDir: "docsout" }, + coverage: [], + policy: [], + }, + sources: [ + { + path: "specs/seul.mdx", + groups: [{ name: "main", kind: "spec" }], + }, + ], + derived: [ + // With emission disabled, `markdown` is null for every source + // whatever `outDir` says (7.3, 12.7); the module path stays — + // generation does not depend on emission (13.1). + { + source: "specs/seul.mdx", + module: "specs/seul.xspec.ts", + markdown: null, + }, + ], + } satisfies InventoryResolvedMap, + "T11.6-2 — emission disabled explicitly: the view reports " + + "emit-false with the configured outDir, and `markdown` is null " + + "for every source — destinations exist exactly while emission is " + + "enabled (SPEC 7.3, 11.6, 12.7)", + ); + } finally { + await disabled.dispose(); + } + }, +}); + +export const section116Tests: readonly ProductTestEntry[] = [T11_6_1, T11_6_2]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index d42ec5d..aeadffb 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -318,6 +318,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.5-2": ["11.5"], "T11.5-3": ["11.5"], "T11.6-1": ["11.6"], + "T11.6-2": ["11.6"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From e7877026dcf9b781529c3ea6d80499459b32c890 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 22:17:52 +0000 Subject: [PATCH 116/226] sdg(phase-9): implement T11.6-3's record, area, durables, and orders (FP-071) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 56 ++- test/helpers/adapters/forms.ts | 200 +++++++- test/helpers/adapters/model.ts | 11 + test/self/s5-output-adapters.test.ts | 133 ++++++ test/suite/registry/section-11.6.ts | 678 ++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 5 + 6 files changed, 1057 insertions(+), 26 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index c607583..67222f8 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3102,8 +3102,62 @@ certify against FP-091's fixtures once those land. certification green 17/17 (violators failing as certified). FP-071/072 build T11.6-3/-4 on this module and may widen the scoped decode to the full top-level form.] -- [ ] FP-071 — Implement T11.6-3: record, area, durables, orders. [R2 #6; +- [x] FP-071 — Implement T11.6-3: record, area, durables, orders. [R2 #6; TEST-SPEC §11.6] + [Done 2026-08-14: T11.6-3 registered in section-11.6.ts (SUITE-56), + decoding through the new full-form `decodeInventoryDocument` + (forms.ts/model.ts — FP-070's stated widening: exactly the ten 12.7 + top-level members via expectOnlyMembers, composed from the existing + scoped decoders plus the `graphData` path value, `journal` + `{"path","occupied"}`, and `sessions` decode with byte-order-of-file-name + enforcement; `decodeInventoryRecordedDatum` additionally now enforces the + recorded list's byte order and uniqueness, strengthening its existing + T12.2-2/T13.3-2 callers). Two workspaces. Record/area/journal workspace + (two spec groups `zz` before `aa` — configuration order inverts byte + order — emission on): pre-build arm flag-less AND `--json` against one + expectation — recorded exactly {state value, []} (never null/unavailable), + graphData exactly ".xspec", journal exactly {".xspec/journal", false}, + sessions [], groups ["zz","aa"], derived-with-markdown as the lag + baseline; post-build arm — both modules and both emitted .md pinned + present in recorded, every further entry attributable through the 13.1 + naming scheme (`<dir>/<NAME>.xspec.<suffix>` beside a discovered + `<dir>/<NAME>.mdx` — companions asserted by attributability, not + enumeration, since 13.1 pins no companion set), byte order + decoder-enforced; foreign file `.xspec/zzz-artefact-etranger.bin` staged + after the build — exact sources/sessions compares plus the attribution + rule exclude it from every list and a document-wide byte scan asserts it + is never claimed; lag arm — config rewritten to emit:false without + rebuild: view/derived report the new configuration (markdown null) while + recorded still carries the .md paths (as recorded, not as configured); + journal occupancy arms — garbage plain file, directory, broken symlink + each {occupied:true} with the finding-free exit-0 frame (no 14.13 from + inventory; the broken link discriminates stat-through-the-link + products). Sessions workspace: product-written session (`review create + --strategy audit --name ancien`, plain-file premise pinned), garbage + S.json, directory S2.json → sessions exactly [S.json, S2.json, + ancien.json] in byte order of file name (0x53 'S' < 0x61 'a' — case + folding inverts it), findings [] (no 14.21 here), notes.txt/.foo.json + never listed and absent from the document bytes. Traceability + "T11.6-3": ["11.6"] (13.3/13.1/6.1/10.1/12.7 context, home coverage + elsewhere; findings-[] arms assert no numbered condition — no "14", the + T11.6-2 precedent). In CERTIFICATIONS.md's Exclusions, no fixture scope. + Verified: typecheck/format clean; S-5 gains the full-document guards + (good + recorded-unavailable/empty-sessions positives; ten bad shapes + incl. extra top-level member, journal form breaks, session order) and + two recorded-order rejections — all green; suite red-as-diagnosed at the + first arm's exit-0 assert (stub: unknown command 'inventory', exit 2); + soundness proven by a scratch mock implementing the 11.6 projection + independently (config eval + own glob matcher + own derived/record/ + session arithmetic; body green — the two implementations agree); teeth + probed via eleven mock deviations (trailing-separator area, stat-journal + broken-link occupancy, journal-content-read 14.13, recorded-as-configured, + record-without-markdown, claim-foreign, sessions-any-name, + sessions-casefold order, sessions-files-only, recorded-unavailable, + groups-byte-order), each failing at exactly the intended assertion. + `npm run test:self`: unchanged 3 planned mid-loop reds + (certification-document ×2 → FP-091; S-1 unmapped {12.6, 12.7} → stage + G); certification green (violators failing as certified). FP-072 builds + T11.6-4 on this module and decoder.] - [ ] FP-072 — Implement T11.6-4: no-parse/no-write/one-finding (condition-23, `recorded` unavailable). [R2 #6; TEST-SPEC §11.6, SPEC 14.23] diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts index 8188772..541160b 100644 --- a/test/helpers/adapters/forms.ts +++ b/test/helpers/adapters/forms.ts @@ -24,8 +24,9 @@ // {"unavailable": true} (11.4, 12.7) // - the scoped inventory decodes: the `recorded` datum, the `findings` // member, the `root`/`config` anchoring, and the resolved -// configuration/sources/derived map (11.6; the full inventory form is -// T11.6-*'s subject) +// configuration/sources/derived map (11.6), plus the full ten-member +// inventory document decode composing them with the `graphData`, +// `journal`, and `sessions` forms (T11.6-3) // - the occurrence-record form {"file","range","kind","source","target"} // and the occurrences document {"findings","occurrences"} (5.7, 11.3) // - the at document {"findings","resolution"} (11.5) @@ -56,6 +57,7 @@ import type { InventoryCoverageProfileView, InventoryDerivedEntry, InventoryGroupDef, + InventoryJournalStatus, InventoryPolicyRuleView, InventoryPolicySelector, InventoryResolvedMap, @@ -547,17 +549,20 @@ export function decodeDatum<T>( /** * Scoped decode of the inventory document's `recorded` member (SPEC 11.6, * 12.7): the record-supplied datum — the recorded derived-file paths, each a - * 12.7 path value — as a three-state datum: a plain list, `null`, or the - * explicit-unavailability marker (14.23). Which states are legitimate for - * this member is the caller's value assertion (a conforming inventory - * reports the plain list or unavailability, never `null`, 11.6/12.7). - * Deliberately scoped: SPEC 12.7 fixes the whole inventory form and the - * T11.6-* tests pin it entirely; this decoder reads exactly the one pinned - * member the record-recovery contract needs (T12.2-2's unreadable-record - * arm: after a successful `build` replaces the corrupt state, `inventory` - * reports `recorded` again) — the top level must be an object and the member - * present (`null` is never omission, 12.7) while every other member stays - * unread. Form-exact (H-3): never adjustable to a product's shape. + * 12.7 path value, the list in byte order of workspace-relative path with no + * duplicate (11.6/12.7 pin the order; decoder-enforced, exactly as the + * `sources`/`derived` orders are) — as a three-state datum: a plain list, + * `null`, or the explicit-unavailability marker (14.23). Which states are + * legitimate for this member is the caller's value assertion (a conforming + * inventory reports the plain list or unavailability, never `null`, + * 11.6/12.7). Deliberately scoped: SPEC 12.7 fixes the whole inventory form + * and the T11.6-* tests pin it entirely; this decoder reads exactly the one + * pinned member the record-recovery contract needs (T12.2-2's + * unreadable-record arm: after a successful `build` replaces the corrupt + * state, `inventory` reports `recorded` again) — the top level must be an + * object and the member present (`null` is never omission, 12.7) while every + * other member stays unread. Form-exact (H-3): never adjustable to a + * product's shape. */ export function decodeInventoryRecordedDatum( doc: unknown, @@ -566,11 +571,34 @@ export function decodeInventoryRecordedDatum( const site = rootSite("11.6 inventory (recorded datum)", context); const obj = expectObject(doc, site); const recordedSite = at(site, "recorded"); - return decodeDatum(obj["recorded"], recordedSite, (value, valueSite) => - expectArray(value, valueSite).map((element, index) => + return decodeDatum(obj["recorded"], recordedSite, (value, valueSite) => { + const paths = expectArray(value, valueSite).map((element, index) => decodePathValue(element, at(valueSite, index)), - ), - ); + ); + for (let i = 1; i < paths.length; i += 1) { + const order = Buffer.compare( + pathValueBytes(paths[i - 1]!), + pathValueBytes(paths[i]!), + ); + if (order === 0) { + formFail( + at(valueSite, i), + "recorded derived-file paths without duplicates (SPEC 11.6: a " + + "deterministically ordered path list)", + value, + ); + } + if (order > 0) { + formFail( + at(valueSite, i), + "recorded derived-file paths in byte order of workspace-relative " + + "path (SPEC 11.6, 12.7)", + value, + ); + } + } + return paths; + }); } /** @@ -1007,6 +1035,144 @@ export function decodeInventoryResolvedMap( return { configuration, sources, derived }; } +// --- the full inventory document (11.6, 12.7) --------------------------------- + +/** + * The complete decoded inventory document (SPEC 11.6, 12.7 — the surface the + * T11.6-* tests pin together): every member of the pinned form + * `{"findings", "root", "config", "configuration", "sources", "derived", + * "recorded", "graphData", "journal", "sessions"}`. + */ +export interface InventoryDocument { + readonly findings: readonly Finding[]; + readonly root: PathValue; + readonly config: PathValue; + readonly configuration: InventoryConfigurationView; + readonly sources: readonly InventorySourceEntry[]; + readonly derived: readonly InventoryDerivedEntry[]; + readonly recorded: DecodedDatum<readonly PathValue[]>; + readonly graphData: PathValue; + readonly journal: InventoryJournalStatus; + readonly sessions: readonly PathValue[]; +} + +/** The ten members of the inventory document form, exactly (SPEC 12.7). */ +const INVENTORY_DOCUMENT_MEMBERS = [ + "findings", + "root", + "config", + "configuration", + "sources", + "derived", + "recorded", + "graphData", + "journal", + "sessions", +] as const; + +/** The file-name bytes of a 12.7 path value (its bytes after the last `/`). */ +function pathValueFileNameBytes(value: PathValue): Buffer { + const bytes = pathValueBytes(value); + const lastSep = bytes.lastIndexOf(0x2f); + return lastSep === -1 ? bytes : bytes.subarray(lastSep + 1); +} + +/** + * The `journal` member form: `{"path", "occupied"}` exactly — the journal + * path as a 12.7 path value and occupancy as a boolean (SPEC 11.6, 12.7). + */ +function decodeInventoryJournalStatus( + value: unknown, + site: DecodeSite, +): InventoryJournalStatus { + const obj = expectObject(value, site); + expectOnlyMembers(obj, ["path", "occupied"], site); + return { + path: decodePathValue(requiredKey(obj, "path", site), at(site, "path")), + occupied: expectBoolean( + requiredKey(obj, "occupied", site), + at(site, "occupied"), + ), + }; +} + +/** + * Full decode of the inventory document (SPEC 11.6, 12.7; the T11.6-3 entry + * completes the member set the T11.6-* tests pin): the top level carries + * exactly the ten members of the pinned form — `null` never omission, no + * member outside the form — decoded through the scoped decoders above (one + * code path per member form) plus the `recorded`, `graphData`, `journal`, + * and `sessions` members: `recorded` the three-state record-supplied datum + * (byte-ordered paths, or the unavailability marker, 14.23); `graphData` a + * path value (the `.xspec` spelling is the caller's byte-exact value + * assertion); `journal` `{"path", "occupied"}`; `sessions` the session file + * paths in byte order of file name with no duplicate (11.6 pins that order; + * decoder-enforced). Form-exact (H-3): never adjustable to a product's + * shape. + */ +export function decodeInventoryDocument( + doc: unknown, + context?: string, +): InventoryDocument { + const site = rootSite("11.6 inventory (document)", context); + const obj = expectObject(doc, site); + expectOnlyMembers(obj, INVENTORY_DOCUMENT_MEMBERS, site); + + const anchoring = decodeInventoryAnchoring(doc, context); + const map = decodeInventoryResolvedMap(doc, context); + const findings = decodeInventoryFindings(doc, context); + const recorded = decodeInventoryRecordedDatum(doc, context); + + const graphData = decodePathValue( + requiredKey(obj, "graphData", site), + at(site, "graphData"), + ); + const journal = decodeInventoryJournalStatus( + requiredKey(obj, "journal", site), + at(site, "journal"), + ); + + const sessionsSite = at(site, "sessions"); + const sessions = expectArray( + requiredKey(obj, "sessions", site), + sessionsSite, + ).map((element, index) => decodePathValue(element, at(sessionsSite, index))); + for (let i = 1; i < sessions.length; i += 1) { + const order = Buffer.compare( + pathValueFileNameBytes(sessions[i - 1]!), + pathValueFileNameBytes(sessions[i]!), + ); + if (order === 0) { + formFail( + at(sessionsSite, i), + "one entry per session file — directory entries are unique, so no " + + "two session file names coincide (SPEC 11.6, 10.1)", + obj["sessions"], + ); + } + if (order > 0) { + formFail( + at(sessionsSite, i), + "session files in byte order of file name (SPEC 11.6)", + obj["sessions"], + ); + } + } + + return { + findings, + root: anchoring.root, + config: anchoring.config, + configuration: map.configuration, + sources: map.sources, + derived: map.derived, + recorded, + graphData, + journal, + sessions, + }; +} + // --- the occurrences document (5.7, 11.3, 12.7) ------------------------------- const OCCURRENCE_RECORD_MEMBERS = [ diff --git a/test/helpers/adapters/model.ts b/test/helpers/adapters/model.ts index 608fb2a..dae5093 100644 --- a/test/helpers/adapters/model.ts +++ b/test/helpers/adapters/model.ts @@ -610,6 +610,17 @@ export interface InventoryResolvedMap { readonly derived: readonly InventoryDerivedEntry[]; } +/** + * The inventory document's `journal` member — `{"path", "occupied"}` exactly + * (SPEC.md 11.6, 12.7): the journal path (6.1) and whether anything presently + * occupies it. Occupancy is presence alone, whatever kind of filesystem + * object occupies the path — the inventory reads no journal content. + */ +export interface InventoryJournalStatus { + readonly path: PathValue; + readonly occupied: boolean; +} + /** `coverage` (T8.2-1): all profiles by default, one when named. */ export interface CoverageReport { readonly profiles: readonly CoverageProfileReport[]; diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index ad2c0c4..0cb6ef3 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -46,6 +46,7 @@ import { decodeIdsTreeReport, decodeImpactReport, decodeInventoryAnchoring, + decodeInventoryDocument, decodeInventoryFindings, decodeInventoryRecordedDatum, decodeInventoryResolvedMap, @@ -754,6 +755,17 @@ const GOOD_RESOLVED_INVENTORY = { graphData: ".xspec", }; +// The full ten-member inventory document (SPEC 12.7; T11.6-3's frame): the +// resolved-map control plus a non-empty byte-ordered record, the journal +// status, and session file paths in byte order of file name ("S.json" +// before "ancien.json": 0x53 < 0x61 — inverted by case folding). +const GOOD_INVENTORY_DOCUMENT = { + ...GOOD_RESOLVED_INVENTORY, + recorded: ["specs/core/a.md", "specs/core/a.xspec.ts"], + journal: { path: ".xspec/journal", occupied: false }, + sessions: [".xspec/reviews/S.json", ".xspec/reviews/ancien.json"], +}; + // --- decoder table ----------------------------------------------------------- interface BadCase { @@ -2711,6 +2723,18 @@ const DECODERS: readonly DecoderSpec[] = [ label: "a valid-UTF-8 path in the byte form (SPEC 12.7 forbids it)", doc: { recorded: [{ bytes: "612e6d64" }] }, }, + { + label: + "recorded paths out of byte order (SPEC 11.6, 12.7: the recorded " + + "derived-file paths in byte order)", + doc: { recorded: ["specs/A.xspec.ts", "specs/A.md"] }, + }, + { + label: + "a duplicate recorded path (SPEC 11.6: a deterministically " + + "ordered path list)", + doc: { recorded: ["specs/A.md", "specs/A.md"] }, + }, ], }, { @@ -3100,6 +3124,115 @@ const DECODERS: readonly DecoderSpec[] = [ }, ], }, + { + // The full inventory document decode (SPEC 11.6, 12.7; T11.6-3's + // frame): the top level carries exactly the ten pinned members, decoded + // through the scoped decoders plus the recorded/graphData/journal/ + // sessions forms — `recorded` a three-state datum in byte order, + // `journal` {"path","occupied"} exactly, `sessions` in byte order of + // file name. + name: "11.6 inventory (document)", + decode: decodeInventoryDocument, + good: GOOD_INVENTORY_DOCUMENT, + verify: (decoded: ReturnType<typeof decodeInventoryDocument>) => { + expect(decoded.root).toBe("."); + expect(decoded.config).toBe("xspec.config.ts"); + expect(decoded.configuration.specs.map((g) => g.name)).toEqual([ + "core", + "aux", + ]); + expect(decoded.findings).toEqual([]); + expect(decoded.recorded).toEqual({ + state: "value", + value: ["specs/core/a.md", "specs/core/a.xspec.ts"], + }); + expect(decoded.graphData).toBe(".xspec"); + expect(decoded.journal).toEqual({ + path: ".xspec/journal", + occupied: false, + }); + expect(decoded.sessions).toEqual([ + ".xspec/reviews/S.json", + ".xspec/reviews/ancien.json", + ]); + }, + alsoGood: [ + { + label: + "recorded unavailable (14.23) beside an occupied journal decodes " + + "as stated — never as an empty record or a defaulted occupancy", + doc: put( + put(GOOD_INVENTORY_DOCUMENT, { unavailable: true }, "recorded"), + true, + "journal", + "occupied", + ), + verify: (decoded: ReturnType<typeof decodeInventoryDocument>): void => { + expect(decoded.recorded).toEqual({ state: "unavailable" }); + expect(decoded.journal.occupied).toBe(true); + }, + }, + { + label: "no sessions is the empty array (SPEC 12.7)", + doc: put(GOOD_INVENTORY_DOCUMENT, [], "sessions"), + verify: (decoded: ReturnType<typeof decodeInventoryDocument>): void => { + expect(decoded.sessions).toEqual([]); + }, + }, + ], + bad: [ + { + label: + "an extra top-level member (the form carries exactly the ten " + + "pinned members, SPEC 12.7)", + doc: put(GOOD_INVENTORY_DOCUMENT, ".xspec", "area"), + }, + { + label: "absent graphData member (null is never omission, SPEC 12.7)", + doc: omit(GOOD_INVENTORY_DOCUMENT, "graphData"), + }, + { + label: "absent journal member", + doc: omit(GOOD_INVENTORY_DOCUMENT, "journal"), + }, + { + label: + 'journal without occupied (the member form is {"path", ' + + '"occupied"} exactly, SPEC 12.7)', + doc: omit(GOOD_INVENTORY_DOCUMENT, "journal", "occupied"), + }, + { + label: "journal with an extra member", + doc: put(GOOD_INVENTORY_DOCUMENT, 3, "journal", "lines"), + }, + { + label: "a stringly-typed occupied", + doc: put(GOOD_INVENTORY_DOCUMENT, "false", "journal", "occupied"), + }, + { + label: "absent sessions member", + doc: omit(GOOD_INVENTORY_DOCUMENT, "sessions"), + }, + { + label: "null sessions (an empty list is [], never null, SPEC 12.7)", + doc: put(GOOD_INVENTORY_DOCUMENT, null, "sessions"), + }, + { + label: + "sessions out of byte order of file name (the case-folded order, " + + "SPEC 11.6)", + doc: put( + GOOD_INVENTORY_DOCUMENT, + [".xspec/reviews/ancien.json", ".xspec/reviews/S.json"], + "sessions", + ), + }, + { + label: "absent recorded member", + doc: omit(GOOD_INVENTORY_DOCUMENT, "recorded"), + }, + ], + }, { name: "coverage", decode: decodeCoverageReport, diff --git a/test/suite/registry/section-11.6.ts b/test/suite/registry/section-11.6.ts index 695865b..e1fe4d1 100644 --- a/test/suite/registry/section-11.6.ts +++ b/test/suite/registry/section-11.6.ts @@ -1,5 +1,5 @@ -// TEST-SPEC §11.6 (`xspec inventory`) — SUITE-56: T11.6-1, T11.6-2 -// (T11.6-3..T11.6-4 register here as they are implemented). +// TEST-SPEC §11.6 (`xspec inventory`) — SUITE-56: T11.6-1, T11.6-2, T11.6-3 +// (T11.6-4 registers here as it is implemented). // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -10,8 +10,9 @@ // runs bare (per-test arms additionally with `--json`, asserting the two // forms carry the same information, SPEC 11) and its stdout decodes through // the scoped form-exact decoders `decodeInventoryAnchoring`, -// `decodeInventoryFindings`, and `decodeInventoryResolvedMap` (the full -// inventory form is pinned across the T11.6-* tests as they land). +// `decodeInventoryFindings`, and `decodeInventoryResolvedMap` (T11.6-1/-2) +// and the full ten-member `decodeInventoryDocument` (T11.6-3 — its entry +// completes the member set, so its arms pin the whole document form). // // T11.6-1 — anchoring (SPEC 11.6, 12.0). The workspace root and the // configuration file are identified relative to the invocation working @@ -87,22 +88,66 @@ // and the exit code is 0 (SPEC 12.0, 11.6) — T11.6-1's workspaces being // valid, and T11.6-2's 14.19 staging never being the inventory's finding. // +// T11.6-3 — record, area, durables, order (SPEC 11.6, 13.3, 13.1, 6.1, +// 10.1, 12.7). Two workspaces: +// +// - record/area/journal workspace (emission enabled; two spec groups `zz` +// before `aa` so configuration order has teeth): before any build, +// `recorded` is [] (empty before any generation — never null, never +// unavailable), `graphData` is exactly ".xspec" (reported unconditionally, +// no trailing separator), `journal` is {".xspec/journal", occupied: false} +// (an absent journal is an empty journal, 6.1), `sessions` [] — flag-less +// and `--json` forms against the same expectation (SPEC 11). After a +// `build`: `recorded` lists the recorded derived paths — both generated +// modules and both emitted Markdown files pinned present, and every +// further entry attributable to a discovered source through the 13.1 +// naming scheme (`<dir>/<NAME>.xspec.<suffix>` beside `<dir>/<NAME>.mdx`) +// — in byte order (decoder-enforced). After a configuration change +// without rebuild (emission flipped off): the resolved view and derived +// map report the new configuration (`markdown` null per source) while +// `recorded` still lists the previously generated Markdown — the record +// lags, reported as recorded, not as configured (11.6, 13.3). A foreign +// file placed under `.xspec/` (neither journal, session-named, nor +// recorded) appears in no inventory list and is never claimed: the exact +// sources/sessions compares and the recorded attribution rule exclude it, +// and its name appears nowhere in the document bytes. Journal occupancy is +// presence alone: a garbage-content plain file, a directory, and a broken +// symbolic link each report occupied true with a finding-free answer (no +// content read, no 14.13 from inventory; the broken link discriminates a +// product probing occupancy through the link). +// +// - sessions workspace: a product-written session (`review create +// --strategy audit --name ancien`), a garbage-content `S.json`, and a +// directory named `S2.json` are all listed (selection by name alone, +// content unread — no 14.21 here), while `notes.txt` and `.foo.json` are +// never listed (no session file name, 10.1) and never claimed (their +// names appear nowhere in the document bytes). Order: byte order of file +// name — "S.json" < "S2.json" < "ancien.json" (0x53 'S' sorts before +// 0x61 'a'), inverting under case folding, so the byte-order contract has +// teeth. +// // Certification note: CERTIFICATIONS.md's Exclusions list T11.6-1 through // T11.6-4 ("`inventory` and `version`"), so no fixture executes these -// bodies; the anchoring, resolved-configuration, and derived-map arms are -// positive and byte-asserted per that entry. +// bodies; the anchoring, resolved-configuration, derived-map, occupancy, +// and listing arms are positive and byte-asserted per that entry. +import { Buffer } from "node:buffer"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; import type { + DecodedDatum, DependencyEdgeKind, GroupKind, InventoryConfigurationView, + InventoryDocument, + InventoryJournalStatus, InventoryResolvedMap, PathValue, } from "../../helpers/adapters/index.js"; import { + GRAPH_DATA_AREA_PATH, decodeInventoryAnchoring, + decodeInventoryDocument, decodeInventoryFindings, decodeInventoryResolvedMap, renderPathValue, @@ -118,7 +163,7 @@ import type { ProductTestEntry } from "../../helpers/registry.js"; import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; import { runProduct } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import { assertSameJson } from "./support.js"; +import { assertSameJson, buildOk, expectExit } from "./support.js"; // --- fixture ------------------------------------------------------------------ // @@ -1013,4 +1058,621 @@ const T11_6_2 = defineProductTest({ }, }); -export const section116Tests: readonly ProductTestEntry[] = [T11_6_1, T11_6_2]; +// --- T11.6-3 ------------------------------------------------------------------ +// +// Fixtures. The record/area/journal workspace declares two spec groups in an +// order (`zz` before `aa`) that inverts name byte order, so the +// configuration-order clause of 11.6's ordering contract has teeth here too; +// emission is enabled so the post-build record carries modules AND Markdown; +// the lag arm rewrites the configuration to the emission-off twin (still +// valid — a configuration error would preempt the inventory, 14.14) without +// rebuilding. + +const DURABLES_EMIT_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + zz: ["specs/z*.mdx"], + aa: ["specs/a*.mdx"] + }, + markdown: { emit: true } +}) +`; + +const DURABLES_NOEMIT_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + zz: ["specs/z*.mdx"], + aa: ["specs/a*.mdx"] + }, + markdown: { emit: false } +}) +`; + +/** + * The foreign occupant's distinctive name component: chosen to appear in no + * legitimate inventory content of these workspaces, so "appears in no + * inventory list and is never claimed" (SPEC 11.6) is assertable as + * document-wide byte absence on top of the exact list compares. + */ +const FOREIGN_TOKEN = "zzz-artefact-etranger"; + +const SESSIONS_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`; + +/** The journal's workspace-relative path (SPEC 6.1). */ +const JOURNAL_PATH = `${GRAPH_DATA_AREA_PATH}/journal`; + +/** + * Run `inventory` and assert the T11.6-3 frame: exit 0 exactly (every + * staging here is a complete, finding-free answer — the inventory parses no + * sources, reads no journal or session content, and no arm corrupts the + * record; SPEC 11.6, 12.0; H-5); exactly one JSON document as the entire + * stdout (JSON-only, SPEC 11); the full ten-member 12.7 inventory document + * form (H-3); `findings` [] — which IS the no-14.13/no-14.21 observation on + * the occupancy and session stagings. Returns the decoded document and the + * raw run for the callers' value assertions and byte scans. + */ +async function expectInventoryDocument( + product: ProductBinding, + cwd: string, + argv: readonly string[], + context: string, +): Promise<{ document: InventoryDocument; result: RunResult }> { + const result = await runProduct(product, { cwd, argv }); + assertExitCode( + result, + 0, + `${context} — a complete, finding-free inventory answer exits 0: the ` + + `inventory parses no sources and reads no journal or session content, ` + + `and the findings a listed file or path may bear are reported where ` + + `their conditions assign them, never here (SPEC 11.6, 12.0)`, + ); + const document = decodeInventoryDocument( + parseJsonStdout( + result, + `${context} — inventory is JSON-only: a single JSON document is its ` + + `only output form, with or without --json (SPEC 11, 12.0)`, + ), + context, + ); + if (document.findings.length !== 0) { + fail( + `${context}: the inventory answer is finding-free — findings [] ` + + `(SPEC 11.6, 12.7: parsing no sources and reading no journal or ` + + `session content, the inventory meets no condition on these ` + + `stagings — no 14.13, no 14.21 — and no arm corrupts the record); ` + + `got ${String(document.findings.length)} finding(s), first: ` + + `${JSON.stringify(document.findings[0]?.message)}`, + ); + } + return { document, result }; +} + +/** The graph-data area: exactly `.xspec`, no trailing separator (11.6). */ +function assertGraphDataArea(actual: PathValue, context: string): void { + if (actual === GRAPH_DATA_AREA_PATH) return; + fail( + `${context}: the graph-data area is reported unconditionally as its ` + + `workspace-relative path ${JSON.stringify(GRAPH_DATA_AREA_PATH)} with ` + + `no trailing separator (SPEC 11.6, 13.3); got ` + + `${renderPathValue(actual)}`, + ); +} + +/** The journal member: `{".xspec/journal", occupied}` byte-exact (11.6). */ +function assertJournalStatus( + actual: InventoryJournalStatus, + occupied: boolean, + context: string, +): void { + if (actual.path !== JOURNAL_PATH) { + fail( + `${context}: the inventory reports the journal path — xspec maintains ` + + `the journal at ${JSON.stringify(JOURNAL_PATH)} (SPEC 6.1, 11.6); ` + + `got ${renderPathValue(actual.path)}`, + ); + } + if (actual.occupied !== occupied) { + fail( + `${context}: journal occupancy must be ${String(occupied)} — ` + + `occupancy is presence alone, whatever kind of filesystem object ` + + `occupies the path, and an absent journal is an empty journal ` + + `(SPEC 11.6, 6.1); got ${String(actual.occupied)}`, + ); + } +} + +/** + * The document's bytes must not contain the token anywhere: a path the + * inventory neither lists nor claims (a foreign occupant under `.xspec/`, a + * non-session entry under the review-session directory) appears nowhere in + * the answer (SPEC 11.6, 10.1). + */ +function assertStdoutOmits( + result: RunResult, + token: string, + context: string, +): void { + if (!Buffer.from(result.stdoutBytes).includes(Buffer.from(token, "utf8"))) { + return; + } + fail( + `${context}: the inventory document mentions ${JSON.stringify(token)} — ` + + `an unattributed path under the graph-data area (or a non-session ` + + `entry under the review-session directory) appears in no inventory ` + + `list and is never claimed (SPEC 11.6, 10.1)`, + ); +} + +interface RecordedExpectation { + /** + * Paths that MUST be recorded: the generated module and (while emission + * was enabled at the recording build) the emitted Markdown per source — + * the paths as last generated (SPEC 13.3, 13.1, 13.2). + */ + readonly pinned: readonly string[]; + /** + * The discovered spec sources (`<dir>/<NAME>.mdx`) every further recorded + * entry must attribute to through the 13.1 naming scheme. + */ + readonly specSources: readonly string[]; +} + +/** + * Assert the record-supplied datum after a generation has run: the plain + * list state (never `null`, never unavailable — 14.23 is T11.6-4's staging), + * every pinned module/Markdown path present, and every further entry a + * companion attributable to its source through the 13.1 naming scheme — + * `<dir>/<NAME>.xspec.<suffix>` beside a discovered `<dir>/<NAME>.mdx`, the + * suffix non-empty. 13.1 pins no companion set (a product generates + * whatever companions its modules need), so companions are asserted by + * attributability, not enumeration; a path attributable to no source — a + * graph-data path, the foreign occupant, any invention — fails. Byte order + * and uniqueness are decoder-enforced (SPEC 11.6, 12.7). + */ +function assertRecordedDerivedPaths( + recorded: DecodedDatum<readonly PathValue[]>, + expectation: RecordedExpectation, + context: string, +): void { + if (recorded.state !== "value") { + fail( + `${context}: the recorded derived-file paths must be the plain list — ` + + `the record exists and is readable on this staging, so the datum is ` + + `never null and never the unavailability marker (14.23 is the ` + + `corrupt-record case, T11.6-4) (SPEC 11.6, 12.7); got state ` + + `"${recorded.state}"`, + ); + } + const entries: string[] = recorded.value.map((entry, index) => { + if (typeof entry === "string") return entry; + fail( + `${context}: recorded entry ${String(index)} arrived in the marked ` + + `byte form (${renderPathValue(entry)}) — every derived path of this ` + + `staging is valid UTF-8, and a valid-UTF-8 path is never presented ` + + `in the byte form (SPEC 12.0, 12.7)`, + ); + }); + for (const pinnedPath of expectation.pinned) { + if (!entries.includes(pinnedPath)) { + fail( + `${context}: the record must list ${JSON.stringify(pinnedPath)} — ` + + `the recorded derived-file paths are the paths as last generated: ` + + `the generated modules with their companions and the emitted ` + + `Markdown (SPEC 13.3, 13.1, 13.2, 11.6); recorded: ` + + `${JSON.stringify(entries)}`, + ); + } + } + const stems = expectation.specSources.map((source) => { + if (!source.endsWith(".mdx")) { + fail( + `${context}: fixture self-check — spec source ` + + `${JSON.stringify(source)} does not end in ".mdx" (a harness ` + + `staging defect, not a product failure)`, + ); + } + return source.slice(0, -".mdx".length); + }); + for (const entry of entries) { + if (expectation.pinned.includes(entry)) continue; + const attributable = stems.some( + (stem) => + entry.startsWith(`${stem}.xspec.`) && + entry.length > `${stem}.xspec.`.length, + ); + if (!attributable) { + fail( + `${context}: recorded entry ${JSON.stringify(entry)} is neither a ` + + `pinned module/Markdown path nor a companion attributable to a ` + + `discovered source through the 13.1 naming scheme ` + + `("<dir>/<NAME>.xspec." plus a suffix, beside "<dir>/<NAME>.mdx") ` + + `— the record lists generated modules, their companions, and ` + + `emitted Markdown, and nothing else: graph data records no paths ` + + `of its own, and an unattributed path is never claimed (SPEC ` + + `13.3, 13.1, 11.6)`, + ); + } + } +} + +const T11_6_3 = defineProductTest({ + id: "T11.6-3", + title: + 'inventory record, area, durables, order: `recorded` is [] before any generation (never null, never unavailable) and after a build lists the recorded derived paths in byte order — generated modules and emitted Markdown pinned present, every further entry a companion attributable to its source through the 13.1 naming scheme — and after a configuration change without rebuild it lags, reported as recorded, not as configured (emission flipped off: `derived[*].markdown` null while the previously emitted `.md` paths stay recorded); the graph-data area is reported unconditionally — before any build — as ".xspec" with no trailing separator; a foreign file placed under `.xspec/` appears in no inventory list and is never claimed (its name absent from the document bytes); `journal` is {".xspec/journal", occupied} with occupancy by presence alone — absent false; a garbage-content plain file, a directory, and a broken symbolic link each true, content unread, no 14.13 from inventory, the answer finding-free; sessions are selected by name alone — a product-written session, a garbage-content S.json, and a directory named S2.json all listed (content unread, no 14.21 here) in byte order of file name ("S.json" < "S2.json" < "ancien.json", inverting under case folding), while notes.txt and .foo.json are never listed; groups stay in configuration order (`zz` before `aa` against name byte order); every answer complete and finding-free at exit 0, the pre-build state asserted in the flag-less and `--json` forms against one expectation (SPEC 11.6, 13.3, 13.1, 13.2, 6.1, 10.1, 12.7, 12.0, 11)', + run: async (product) => { + // --- record / area / journal workspace --------------------------------- + const workspace = await TestWorkspace.create({ + files: { + [CONFIG_FILE]: DURABLES_EMIT_CONFIG, + "specs/apex.mdx": '<S id="apex">\nSommet.\n</S>\n', + "specs/zele.mdx": '<S id="zele">\nArdeur.\n</S>\n', + }, + }); + try { + // Before any build: the record is empty, the area is already reported, + // the absent journal is unoccupied, no sessions exist — flag-less and + // `--json` forms against the same expectation (JSON-only, SPEC 11). + for (const argv of [["inventory"], ["inventory", "--json"]] as const) { + const context = + `T11.6-3 — \`${argv.join(" ")}\` before any build: recorded [], ` + + `graphData ".xspec", journal unoccupied, sessions [] (SPEC 11.6)`; + const { document } = await expectInventoryDocument( + product, + workspace.root, + argv, + context, + ); + assertSameJson( + document.recorded, + { state: "value", value: [] }, + `${context} — \`recorded\` is empty before any generation has ` + + `run: the empty list, never null and never the unavailability ` + + `marker (SPEC 11.6, 12.7)`, + ); + assertGraphDataArea( + document.graphData, + `${context} — the graph-data area is reported unconditionally: a ` + + `consumer must know the area before any build has run`, + ); + assertJournalStatus( + document.journal, + false, + `${context} — no journal file exists yet`, + ); + assertSameJson( + document.sessions, + [], + `${context} — no session directory entries exist (SPEC 11.6, 10.1)`, + ); + // 11.6's ordering contract, configuration-order half: groups arrive + // in configuration order — `zz` before `aa`, inverting name byte + // order (profiles and rules ride the same clause; their + // configuration-order exact compares are T11.6-2's). + assertSameJson( + document.configuration.specs.map((group) => group.name), + ["zz", "aa"], + `${context} — groups in configuration order, not name byte order ` + + `(SPEC 11.6)`, + ); + // The as-configured baseline the lag arm contrasts against. + assertSameJson( + document.derived, + [ + { + source: "specs/apex.mdx", + module: "specs/apex.xspec.ts", + markdown: "specs/apex.md", + }, + { + source: "specs/zele.mdx", + module: "specs/zele.xspec.ts", + markdown: "specs/zele.md", + }, + ], + `${context} — the derived map per spec source with emission ` + + `enabled (SPEC 11.6, 13.1, 7.3)`, + ); + } + + // Build, then place a foreign file under the graph-data area. The + // foreign occupant is staged after the build so the arm asserts + // exactly what 11.6 defines — the inventory's treatment of an + // unattributed path — not any build-time behavior toward it. + await buildOk( + product, + workspace, + "T11.6-3 — the staged workspace is valid, so `build` succeeds and " + + "records the generated derived paths (SPEC 12.1, 13.3)", + ); + await workspace.file( + `${GRAPH_DATA_AREA_PATH}/${FOREIGN_TOKEN}.bin`, + "contenu etranger — ni journal, ni session, ni enregistre\n", + ); + + const postBuildRecorded: RecordedExpectation = { + pinned: [ + "specs/apex.md", + "specs/apex.xspec.ts", + "specs/zele.md", + "specs/zele.xspec.ts", + ], + specSources: ["specs/apex.mdx", "specs/zele.mdx"], + }; + const afterContext = + "T11.6-3 — `inventory` after a build: the record lists the " + + "recorded derived paths — modules, companions, Markdown (SPEC " + + "11.6, 13.3)"; + const after = await expectInventoryDocument( + product, + workspace.root, + ["inventory"], + afterContext, + ); + assertRecordedDerivedPaths( + after.document.recorded, + postBuildRecorded, + `${afterContext} — both generated modules and both emitted Markdown ` + + `files recorded, every further entry a 13.1-attributable companion`, + ); + assertJournalStatus( + after.document.journal, + false, + `${afterContext} — a build journals nothing: the journal is written ` + + `only by rename and move (SPEC 6.1)`, + ); + assertGraphDataArea(after.document.graphData, afterContext); + assertSameJson( + after.document.sessions, + [], + `${afterContext} — still no session directory entries`, + ); + // The foreign occupant is in no list: `sources` holds exactly the two + // discovered files (`.xspec/` is excluded from every group, 13.4), + // `sessions` is empty, the recorded entries are pinned-or-attributable + // — and the name appears nowhere in the document at all. + assertSameJson( + after.document.sources, + [ + { path: "specs/apex.mdx", groups: [{ name: "aa", kind: "spec" }] }, + { path: "specs/zele.mdx", groups: [{ name: "zz", kind: "spec" }] }, + ], + `${afterContext} — every discovered source with its membership; the ` + + `foreign file under .xspec/ is never a source (SPEC 11.6, 13.4)`, + ); + assertStdoutOmits( + after.result, + FOREIGN_TOKEN, + `${afterContext} — a foreign file under the graph-data area ` + + `(neither journal, session-named, nor recorded) is unattributed: ` + + `listed nowhere, claimed never (SPEC 11.6)`, + ); + + // Configuration change without rebuild: emission off. The resolved + // view and derived map follow the new configuration; the record lags — + // reported as recorded, not as configured (SPEC 11.6, 13.3: the + // inventory never refreshes or writes, and even a refresh leaves the + // recorded paths unchanged). + await workspace.file(CONFIG_FILE, DURABLES_NOEMIT_CONFIG); + const lagContext = + "T11.6-3 — `inventory` after the configuration change (emission " + + "off) without rebuild: the record lags, reported as recorded, not " + + "as configured (SPEC 11.6, 13.3)"; + const lag = await expectInventoryDocument( + product, + workspace.root, + ["inventory"], + lagContext, + ); + assertSameJson( + lag.document.configuration.markdown, + { emit: false, outDir: null }, + `${lagContext} — the resolved view reports the new configuration ` + + `(SPEC 7.3, 11.6)`, + ); + assertSameJson( + lag.document.derived, + [ + { + source: "specs/apex.mdx", + module: "specs/apex.xspec.ts", + markdown: null, + }, + { + source: "specs/zele.mdx", + module: "specs/zele.xspec.ts", + markdown: null, + }, + ], + `${lagContext} — the derived map follows the configuration: no ` + + `Markdown destination exists while emission is disabled (SPEC ` + + `7.3, 11.6)`, + ); + assertRecordedDerivedPaths( + lag.document.recorded, + postBuildRecorded, + `${lagContext} — the previously emitted Markdown paths and the ` + + `modules stay recorded until a rebuild replaces the record: a ` + + `product recomputing "recorded" from the current configuration ` + + `drops the .md paths and fails here`, + ); + assertStdoutOmits(lag.result, FOREIGN_TOKEN, lagContext); + + // Journal occupancy by presence alone: a garbage-content plain file, a + // directory, and a broken symbolic link each occupy the path — no + // content read, no 14.13 from the inventory (findings [] is asserted + // by the shared frame on every decode). + await workspace.file( + JOURNAL_PATH, + "ceci n'est pas une entree de journal\n", + ); + const plainFile = await expectInventoryDocument( + product, + workspace.root, + ["inventory"], + "T11.6-3 — `inventory` with a garbage-content plain file at the " + + "journal path: occupancy is presence alone and no content is " + + "read — no 14.13 from the inventory (SPEC 11.6, 6.1)", + ); + assertJournalStatus( + plainFile.document.journal, + true, + "T11.6-3 — journal occupied by a plain file", + ); + + await fsp.rm(workspace.path(JOURNAL_PATH)); + await workspace.dir(JOURNAL_PATH); + const directory = await expectInventoryDocument( + product, + workspace.root, + ["inventory"], + "T11.6-3 — `inventory` with a directory at the journal path: " + + "occupancy is presence alone, whatever kind of filesystem object " + + "occupies it (SPEC 11.6, 6.1)", + ); + assertJournalStatus( + directory.document.journal, + true, + "T11.6-3 — journal occupied by a directory", + ); + + await fsp.rm(workspace.path(JOURNAL_PATH), { recursive: true }); + await workspace.symlink(JOURNAL_PATH, "cible-fantome"); + const symlink = await expectInventoryDocument( + product, + workspace.root, + ["inventory"], + "T11.6-3 — `inventory` with a broken symbolic link at the journal " + + "path: still occupied — presence alone, so a product probing " + + "occupancy through the link (stat, open) wrongly reports absent " + + "(SPEC 11.6, 6.1)", + ); + assertJournalStatus( + symlink.document.journal, + true, + "T11.6-3 — journal occupied by a broken symbolic link", + ); + } finally { + await workspace.dispose(); + } + + // --- sessions workspace ------------------------------------------------- + const sessions = await TestWorkspace.create({ + files: { + [CONFIG_FILE]: SESSIONS_CONFIG, + "specs/seul.mdx": '<S id="seul">\nSeul.\n</S>\n', + }, + }); + try { + await buildOk( + product, + sessions, + "T11.6-3 — the sessions workspace is valid, so `build` succeeds " + + "(SPEC 12.1)", + ); + await expectExit( + product, + sessions, + ["review", "create", "--strategy", "audit", "--name", "ancien"], + 0, + "T11.6-3 — `review create --strategy audit --name ancien` writes " + + "the product's own session file (SPEC 10.1, 10.7)", + ); + // Staging premise: the product wrote a plain session file where 10.1 + // stores sessions — the later exact listing rests on it. + const sessionKind = await sessions.kind( + `${GRAPH_DATA_AREA_PATH}/reviews/ancien.json`, + ); + if (sessionKind !== "file") { + fail( + "T11.6-3 — staging premise: `review create` must store the " + + "session at .xspec/reviews/ancien.json as a plain file (SPEC " + + `10.1, 13.4); found ${sessionKind}`, + ); + } + await sessions.file( + `${GRAPH_DATA_AREA_PATH}/reviews/S.json`, + "{{{ pas du JSON du tout — contenu jamais lu par l'inventaire", + ); + await sessions.dir(`${GRAPH_DATA_AREA_PATH}/reviews/S2.json`); + await sessions.file( + `${GRAPH_DATA_AREA_PATH}/reviews/notes.txt`, + "a ne jamais lister\n", + ); + await sessions.file(`${GRAPH_DATA_AREA_PATH}/reviews/.foo.json`, "{}\n"); + + const listContext = + "T11.6-3 — `inventory` over the staged review-session directory: " + + "sessions are selected by name alone, content unread (SPEC 11.6, " + + "10.1)"; + const listed = await expectInventoryDocument( + product, + sessions.root, + ["inventory"], + listContext, + ); + // Selection by name alone, whatever occupies the entry: the + // product-written session, the garbage-content S.json, and the + // directory S2.json are all listed — no 14.21 here (findings [] in + // the frame) — while notes.txt (no .json session name) and .foo.json + // (a session name never begins with ".") never are. Order: byte + // order of file name — "S.json" < "S2.json" (0x2e < 0x32) < + // "ancien.json" (0x53 < 0x61); case folding would sort "ancien" + // first, so the byte-order contract has teeth. + assertSameJson( + listed.document.sessions, + [ + `${GRAPH_DATA_AREA_PATH}/reviews/S.json`, + `${GRAPH_DATA_AREA_PATH}/reviews/S2.json`, + `${GRAPH_DATA_AREA_PATH}/reviews/ancien.json`, + ], + `${listContext} — exactly the three session-named entries, in byte ` + + `order of file name`, + ); + assertStdoutOmits( + listed.result, + "notes.txt", + `${listContext} — a review-directory entry with no session file ` + + `name is not a session: never listed, never claimed (SPEC 10.1, ` + + `11.6)`, + ); + assertStdoutOmits( + listed.result, + ".foo.json", + `${listContext} — a session name never begins with ".", so ` + + `.foo.json is no session file name: never listed, never claimed ` + + `(SPEC 10.1, 11.6)`, + ); + assertJournalStatus( + listed.document.journal, + false, + `${listContext} — review operations never touch the journal (SPEC ` + + `6.1)`, + ); + assertGraphDataArea(listed.document.graphData, listContext); + assertRecordedDerivedPaths( + listed.document.recorded, + { pinned: ["specs/seul.xspec.ts"], specSources: ["specs/seul.mdx"] }, + `${listContext} — the record from the build: the module (no ` + + `Markdown; emission is disabled by the absent key) plus ` + + `attributable companions (SPEC 13.3, 13.1, 7.3)`, + ); + } finally { + await sessions.dispose(); + } + }, +}); + +export const section116Tests: readonly ProductTestEntry[] = [ + T11_6_1, + T11_6_2, + T11_6_3, +]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index aeadffb..8c4e9ea 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -319,6 +319,11 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T11.5-3": ["11.5"], "T11.6-1": ["11.6"], "T11.6-2": ["11.6"], + // T11.6-3: 13.3/13.1/6.1/10.1/12.7 are carriage context with home + // coverage at T13.3-*/T13.1-*/T6.1-*/T10.1-*/T12.7-*; the occupancy and + // listing arms assert findings [] — no numbered condition is asserted, so + // no "14" (the T11.6-2 precedent). + "T11.6-3": ["11.6"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From 69e0e419e48be3242c932243806584126c846b49 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 22:41:53 +0000 Subject: [PATCH 117/226] sdg(phase-9): implement T11.6-4's no-parse/no-write/one-finding arms (FP-072) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 50 ++- test/suite/registry/section-11.6.ts | 664 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 6 + 3 files changed, 706 insertions(+), 14 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 67222f8..8ee66c0 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3158,9 +3158,57 @@ certify against FP-091's fixtures once those land. (certification-document ×2 → FP-091; S-1 unmapped {12.6, 12.7} → stage G); certification green (violators failing as certified). FP-072 builds T11.6-4 on this module and decoder.] -- [ ] FP-072 — Implement T11.6-4: no-parse/no-write/one-finding +- [x] FP-072 — Implement T11.6-4: no-parse/no-write/one-finding (condition-23, `recorded` unavailable). [R2 #6; TEST-SPEC §11.6, SPEC 14.23] + [Done 2026-08-14: T11.6-4 registered in section-11.6.ts (SUITE-56), three + arm groups on FP-071's full-document decoder. Imperfect workspace (sources + failing every validation family + garbage journal line + corrupt session + `.xspec/reviews/louche.json`): staging premise pinned first, FP-016 style + — `build --json` exit 1 with exactly the staged multiset {14.1–14.9, + 14.11, 14.13, 14.15–14.20, one each} across MDX and TS (no 14.21 — build + reads no sessions; no 14.10/14.12 — check-only), probe-confirmed + byte-for-byte against the real product's build on the identical staging; + then flag-less and `--json` inventory against ONE expected document inside + a single whole-root `assertLeavesUnchanged` — the complete ten-member + document asserted exactly (unparseable casse.mdx listed in sources AND + carrying module/markdown in derived — discovery/configuration-determined, + the parse-driven-product discriminator; note.txt null/null; recorded [] + after the failed build, 12.1; journal occupied true; the corrupt session + listed by name), findings [] at exit 0 realizing "reported where their + conditions assign them, never here". Config-precedence arm: missing (bare + tree, T7-1 operationalization) and invalid (garbage TS beside a valid + source) each asserted flag-less (JSON-only surface — new local helper + `expectFlaglessInventoryConfigurationError`: exit 2, error document, + stable code `configuration-error`, non-null concerned path, /config/i + stderr) and via `expectConfigurationError` with `--json`; "no inventory" + is the decode's single-`error`-member enforcement. Corrupt-record arm: + valid built workspace, intact-record premise via `assertRecordedDerivedPaths` + (module+Markdown pinned), `corruptGraphDataShapeBlind` (T6.6-6's shared + staging), then both output forms inside one whole-root compare — exit 1, + exactly {"14.23": 1} (the token table pins `unreadable-record`), + concerned path `.xspec`, locations [], `recorded` exactly + {state:"unavailable"} (never read as empty), and every other member + deep-equal to the intact answer via `inventoryApartFromRecordSupplied`. + Traceability "T11.6-4": ["11.6", "14"] (TEST-SPEC 14's primary-test + record lists T11.6-4 under 14.23; 14.14/12.7/13.3/12.1 context, home + coverage elsewhere). In CERTIFICATIONS.md's Exclusions, no fixture scope. + Verified: typecheck/format clean; suite red-as-diagnosed at the premise + build's form-exact findings decode (the FP-001-class product gap — the + product still emits `condition`-member findings; inventory itself is + still unknown-command exit 2, the T11.6-1..-3 diagnosis); soundness + proven by a scratch mock implementing the 11.6 projection independently + (config extraction + own glob matcher + discovery walk + derived/record/ + session arithmetic, record unavailability by actual parse failure; the + premise findings hardcoded in 12.7 form/pinned order, their multiset + anchored by the real-product probe) — the whole registered body ran green + against it via `entry.run(mockBinding)`; teeth probed via seven mock + deviations (read-empty, session-content, refresh, parse-derived, + no-precedence, concern-inside, repair), each failing diagnosed at the + intended assertion. `npm run test:self`: unchanged 3 planned mid-loop + reds (certification-document ×2 → FP-091; S-1 unmapped {12.6, 12.7} → + stage G), 264 passed, certification green (violators failing as + certified).] - [ ] FP-073 — Implement T12.0-13: multi-`#` operand malformedness vs `#` in `<file>`/`--file` values (`specs/a#b.mdx` staging). [R2 #7; TEST-SPEC diff --git a/test/suite/registry/section-11.6.ts b/test/suite/registry/section-11.6.ts index e1fe4d1..caa184c 100644 --- a/test/suite/registry/section-11.6.ts +++ b/test/suite/registry/section-11.6.ts @@ -1,5 +1,5 @@ -// TEST-SPEC §11.6 (`xspec inventory`) — SUITE-56: T11.6-1, T11.6-2, T11.6-3 -// (T11.6-4 registers here as it is implemented). +// TEST-SPEC §11.6 (`xspec inventory`) — SUITE-56: T11.6-1, T11.6-2, T11.6-3, +// T11.6-4. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -126,10 +126,66 @@ // 0x61 'a'), inverting under case folding, so the byte-order contract has // teeth. // +// T11.6-4 — no parse, no write, one finding (SPEC 11.6, 14.23, 14.14, 12.7, +// 12.0, 13.3). Three arm groups, three workspaces: +// +// - imperfect workspace: sources failing every validation family — an +// unparseable file included — plus a garbage journal line and a corrupt +// session. Staging premise pinned first (the FP-016 style): `build --json` +// exits 1 reporting exactly the staged multiset — 14.1–14.9, 14.11, 14.15 +// through 14.20 across MDX and TS, plus the journal line's 14.13 — one +// finding each, nothing beside (14.21 deliberately absent: `build` does +// not read sessions, SPEC 14; 14.10/14.12 are `check`-only; the record is +// absent, so no 14.23 anywhere). Then `inventory`, flag-less and `--json` +// against ONE expected document, both inside a single whole-root +// modifies-nothing compare (byte-compare; no refresh — graph data absent +// before and after, where every refreshing read would create it or die on +// the invalid sources): the COMPLETE ten-member document asserted exactly +// — every discovered source listed with its membership, the unparseable +// and non-`.mdx` files included; the derived map determined by +// configuration and discovery alone (the unparseable source's module and +// Markdown paths present — a product computing the map through parsing +// dies here); `recorded` [] (the failed build modified nothing, 12.1); +// the journal occupied; the corrupt session listed by name — and +// `findings` [] at exit 0: the staged findings are reported where their +// conditions assign them (the premise build; T13.3-3, T10.1-4, T12.2-2), +// never here, which IS the parses-no-sources/reads-no-content observation. +// +// - configuration errors keep precedence (14.14): missing configuration (a +// bare directory tree with no reachable xspec.config.ts, the T7-1 +// operationalization) and invalid configuration (garbage TypeScript, a +// valid source beside it so the refusal is attributable to the +// configuration alone) each → exit 2 with the single 12.7 error document +// as the entire stdout — asserted in the flag-less form (inventory is a +// JSON-only surface, so JSON output is in effect without `--json`, SPEC +// 12.0) and via `expectConfigurationError`'s `--json` form — the finding +// carrying the stable code `configuration-error` and a concerned path, +// the stderr message naming the configuration; "no inventory" is the +// decode itself: the error document is `{"error": …}` exactly, no +// inventory member beside it (12.7). +// +// - corrupt-record workspace: valid, built, `recorded` premise-pinned as a +// readable non-empty record (module and Markdown present), then the +// record corrupted shape-blind (T6.6-6's staging — garbage over T13.3-2's +// operational path set, product-written files only, H-3/H-4). Both output +// forms inside one whole-root compare (the corrupt state is left neither +// read-repaired nor replaced, 13.3): exit 1 (an answer carrying a finding +// and explicitly-unavailable data, 12.0), `recorded` exactly the +// unavailability marker — never read as empty, never fabricated — +// `findings` exactly one condition-23 finding (the stable code +// `unreadable-record` pinned through the decode's token table), concerned +// path the graph-data area, locations [] (no path inside the area is +// named, 13.3/12.7), and every other member emitted in full — deep-equal +// to the intact-record answer on the same workspace. +// // Certification note: CERTIFICATIONS.md's Exclusions list T11.6-1 through // T11.6-4 ("`inventory` and `version`"), so no fixture executes these // bodies; the anchoring, resolved-configuration, derived-map, occupancy, -// and listing arms are positive and byte-asserted per that entry. +// and listing arms are positive and byte-asserted per that entry, T11.6-4's +// no-parse/no-write negatives ride the certified compare-around machinery, +// and every broken state T11.6-4 must ignore is positively reported from +// the same staging by its home reporter (its premise build in-test; +// T13.3-3, T10.1-4, T12.2-2 on their own stagings). import { Buffer } from "node:buffer"; import * as fsp from "node:fs/promises"; @@ -146,6 +202,7 @@ import type { } from "../../helpers/adapters/index.js"; import { GRAPH_DATA_AREA_PATH, + corruptGraphDataShapeBlind, decodeInventoryAnchoring, decodeInventoryDocument, decodeInventoryFindings, @@ -160,10 +217,20 @@ import { } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; -import { runProduct } from "../../helpers/subprocess.js"; +import { runProduct, summarizeResult } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import { assertSameJson, buildOk, expectExit } from "./support.js"; +import { + assertConditionCounts, + assertFindingConcernsPath, + assertSameJson, + buildFindings, + buildOk, + expectConfigurationError, + expectErrorDocument, + expectExit, +} from "./support.js"; // --- fixture ------------------------------------------------------------------ // @@ -1111,14 +1178,15 @@ export default defineConfig({ const JOURNAL_PATH = `${GRAPH_DATA_AREA_PATH}/journal`; /** - * Run `inventory` and assert the T11.6-3 frame: exit 0 exactly (every - * staging here is a complete, finding-free answer — the inventory parses no - * sources, reads no journal or session content, and no arm corrupts the - * record; SPEC 11.6, 12.0; H-5); exactly one JSON document as the entire - * stdout (JSON-only, SPEC 11); the full ten-member 12.7 inventory document - * form (H-3); `findings` [] — which IS the no-14.13/no-14.21 observation on - * the occupancy and session stagings. Returns the decoded document and the - * raw run for the callers' value assertions and byte scans. + * Run `inventory` and assert the finding-free full-document frame (T11.6-3; + * reused by T11.6-4's imperfect-workspace arm and intact-record premise): + * exit 0 exactly (a complete, finding-free answer — the inventory parses no + * sources, reads no journal or session content, and the calling arm has not + * corrupted the record; SPEC 11.6, 12.0; H-5); exactly one JSON document as + * the entire stdout (JSON-only, SPEC 11); the full ten-member 12.7 inventory + * document form (H-3); `findings` [] — which IS the no-14.13/no-14.21 + * observation on the occupancy and session stagings. Returns the decoded + * document and the raw run for the callers' value assertions and byte scans. */ async function expectInventoryDocument( product: ProductBinding, @@ -1671,8 +1739,578 @@ const T11_6_3 = defineProductTest({ }, }); +// --- T11.6-4 ------------------------------------------------------------------ +// +// Fixtures. The imperfect workspace's sources fail every validation family +// (see the module header): one heavily invalid but parseable file, one +// unparseable file, an in-file dependency cycle, two valid resolution +// targets, a non-`.mdx` spec-group file (the extension-free glob discovers +// it, 14.19), and a TypeScript source with the code-side reference family — +// plus a garbage journal line and a corrupt session staged as files. The +// configuration is valid (a configuration error would preempt everything, +// 14.14) with emission enabled, so the derived map carries Markdown +// destinations for every `.mdx` source, the unparseable one included. + +const IMPERFECT_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + grp: ["specs/*"] + }, + code: { + impl: ["src/**/*.ts"] + }, + markdown: { emit: true } +}) +`; + +/** + * The parseable multi-family file: 14.1 (id-less section), 14.2 (top-level + * multi-segment ID), 14.3 (duplicated `paire`, one finding), 14.4 + * (whitespace in a segment), 14.5 (`d` to an absent node), 14.6 + * (`text(...)` to an absent node), 14.8 (zero-argument `text()`), 14.15 + * (import designating no discovered spec source), 14.16 (`<div>`), 14.17 + * (unknown prop) — each staged once. + */ +const IMPERFECT_MULTI = `import AUTRE from "./autre.xspec" +import RIEN from "./inexistant.xspec" + +<S> +Sans identite. +</S> + +<S id="saut.niveau"> +Saute un niveau. +</S> + +<S id="paire"> +Premiere. +</S> + +<S id="paire"> +Seconde. +</S> + +<S id="mauvais seg"> +Segment invalide. +</S> + +<S id="charge" d={AUTRE.absent}> +Dependance inconnue. + +{text(AUTRE.manque)} + +{text()} +</S> + +<S id="props" inconnu="x"> +Prop inconnue. +</S> + +<div>hors grammaire</div> +`; + +/** + * The TypeScript reference family: an unresolving marker (14.7), a spec + * module binding used outside the sanctioned forms (14.18), and a node of + * one module passed to another module's `text` export (14.11) — the two + * imports themselves valid (SPEC 4), their targets the valid spec files. + */ +const IMPERFECT_CODE = `import AUTRE, { text } from "../specs/autre.xspec"; +import PUR from "../specs/pur.xspec"; + +export function usine() { + AUTRE.inconnu; + const garde = AUTRE; + text(PUR.net); +} +`; + +/** In-file dependency cycle via local string references (14.9, SPEC 2.4). */ +const IMPERFECT_CYCLE = `<S id="boucle1" d={"boucle2"}> +Premier maillon. +</S> + +<S id="boucle2" d={"boucle1"}> +Second maillon. +</S> +`; + +/** Unparseable MDX: an unclosed section tag (14.20). */ +const IMPERFECT_BROKEN = '<S id="casse">\nJamais fermee.\n'; + +/** The corrupt session's path: a well-formed session file name (SPEC 10.1). */ +const CORRUPT_SESSION_PATH = `${GRAPH_DATA_AREA_PATH}/reviews/louche.json`; + +/** + * The staged multiset the premise `build` must report — one finding per + * staged construct, nothing beside: 14.21 is deliberately absent (`build` + * does not read sessions, SPEC 14), 14.10/14.12 are `check`-only, the + * configuration is valid (no 14.14), no write path is obstructed (no + * 14.22), and no record exists (14.23 is never `build`'s anyway). Counting + * keys are the token-derived `14.N` identities (support.ts), so each count + * pins the exact stable code string too. + */ +const IMPERFECT_PREMISE_CONDITIONS: Readonly<Record<string, number>> = { + "14.1": 1, + "14.2": 1, + "14.3": 1, + "14.4": 1, + "14.5": 1, + "14.6": 1, + "14.7": 1, + "14.8": 1, + "14.9": 1, + "14.11": 1, + "14.13": 1, + "14.15": 1, + "14.16": 1, + "14.17": 1, + "14.18": 1, + "14.19": 1, + "14.20": 1, +}; + +/** Not well-formed TypeScript: the invalid-configuration staging (14.14). */ +const IMPERFECT_BROKEN_CONFIG = + "ceci n'est pas du TypeScript ((( donc pas une configuration\n"; + +/** Arm C's valid workspace: one source, emission on (a rich record). */ +const RECORD_EMIT_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + seul: ["specs/**/*.mdx"] + }, + markdown: { emit: true } +}) +`; + +/** + * Run flag-less `inventory` from `cwd` and assert the 14.14 precedence + * contract on a JSON-only surface: exit 2 exactly; stdout exactly the + * single 12.7 error document — JSON output is in effect without `--json` + * (SPEC 12.0), and the decode's single `error` member IS the no-inventory + * observation — its finding carrying the stable code `configuration-error` + * and a non-`null` concerned path (SPEC 14; the exact anchoring-form + * spelling is T12.7-3's assertion); and a standard-error message + * identifying the configuration as the failing subject (/config/i, the + * `expectConfigurationError` operationalization; 12.0: error messages are + * standard-error content, diagnostics beside the error document). + */ +async function expectFlaglessInventoryConfigurationError( + product: ProductBinding, + cwd: string, + context: string, +): Promise<void> { + const result = await runProduct(product, { cwd, argv: ["inventory"] }); + assertExitCode( + result, + 2, + `${context} — missing or invalid configuration is a configuration ` + + `error, preceding the inventory: exit 2, no inventory (SPEC 14.14, ` + + `11.6, 12.0)`, + ); + const error = expectErrorDocument(result, context); + if (error.code !== "configuration-error") { + fail( + `${context}: the error document's finding must carry the stable code ` + + `"configuration-error" (SPEC 14 condition 14, 12.7); got ` + + `${JSON.stringify(error.code)} (message: ` + + `${JSON.stringify(error.message)})`, + ); + } + if (error.path === null) { + fail( + `${context}: a configuration error's finding carries its concerned ` + + `path — the configuration file, or "." for a failed upward search — ` + + `in the anchoring form (SPEC 14, 12.7); got null`, + ); + } + if (!/config/i.test(result.stderr)) { + fail( + `${context}: the configuration-error message on stderr must identify ` + + `the configuration as the failing subject (SPEC 14.14; 12.0: error ` + + `messages are standard-error content) — any phrasing naming ` + + `xspec.config.ts or "configuration" qualifies (H-3); got ` + + `${summarizeResult(result)}`, + ); + } +} + +/** + * An inventory document's eight members apart from the record-supplied + * `recorded` datum and the `findings` that report its state — the "every + * other member emitted in full" projection of SPEC 14.23, built in the + * decoded document's member order so `assertSameJson` compares exactly. + */ +function inventoryApartFromRecordSupplied( + document: InventoryDocument, +): Record<string, unknown> { + return { + root: document.root, + config: document.config, + configuration: document.configuration, + sources: document.sources, + derived: document.derived, + graphData: document.graphData, + journal: document.journal, + sessions: document.sessions, + }; +} + +const T11_6_4 = defineProductTest({ + id: "T11.6-4", + title: + "inventory no parse, no write, one finding: on a workspace whose sources fail every validation family — an unparseable file included, the premise `build --json` exiting 1 with exactly one finding per staged construct (14.1–14.9, 14.11, 14.15–14.20 across MDX and TS, plus the garbage journal line's 14.13; no 14.21 — build reads no sessions) — with a garbage journal line and a corrupt session staged, `inventory` answers in full, finding-free, exit 0, modifying nothing (whole-root byte-compare; no refresh — graph data absent throughout): the complete ten-member document asserted exactly in the flag-less and `--json` forms against one expectation — every discovered source listed with its membership (the unparseable and non-`.mdx` files included), the derived map determined by configuration and discovery alone (the unparseable source's module and Markdown paths present), `recorded` [], the journal occupied, the corrupt session listed by name — those findings reported where their conditions assign them, never here; configuration errors keep precedence: missing and invalid configuration each exit 2 with the single 12.7 error document as the entire stdout (stable code `configuration-error`, concerned path present, stderr naming the configuration), flag-less — a JSON-only surface — and with `--json` alike, no inventory beside the error member; the one finding it ever carries: with the record corrupted shape-blind (T6.6-6's staging) after a pinned readable-record premise, `recorded` is exactly the unavailability marker — never read as empty — beside exactly one condition-23 finding (stable code `unreadable-record`, concerned path the graph-data area, locations []: no path inside the area is named), exit 1, every other member emitted in full (deep-equal to the intact-record answer), the corrupt state left unmodified in a whole-root compare (SPEC 11.6, 14.23, 14.14, 14, 12.7, 12.0, 13.3, 12.1, 11)", + run: async (product) => { + // --- arm A: the imperfect workspace ------------------------------------ + const imperfect = await TestWorkspace.create({ + files: { + [CONFIG_FILE]: IMPERFECT_CONFIG, + "specs/anneau.mdx": IMPERFECT_CYCLE, + "specs/autre.mdx": '<S id="autre">\nCible saine.\n</S>\n', + "specs/casse.mdx": IMPERFECT_BROKEN, + "specs/multi.mdx": IMPERFECT_MULTI, + "specs/note.txt": "pas une source xspec\n", + "specs/pur.mdx": '<S id="net">\nCible nette.\n</S>\n', + "src/impl.ts": IMPERFECT_CODE, + [JOURNAL_PATH]: "pas une entree de journal valide\n", + [CORRUPT_SESSION_PATH]: "{{{ pas du JSON — session corrompue\n", + }, + }); + try { + // Staging premise (SPEC 14; the Exclusions' positively-reported + // condition): the workspace genuinely fails every staged family — the + // premise build reports exactly one finding per staged construct and + // nothing beside. 14.21 is absent (build reads no sessions), which is + // itself part of the "reported where their conditions assign them" + // contract this arm rides. + const premise = await buildFindings( + product, + imperfect, + "T11.6-4 — staging premise: `build --json` on the imperfect " + + "workspace exits 1 reporting the staged validation findings " + + "(SPEC 12.1, 14)", + ); + assertConditionCounts( + premise, + IMPERFECT_PREMISE_CONDITIONS, + "T11.6-4 — staging premise: exactly the staged multiset — every " + + "validation family fails once, none masked away, none phantom, " + + "no 14.21 (build reads no sessions) and no 14.10/14.12 " + + "(check-only) (SPEC 14)", + ); + + // The complete expected document, asserted exactly (SPEC 11.6, 12.7): + // the answer is full — sources and derived from configuration and + // discovery alone, the record-supplied datum the empty record (the + // failed premise build modified nothing, 12.1), durables by presence + // and name alone — and finding-free at exit 0. + const expectedImperfect: InventoryDocument = { + findings: [], + root: ".", + config: CONFIG_FILE, + configuration: { + specs: [{ name: "grp", globs: ["specs/*"] }], + code: [{ name: "impl", globs: ["src/**/*.ts"] }], + markdown: { emit: true, outDir: null }, + coverage: [], + policy: [], + }, + sources: [ + { + path: "specs/anneau.mdx", + groups: [{ name: "grp", kind: "spec" }], + }, + { path: "specs/autre.mdx", groups: [{ name: "grp", kind: "spec" }] }, + // The unparseable file IS a discovered source with a membership: + // discovery is glob-driven, never parse-driven (SPEC 7, 11.6). + { path: "specs/casse.mdx", groups: [{ name: "grp", kind: "spec" }] }, + { path: "specs/multi.mdx", groups: [{ name: "grp", kind: "spec" }] }, + { path: "specs/note.txt", groups: [{ name: "grp", kind: "spec" }] }, + { path: "specs/pur.mdx", groups: [{ name: "grp", kind: "spec" }] }, + { path: "src/impl.ts", groups: [{ name: "impl", kind: "code" }] }, + ], + derived: [ + { + source: "specs/anneau.mdx", + module: "specs/anneau.xspec.ts", + markdown: "specs/anneau.md", + }, + { + source: "specs/autre.mdx", + module: "specs/autre.xspec.ts", + markdown: "specs/autre.md", + }, + // Determined by configuration and discovery, existing whether or + // not generation could ever succeed: the unparseable source's + // derived paths are present — a product computing the map by + // parsing sources fails here (SPEC 11.6, 13.1). + { + source: "specs/casse.mdx", + module: "specs/casse.xspec.ts", + markdown: "specs/casse.md", + }, + { + source: "specs/multi.mdx", + module: "specs/multi.xspec.ts", + markdown: "specs/multi.md", + }, + // The spec-group file without `.mdx` (14.19): both structurally + // absent (SPEC 11.6, 13.1, 12.7). + { source: "specs/note.txt", module: null, markdown: null }, + { + source: "specs/pur.mdx", + module: "specs/pur.xspec.ts", + markdown: "specs/pur.md", + }, + ], + // Empty before any generation — the premise build failed and + // modified nothing (SPEC 12.1), so the record is the empty list: + // never null, never the unavailability marker (SPEC 11.6, 12.7). + recorded: { state: "value", value: [] }, + graphData: GRAPH_DATA_AREA_PATH, + // Occupancy by presence alone — the garbage content is never read, + // no 14.13 from the inventory (SPEC 11.6, 6.1). + journal: { path: JOURNAL_PATH, occupied: true }, + // Selected by name alone — the corrupt content is never read, no + // 14.21 from the inventory (SPEC 11.6, 10.1). + sessions: [CORRUPT_SESSION_PATH], + }; + + // Both output forms inside ONE whole-root modifies-nothing compare: + // the inventory never refreshes or writes anything (SPEC 11.6) — + // graph data stays absent (every refreshing read would create it or + // die on the invalid sources, 13.3), sources, journal, and session + // bytes stay put. + await assertLeavesUnchanged( + imperfect.root, + async () => { + for (const argv of [ + ["inventory"], + ["inventory", "--json"], + ] as const) { + const context = + `T11.6-4 — \`${argv.join(" ")}\` on the imperfect workspace: ` + + `the inventory parses no sources and reads no journal or ` + + `session content — the answer is complete, finding-free, ` + + `exit 0, the staged findings reported where their conditions ` + + `assign them, never here (SPEC 11.6, 12.0)`; + const { document } = await expectInventoryDocument( + product, + imperfect.root, + argv, + context, + ); + assertSameJson( + document, + expectedImperfect, + `${context} — the complete ten-member document, exactly: ` + + `every discovered source with its membership (unparseable ` + + `and non-.mdx files included), the configuration-determined ` + + `derived map, recorded [], the occupied journal, the ` + + `corrupt session listed by name (SPEC 11.6, 12.7)`, + ); + } + }, + "T11.6-4 — `inventory` on the imperfect workspace modifies nothing " + + "and never refreshes: graph data absent before and after, every " + + "source, journal, and session byte untouched (SPEC 11.6, 13.3)", + ); + } finally { + await imperfect.dispose(); + } + + // --- arm B: configuration errors keep precedence (14.14) --------------- + const missing = await TestWorkspace.create({}); + try { + await expectFlaglessInventoryConfigurationError( + product, + missing.root, + "T11.6-4 — flag-less `inventory` with no reachable configuration " + + "(the upward search exhausts): the error document on a JSON-only " + + "surface, no inventory (SPEC 14.14, 11.6, 12.0, 12.7)", + ); + await expectConfigurationError( + product, + missing, + ["inventory"], + "T11.6-4 — `inventory --json` with no reachable configuration: " + + "exit 2, the single 12.7 error document, no inventory (SPEC " + + "14.14, 11.6, 12.0)", + ); + } finally { + await missing.dispose(); + } + + const invalid = await TestWorkspace.create({ + files: { + [CONFIG_FILE]: IMPERFECT_BROKEN_CONFIG, + // A valid source beside the broken configuration: the refusal is + // attributable to the configuration alone, and "no inventory" has + // content an answer would have carried. + "specs/a.mdx": ANCHOR_SOURCE, + }, + }); + try { + await expectFlaglessInventoryConfigurationError( + product, + invalid.root, + "T11.6-4 — flag-less `inventory` with invalid configuration (not " + + "well-formed TypeScript): the error document on a JSON-only " + + "surface, no inventory (SPEC 14.14, 14 condition 14, 11.6, 12.0)", + ); + await expectConfigurationError( + product, + invalid, + ["inventory"], + "T11.6-4 — `inventory --json` with invalid configuration: exit 2, " + + "the single 12.7 error document, no inventory (SPEC 14.14, 11.6)", + ); + } finally { + await invalid.dispose(); + } + + // --- arm C: the one finding it ever carries (14.23) -------------------- + const record = await TestWorkspace.create({ + files: { + [CONFIG_FILE]: RECORD_EMIT_CONFIG, + "specs/seul.mdx": '<S id="seul">\nContenu stable.\n</S>\n', + }, + }); + try { + await buildOk( + product, + record, + "T11.6-4 — the corrupt-record workspace is valid, so `build` " + + "succeeds and records the generated derived paths (SPEC 12.1, " + + "13.3)", + ); + const intactContext = + "T11.6-4 — `inventory` on the intact record: the readable-record " + + "premise the corruption then destroys (SPEC 11.6, 13.3)"; + const intact = await expectInventoryDocument( + product, + record.root, + ["inventory"], + intactContext, + ); + // Premise: the record-supplied datum is a readable, non-empty record + // — module and Markdown pinned present — so the corrupt-state + // "unavailable" below is a real state change, and "never read as + // empty" has a non-empty record to contrast against. + assertRecordedDerivedPaths( + intact.document.recorded, + { + pinned: ["specs/seul.md", "specs/seul.xspec.ts"], + specSources: ["specs/seul.mdx"], + }, + `${intactContext} — the generated module and emitted Markdown ` + + `recorded, every further entry an attributable companion`, + ); + + // Corrupt the product-written record shape-blind (TEST-SPEC T6.6-6; + // H-3 adapter — garbage over T13.3-2's operational path set, files + // present but readable as no record). + await corruptGraphDataShapeBlind( + record.root, + "T11.6-4 — corrupt-record staging", + ); + + // Both output forms inside ONE whole-root compare: the inventory + // leaves the corrupt state neither read-repaired nor replaced (SPEC + // 11.6, 13.3 — only a successful build or finishing regeneration + // replaces it). + await assertLeavesUnchanged( + record.root, + async () => { + for (const argv of [ + ["inventory"], + ["inventory", "--json"], + ] as const) { + const context = + `T11.6-4 — \`${argv.join(" ")}\` with the record corrupted ` + + `shape-blind: recorded explicitly unavailable beside the one ` + + `condition-23 finding, every other member in full (SPEC ` + + `14.23, 11.6)`; + const result = await expectExit( + product, + record, + argv, + 1, + `${context} — an answer carrying a finding and ` + + `explicitly-unavailable data exits 1, emitted in full ` + + `(SPEC 14.23, 12.0)`, + ); + const document = decodeInventoryDocument( + parseJsonStdout( + result, + `${context} — inventory is JSON-only: a single JSON ` + + `document as the entire stdout (SPEC 11, 12.0)`, + ), + context, + ); + // The one finding an inventory answer ever carries: exactly one + // condition-23 finding. The counting key "14.23" is the + // token-derived identity, so this pins the stable code + // `unreadable-record` exactly (an unknown or misspelled code + // fails the decode; a different token counts elsewhere). + assertConditionCounts( + document.findings, + { "14.23": 1 }, + `${context} — exactly the one condition-23 finding (stable ` + + `code unreadable-record) — the workspace is otherwise ` + + `clean, and the inventory meets no other condition (SPEC ` + + `14.23, 11.6, 14)`, + ); + const finding = document.findings[0]!; + assertFindingConcernsPath( + finding, + GRAPH_DATA_AREA_PATH, + `${context} — the concerned path is the graph-data area, the ` + + `.xspec directory spelled workspace-relative with no ` + + `trailing separator (SPEC 14.23, 11.6)`, + ); + assertSameJson( + finding.locations, + [], + `${context} — no path inside the area is named: the record's ` + + `layout is deliberately unenumerated (SPEC 14.23, 13.3), ` + + `and a path-concerned condition is unlocated — locations ` + + `[] (SPEC 12.7)`, + ); + assertSameJson( + document.recorded, + { state: "unavailable" }, + `${context} — the record-supplied datum is exactly the ` + + `unavailability marker: never fabricated, never read as an ` + + `empty record (the intact premise recorded real paths, so ` + + `[] here would be a fabrication) (SPEC 14.23, 11.6, 12.7)`, + ); + assertSameJson( + inventoryApartFromRecordSupplied(document), + inventoryApartFromRecordSupplied(intact.document), + `${context} — every other member emitted in full: the ` + + `anchoring, configuration, sources, derived map, area, ` + + `journal, and sessions equal to the intact-record answer ` + + `on this same workspace (SPEC 14.23, 11.6)`, + ); + } + }, + "T11.6-4 — `inventory` on the corrupt record modifies nothing: the " + + "corrupt state is left neither read-repaired nor replaced, every " + + "byte untouched (SPEC 11.6, 13.3, 14.23)", + ); + } finally { + await record.dispose(); + } + }, +}); + export const section116Tests: readonly ProductTestEntry[] = [ T11_6_1, T11_6_2, T11_6_3, + T11_6_4, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 8c4e9ea..19d59e7 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -324,6 +324,12 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { // listing arms assert findings [] — no numbered condition is asserted, so // no "14" (the T11.6-2 precedent). "T11.6-3": ["11.6"], + // T11.6-4: asserts numbered conditions — the premise build's + // every-family multiset and the condition-23 finding (TEST-SPEC 14's + // primary-test record lists T11.6-4 under 14.23) — so "14" joins the + // home passage; 14.14/12.7/13.3/12.1 are carriage context with home + // coverage at T7-*/T12.7-*/T13.3-*/T12.1-*. + "T11.6-4": ["11.6", "14"], "T12.0-1": ["12.0"], "T12.0-2": ["12.0"], "T12.0-3": ["12.0"], From 949c3c907274c3e4f01161661020cd8cb8c1fa74 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 23:10:19 +0000 Subject: [PATCH 118/226] sdg(phase-9): implement T12.0-13's multi-# malformedness and whole-path arms (FP-073) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 54 ++- test/suite/registry/section-12.0-ii.ts | 559 ++++++++++++++++++++++++- test/suite/registry/traceability.ts | 5 + 3 files changed, 615 insertions(+), 3 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 8ee66c0..17baf35 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3210,9 +3210,61 @@ certify against FP-091's fixtures once those land. stage G), 264 passed, certification green (violators failing as certified).] -- [ ] FP-073 — Implement T12.0-13: multi-`#` operand malformedness vs `#` +- [x] FP-073 — Implement T12.0-13: multi-`#` operand malformedness vs `#` in `<file>`/`--file` values (`specs/a#b.mdx` staging). [R2 #7; TEST-SPEC §12.0] Registry `section-12.0-i.ts` or `-ii.ts`; map `"12.0"`. + [Done 2026-08-14: T12.0-13 registered in section-12.0-ii.ts (SUITE-42) on + one workspace — valid specs/OK.mdx beside specs/a#b.mdx (content + condition-free; multi-byte prefix; section `pa` spelled so the multi-`#` + operand `specs/a#b.mdx#pa` is a last-`#`-split trap: both halves name + real staged things). Staging premise FP-016 style: `build --json` exit 1 + with exactly one pinned 14.19 ({invalid-source-path, locations [], path + specs/a#b.mdx}), whole-root compare. Malformed arms: the entry's literal + `a#b#c` and the trap spelling, each on `show`, `query node`, + `occurrences --to`, and both `move` operand sides (destination = the + T6.5-4 dead-letter spelling, `#` in the section form's target-file part; + moves in whole-root modifies-nothing compares) — each exit 2 via the + FP-002 protocol (single 12.7 error document under `--json`, stderr + nonempty), the usage error preceding the failing workspace's findings + (12.0). Whole-path arms: `view specs/a#b.mdx` → exit 1, exactly its one + per-file view (membership holds — never a specs/a + b.mdx pair, which + would be exit 2 unknown file), tree projection pinned with every node + identity the unavailability marker; `at specs/a#b.mdx 0` → exit 1, root + construct + marker, occurrence null; `occurrences --file specs/a#*` → + exit 1 with exactly the 14.19 (domain membership = the pattern matched) + against the matching-nothing control `specs/zz#*` (exit 0, empty, + finding-free, 11.3). Traceability "T12.0-13": ["12.0"] (FP-016 + precedent: in no TEST-SPEC 14 staging record — the premise 14.19 rides + staging integrity, the T11.2-3 precedent; 11.2-11.5/12.7/6.5 carriage + context, home coverage elsewhere). In CERTIFICATIONS.md's Exclusions + only, no fixture scope. Verified: typecheck/format clean; suite + red-as-diagnosed at the premise build's form-exact findings decode (the + FP-001-class gap — the product emits condition-member findings; + section-12.0-ii 3→4 failed / 3 passed, the other three the pre-existing + T12.0-9/-10/-12 reds); every downstream arm probed against the built + product: the show/query-node multi-`#` arms exit 1 (the gated + invalid-workspace report — the product runs no multi-`#` value check, + the FP-039-noted precedence-gap family), `occurrences`/`view`/`at` exit + 2 unknown-command (patch-new surface; the --to arms' exit-2 coincidence + still fails at the absent error document, FP-002-class), the move + destination arm exit 1 (the product proceeds into the invalid-workspace + refusal — exactly the accepting-product path the arm discriminates), the + move origin arm exit 2 "unknown file 'specs/a'" (first-`#` split) + failing at the absent error document — all probes modifying nothing. + Soundness proven by a scratch conforming mock (config extraction + own + glob matcher + discovery walk + 14.19 arithmetic + byte-offset MDX + section/attribute scan) running the registered body green via + entry.run(mockBinding), with eight deviation mocks each failing at the + intended assertion: split-last (first --to arm's exit code), + split-last-checked (the trap arm alone catches a product rejecting only + unresolvable multi-`#` spellings), no-error-doc (the error-document + decode), view-split (view membership exit), glob-split (occurrences + --file exit), move-writes (the whole-root compare), + identity-over-invalid (the tree projection), extra-finding-attach (the + control's exit 0). `npm run test:self`: unchanged 3 planned mid-loop + reds (certification-document ×2 → FP-091; S-1 unmapped {12.6, 12.7} → + stage G), 264 passed, certification green (violators failing as + certified).] - [ ] FP-074 — Implement T12.6-1 and T12.6-2: the `version` command. [R2 #8; TEST-SPEC §12.6] Form-exact `{"product","interface"}` with diff --git a/test/suite/registry/section-12.0-ii.ts b/test/suite/registry/section-12.0-ii.ts index 7e612c5..d69732b 100644 --- a/test/suite/registry/section-12.0-ii.ts +++ b/test/suite/registry/section-12.0-ii.ts @@ -1,5 +1,5 @@ // TEST-SPEC §12.0 II (global command conventions, second half) — SUITE-42: -// T12.0-7, T12.0-8, T12.0-9, T12.0-10, T12.0-11, T12.0-12. +// T12.0-7, T12.0-8, T12.0-9, T12.0-10, T12.0-11, T12.0-12, T12.0-13. // // T12.0-10's rename/move and baseline arms stay cross-references in // TEST-SPEC ("Rename/move and baseline arms: T6.4-4/T6.5-5 (existence, @@ -77,18 +77,43 @@ // enclosing git repository (walked to the filesystem root), thrown as a // harness staging error — an ambient repository would mask a product that // wrongly requires git. +// - T12.0-13 stages the entry's `specs/a#b.mdx` on every platform (`#` is a +// legal file-name byte on every filesystem the harness supports — the +// T11.2-3 operationalization of the entry's "(Linux leg)" note, which +// exists for that entry's non-UTF-8 siblings, staged nowhere in this +// test — so no platform skips it, H-9). Its multi-`#` spellings pair the +// entry's literal `a#b#c` with `specs/a#b.mdx#pa`, whose last-`#` split +// names a DISCOVERED file plus a SPELLED id: a product splitting at the +// last `#` instead of rejecting the value proceeds into the gated read / +// move machinery and answers exit 1 on this failing workspace — an +// observably different exit — while the first-`#` split's unknown-file +// error stays inside exit class 2 and is discriminated by T12.0-10's +// valid-twin machinery, not re-staged here. "Malformed value → exit 2" is +// asserted with the FP-002 protocol (single 12.7 error document under +// JSON output, stderr message present); the no-configuration-load half of +// malformed-value precedence is T12.0-10's within-class-2 arm. import { Buffer } from "node:buffer"; import * as path from "node:path"; import { assertReportMentions, + decodeAtReport, decodeCoverageReport, decodeExportReport, decodeFindingsReport, decodeNextReport, + decodeOccurrencesReport, decodeReachableReport, + decodeViewReport, +} from "../../helpers/adapters/index.js"; +import type { + ExportReport, + Finding, + PathValue, + SourceRange, + ViewAttributeEntry, + ViewNode, } from "../../helpers/adapters/index.js"; -import type { ExportReport, Finding } from "../../helpers/adapters/index.js"; import { assertBytesEqual, assertExitCode, @@ -103,6 +128,7 @@ import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { assertDirectoriesEqual, + assertLeavesUnchanged, diffSnapshots, snapshotDirectory, } from "../../helpers/snapshot.js"; @@ -2416,6 +2442,534 @@ const T12_0_12 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T12.0-13 — `#` in operands +// --------------------------------------------------------------------------- +// +// SPEC 12.0: `<node>` and `<graph-node>` values are identities in the form of +// 1.5, their `#` splitting path from id or unit, and the split applies +// equally to an operand spelled `<file>#<id>` (6.5); at most one `#` is +// well-formed in any such value — 11.3 pins the same bound for `--to` — so a +// spelling containing more than one `#` is a malformed value, a usage error, +// and the split is never ambiguous. A bare `<file>` operand and a `--file` +// glob are instead a whole path or pattern: `#` has no delimiter role in +// them, so a `#`-containing spelling names the discovered file of that +// invalid path (14.19, 11.4), never a `path#id` pair. +// +// One workspace serves both halves: valid `specs/OK.mdx` (the move origin +// and valid-side contrast) beside `specs/a#b.mdx` — the entry's literal +// name, its content deliberately condition-free (well-formed unique id `pa`, +// multi-byte prose prefix shifting every later byte offset, SPEC 1.7) so the +// staging premise `build --json` reports EXACTLY one 14.19 and every later +// observation is attributable to the path alone. The workspace failing +// `build` is itself load-bearing twice over: the malformed-value exit 2 must +// precede the gated report (12.0 — argument checks precede the invalid- +// workspace report), and a product that instead splits `specs/a#b.mdx#pa` +// at the last `#` finds a discovered file whose spelled identities include +// `pa`, passes its parse-local argument check, and answers the gated report +// exit 1 — the sharpest observable divergence from the required exit 2. +// The `--file` control `specs/zz#*` (a `#`-containing pattern matching +// nothing) pins the other side: the empty admitted set is an empty, +// finding-free answer, exit 0 (11.3), so the exit-1-with-14.19 answer on +// `specs/a#*` is attributable to the pattern MATCHING the invalid path. + +/** + * Running byte-offset fixture assembler (the T5.7-2/T1.7-2 discipline; + * the module-local class of section-11.2/-11.4/-11.5): `add` appends a + * segment and returns its byte range, `attr` an attribute segment as the + * expected `{name, range, text}` view entry (SPEC 11.4). Every expected + * offset is composed from the same parts the staged file is. + */ +class ByteFixture { + private readonly parts: string[] = []; + private bytes = 0; + + get pos(): number { + return this.bytes; + } + + get source(): string { + return this.parts.join(""); + } + + add(segment: string): SourceRange { + const start = this.bytes; + this.parts.push(segment); + this.bytes += Buffer.byteLength(segment, "utf8"); + return { start, end: this.bytes }; + } + + attr(name: string, text: string): ViewAttributeEntry { + return { name, range: this.add(text), text }; + } +} + +/** The 12.7 unavailability marker, as decoded (one-datum state). */ +const UNAVAILABLE = { unavailable: true } as const; + +/** Fixture self-check (T5.7-2 discipline): a claimed range slices the staged bytes to exactly `expected` — before the product is ever invoked. */ +function sliceCheck( + source: string, + range: SourceRange, + expected: string, + what: string, +): void { + const actual = Buffer.from(source, "utf8") + .subarray(range.start, range.end) + .toString("utf8"); + if (actual !== expected) { + throw new Error( + `section-12.0-ii fixture self-check: ${what} — expected the range ` + + `[${String(range.start)}, ${String(range.end)}) to slice to ` + + `${JSON.stringify(expected)}, got ${JSON.stringify(actual)}; the ` + + `staging arithmetic is wrong (harness defect, not a product result)`, + ); + } +} + +// --- specs/OK.mdx — valid path: the move origin and valid-side contrast ----- +const H13_OK_FILE = "specs/OK.mdx"; +const H13_OK_SOURCE = ['<S id="ok">', "Anchor text.", "</S>", ""].join("\n"); + +// --- specs/a#b.mdx — the `#`-containing discovered spec source (14.19) ------ +// The path is the file's ONLY defect: `pa` is well-formed, unique, and +// structurally valid, so the premise `build` reports exactly one 14.19. The +// section deliberately spells `pa` so the multi-`#` operand +// `specs/a#b.mdx#pa` below is a last-`#`-split trap: both split halves name +// real staged things, and only rejecting the value gives exit 2. +const H13_FILE = "specs/a#b.mdx"; +const H13 = new ByteFixture(); +H13.add("Ancré — préfixe multi-octets.\n\n"); +const H13_PA_START = H13.pos; +H13.add("<S "); +const H13_PA_ID = H13.attr("id", 'id="pa"'); +H13.add(">\nHash-path text.\n</S>"); +const H13_PA_RANGE: SourceRange = { start: H13_PA_START, end: H13.pos }; +H13.add("\n"); +const H13_SOURCE = H13.source; +const H13_ROOT_RANGE: SourceRange = { start: 0, end: H13.pos }; + +/** + * The asserted projection of the 14.19 finding (SPEC 14, 12.7): the stable + * code token, the empty locations of a path-level condition, and the + * concerned path. Message and identities stay unpinned (informational). + */ +interface PathFindingExpectation { + readonly code: string | null; + readonly locations: readonly unknown[]; + readonly path: PathValue | null; +} + +function projectPathFinding(finding: Finding): PathFindingExpectation { + return { + code: finding.code, + locations: finding.locations, + path: finding.path, + }; +} + +const H13_19: PathFindingExpectation = { + code: "invalid-source-path", + locations: [], + path: H13_FILE, +}; + +/** + * One malformed multi-`#` operand invocation (SPEC 12.0): run with `--json`, + * assert exit 2 exactly — reported whatever findings the workspace carries + * (the argument checks precede the gated report and source validation, + * 12.0) — the single 12.7 error document as the entire stdout (no report, no + * validation findings; H-5), and a usage error message on stderr (presence, + * not wording — H-3). + */ +async function expectMalformedOperandError( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + context: string, +): Promise<void> { + const rendered = ["xspec", ...argv, "--json"].join(" "); + const result = await runCli(product, workspace, [...argv, "--json"]); + assertExitCode( + result, + 2, + `${context}: \`${rendered}\` — a value containing more than one \`#\` ` + + `is a malformed value, a usage error: exit 2, whatever findings the ` + + `workspace carries (SPEC 12.0)`, + ); + expectErrorDocument( + result, + `${context}: \`${rendered}\` — with JSON output in effect, the exit-2 ` + + `error document is the entire stdout: the malformed value emits no ` + + `report and no validation findings (SPEC 12.0, 12.7, H-5)`, + ); + if (result.stderrBytes.length === 0) { + fail( + `${context}: \`${rendered}\` — usage error messages are ` + + `standard-error content (SPEC 12.0), but stderr is empty`, + ); + } +} + +/** The malformed spellings: the entry's literal, and the last-`#`-split trap. */ +const H13_MULTI_HASH_VALUES: readonly { value: string; trap: string }[] = [ + { + value: "a#b#c", + trap: "the entry's literal spelling — no staged interpretation", + }, + { + value: `${H13_FILE}#pa`, + trap: + "the last-`#` split names the discovered file specs/a#b.mdx plus its " + + "spelled id `pa`, so an accepting product proceeds and answers exit 1 " + + "on this failing workspace", + }, +]; + +/** + * The tree projection the view arm pins (T11.2-1's named clauses): per node, + * the identity datum (the 11.2 three-state), the construct range (1.7), the + * raw attribute entries as parsed, and the children in document order. The + * opening/closing decompositions and interpreted tags/coverage stay outside + * (T11.4-1, T11.2-2/T11.4-3 pin those); the form-exact decode has already + * validated their forms. + */ +interface ViewTreeExpectation { + readonly identity: string | { readonly unavailable: true }; + readonly range: SourceRange; + readonly attributes: readonly ViewAttributeEntry[]; + readonly children: readonly ViewTreeExpectation[]; +} + +function projectViewNode(node: ViewNode): ViewTreeExpectation { + return { + identity: node.identity, + range: node.range, + attributes: node.attributes.map((entry) => ({ + name: entry.name, + range: entry.range, + text: entry.text, + })), + children: node.children.map(projectViewNode), + }; +} + +const T12_0_13 = defineProductTest({ + id: "T12.0-13", + title: + "`#` in operands: a `<node>`, `<graph-node>`, `--to`, or move-operand value containing more than one `#` (the literal `a#b#c`, and `specs/a#b.mdx#pa` — whose last-`#` split would name a discovered file plus a spelled id) is a malformed value — exit 2 with the single 12.7 error document on `show`, `query node`, `occurrences --to`, and `move` (origin and destination operands alike, the destination the T6.5-4 dead-letter spelling — `#` in the section form's target-file part; each move wrapped in a whole-root modifies-nothing compare), the usage error preceding the failing workspace's findings; a bare `<file>` operand or `--file` glob is a whole path or pattern with no delimiter role for `#`: with `specs/a#b.mdx` discovered (condition 19 — the staging premise `build --json` fails with exactly that one pinned 14.19, modifying nothing), `view specs/a#b.mdx` names the discovered file — membership holds: exactly its one per-file view, tree and ranges on view with every node identity explicitly unavailable, its condition-19 finding accompanying, exit 1 — never a `specs/a` + `b.mdx` pair (which would be exit 2, unknown file); `at specs/a#b.mdx 0` resolves the same way (the root construct, identity unavailable, no containing occurrence); and `occurrences --file specs/a#*` matches it as a pattern — domain membership proven by the accompanying 14.19, exit 1, against the matching-nothing control `specs/zz#*` (empty, finding-free, exit 0) (SPEC 12.0, 11.2-11.5, 12.7, 14)", + run: async (product) => { + // Fixture self-checks (T5.7-2 discipline): composed ranges sliced back + // out of the staged bytes before any product invocation. + sliceCheck( + H13_SOURCE, + H13_PA_RANGE, + '<S id="pa">\nHash-path text.\n</S>', + "the pa section construct", + ); + sliceCheck( + H13_SOURCE, + H13_PA_ID.range, + H13_PA_ID.text, + "pa's id attribute", + ); + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [H13_OK_FILE]: H13_OK_SOURCE, + [H13_FILE]: H13_SOURCE, + }, + }); + try { + // --- Staging premise: `build --json` fails with EXACTLY one 14.19 — + // the content of both files stages no other condition, so the path is + // the sole defect — the finding pinned (stable code, no in-source + // locations, the file as concerned path; SPEC 14, 12.7), and a + // failing build modifies nothing (SPEC 12.1). + const buildContext = + "T12.0-13 `build --json` (staging premise: the `#` path is the " + + "workspace's one defect)"; + await assertLeavesUnchanged( + workspace.root, + async () => { + const result = await expectExit( + product, + workspace, + ["build", "--json"], + 1, + buildContext, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, buildContext), + buildContext, + ).findings; + assertConditionCounts( + findings, + { "14.19": 1 }, + `${buildContext} — exactly one condition-19 finding for the ` + + `discovered \`#\` path and nothing else: both files' content ` + + `is condition-free (SPEC 14.19)`, + ); + assertSameJson( + findings.map(projectPathFinding), + [H13_19], + `${buildContext} — the finding carries the stable code ` + + `"invalid-source-path", no in-source locations (a path-level ` + + `condition), and the offending file as its concerned path ` + + `(SPEC 14, 12.7)`, + ); + }, + `${buildContext} — a failing build modifies nothing (SPEC 12.1)`, + ); + + // --- Malformed multi-`#` values: exit 2 on `show`, `query node`, and + // `occurrences --to` (SPEC 12.0; 11.3 pins the `--to` bound — a lax + // product reading the spelling as well-formed selects the empty set + // and answers exit 1 with the domain's findings, never 2). + for (const spelling of H13_MULTI_HASH_VALUES) { + const rows: readonly { argv: readonly string[]; what: string }[] = [ + { + argv: ["show", spelling.value], + what: "`show <node>`", + }, + { + argv: ["query", "node", spelling.value], + what: "`query node <node>`", + }, + { + argv: ["occurrences", "--to", spelling.value], + what: "`occurrences --to <node>`", + }, + ]; + for (const row of rows) { + await expectMalformedOperandError( + product, + workspace, + row.argv, + `T12.0-13 ${row.what}, value ${JSON.stringify(spelling.value)} ` + + `(${spelling.trap})`, + ); + } + } + + // --- Malformed multi-`#` move operands (SPEC 12.0, 6.5): the + // destination arm is T6.5-4's dead letter realized — a `#` in the + // section form's target-file part makes a two-`#` operand — and an + // accepting product's last-`#` split names the discovered + // specs/a#b.mdx as target file (or as origin), proceeds, and answers + // exit 1 (the invalid-workspace refusal) or worse, writes; each arm + // rides a whole-root modifies-nothing compare. + const moveRows: readonly { + readonly argv: readonly string[]; + readonly what: string; + }[] = [ + { + argv: ["move", `${H13_OK_FILE}#ok`, `${H13_FILE}#pa`], + what: + "destination operand with two `#` (the T6.5-4 dead-letter " + + "spelling: `#` in the section form's target-file part)", + }, + { + argv: [`move`, `${H13_FILE}#pa`, `${H13_OK_FILE}#zz`], + what: "origin operand with two `#`", + }, + ]; + for (const row of moveRows) { + const context = `T12.0-13 \`move\`, ${row.what}`; + await assertLeavesUnchanged( + workspace.root, + async () => { + await expectMalformedOperandError( + product, + workspace, + row.argv, + context, + ); + }, + `${context} — a usage error modifies nothing (SPEC 6.5, 12.0)`, + ); + } + + // --- `view specs/a#b.mdx`: a bare `<file>` operand is a whole path — + // the `#`-containing spelling names the DISCOVERED file, so + // membership holds (never a `specs/a` + `b.mdx` pair, which would be + // exit 2, unknown file): exactly its one per-file view is served, + // structure on view, every node identity explicitly unavailable, its + // condition-19 finding accompanying, exit 1 (SPEC 12.0, 11.4, 11.2). + const viewContext = `T12.0-13 \`view ${H13_FILE}\``; + const viewResult = await runCli(product, workspace, ["view", H13_FILE]); + assertExitCode( + viewResult, + 1, + `${viewContext} — the \`#\`-containing operand names the ` + + `discovered file (membership holds, never an unknown-file exit ` + + `2), and the answer carries its finding and unavailable ` + + `identities: exit 1 with the full document (SPEC 12.0, 11.4, 11.2)`, + ); + const viewReport = decodeViewReport( + parseJsonStdout( + viewResult, + `${viewContext} — a single JSON document is the only output ` + + `form, with or without --json (SPEC 11)`, + ), + { text: false }, + viewContext, + ); + assertSameJson( + viewReport.findings.map(projectPathFinding), + [H13_19], + `${viewContext} — the consulted domain is the requested file ` + + `alone: exactly its condition-19 finding accompanies (SPEC 11.2, ` + + `11.4)`, + ); + assertSameJson( + viewReport.views.map((view) => view.file), + [H13_FILE], + `${viewContext} — exactly one per-file view, for the requested ` + + `\`#\` path presented as the whole workspace-relative path ` + + `(SPEC 11.4, 12.0)`, + ); + const h13View = viewReport.views[0]!; + assertSameJson( + projectViewNode(h13View.root), + { + identity: UNAVAILABLE, + range: H13_ROOT_RANGE, + attributes: [], + children: [ + { + identity: UNAVAILABLE, + range: H13_PA_RANGE, + attributes: [H13_PA_ID], + children: [], + }, + ], + }, + `${viewContext} — the invalid-path file keeps its full positional ` + + `tree with byte-exact construct ranges and raw attribute entries ` + + `while every node identity, root included, is explicitly ` + + `unavailable (SPEC 11.2, 1.5)`, + ); + assertSameJson( + [h13View.imports, h13View.occurrences, h13View.comments], + [[], [], []], + `${viewContext} — the file holds no imports, occurrences, or ` + + `comments: empty arrays, never null (SPEC 12.7)`, + ); + + // --- `at specs/a#b.mdx 0` resolves the same way (SPEC 11.5): the + // operand names the discovered file; offset 0 lies in the prose + // before any section, so the innermost enclosing construct is the + // ROOT, its identity explicitly unavailable; no containing + // occurrence; exactly the file's own finding; exit 1. + const atContext = `T12.0-13 \`at ${H13_FILE} 0\``; + const atResult = await runCli(product, workspace, ["at", H13_FILE, "0"]); + assertExitCode( + atResult, + 1, + `${atContext} — the \`<file>\` operand asserts membership exactly ` + + `as a view operand does; the answer carries the file's finding ` + + `and an unavailable identity: exit 1 (SPEC 11.5, 11.2, 12.0)`, + ); + const atReport = decodeAtReport( + parseJsonStdout( + atResult, + `${atContext} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + atContext, + ); + assertSameJson( + atReport.findings.map(projectPathFinding), + [H13_19], + `${atContext} — the consulted domain is the named file alone: ` + + `exactly its condition-19 finding (SPEC 11.2, 11.5)`, + ); + assertSameJson( + atReport.resolution, + { + section: { identity: UNAVAILABLE, range: H13_ROOT_RANGE }, + occurrence: null, + }, + `${atContext} — offset 0 (prose) resolves to the root construct, ` + + `its identity explicitly unavailable, within no occurrence ` + + `(SPEC 11.5, 11.2)`, + ); + + // --- `occurrences --file specs/a#*` matches the file as a PATTERN + // (SPEC 12.0, 11.3, 7): `#` is a literal glob byte, `*` any run of + // bytes within the segment, so the admitted set is {specs/a#b.mdx} — + // proven by the accompanying condition-19 finding (a finding is a + // domain file's exactly when that file is its concerned path, 11.2) — + // while the control pattern admits the empty set: an empty, + // finding-free answer, exit 0 (11.3), pinning that the exit-1 answer + // is attributable to the pattern MATCHING the `#` path. + const occContext = `T12.0-13 \`occurrences --file specs/a#*\``; + const occResult = await runCli(product, workspace, [ + "occurrences", + "--file", + "specs/a#*", + ]); + assertExitCode( + occResult, + 1, + `${occContext} — the pattern matches the discovered \`#\` path ` + + `(no delimiter role in a --file glob), whose finding accompanies ` + + `the answer: exit 1 (SPEC 12.0, 11.3, 11.2)`, + ); + const occReport = decodeOccurrencesReport( + parseJsonStdout( + occResult, + `${occContext} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + occContext, + ); + assertSameJson( + occReport.findings.map(projectPathFinding), + [H13_19], + `${occContext} — the admitted set is exactly {${H13_FILE}}: its ` + + `condition-19 finding accompanies, and no other file's finding ` + + `can (SPEC 11.2, 11.3)`, + ); + assertSameJson( + occReport.occurrences, + [], + `${occContext} — the file spells no references: an empty ` + + `enumeration, [] never null (SPEC 11.3, 12.7)`, + ); + const ctrlContext = `T12.0-13 \`occurrences --file specs/zz#*\` (control)`; + const ctrlResult = await runCli(product, workspace, [ + "occurrences", + "--file", + "specs/zz#*", + ]); + assertExitCode( + ctrlResult, + 0, + `${ctrlContext} — a \`#\`-containing pattern matching nothing ` + + `admits the empty set: an empty, finding-free answer, exit 0 — ` + + `never an unknown-file usage error (SPEC 11.3)`, + ); + assertSameJson( + decodeOccurrencesReport( + parseJsonStdout( + ctrlResult, + `${ctrlContext} — a single JSON document is the only output ` + + `form (SPEC 11)`, + ), + ctrlContext, + ), + { findings: [], occurrences: [] }, + `${ctrlContext} — empty and finding-free: the empty admitted set ` + + `consults no file (SPEC 11.3, 11.2)`, + ); + } finally { + await workspace.dispose(); + } + }, +}); + export const section120iiTests: readonly ProductTestEntry[] = [ T12_0_7, T12_0_8, @@ -2423,4 +2977,5 @@ export const section120iiTests: readonly ProductTestEntry[] = [ T12_0_10, T12_0_11, T12_0_12, + T12_0_13, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 19d59e7..06b3f7b 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -342,6 +342,11 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T12.0-10": ["12.0"], "T12.0-11": ["preamble", "12.0"], "T12.0-12": ["preamble", "12.0"], + // T12.0-13: the FP-016 precedent — in no TEST-SPEC 14 staging record + // (its premise-pinned 14.19 rides staging integrity, the T11.2-3 + // precedent), so no "14"; 11.2-11.5/12.7/6.5 are carriage context with + // home coverage at T11.2-3/T11.3-*/T11.4-*/T11.5-*/T12.7-*/T6.5-*. + "T12.0-13": ["12.0"], "T12.1-1": ["12.1"], "T12.1-3": ["12.1"], "T12.1-4": ["12.1"], From 726d719d1862958fe991c4eae76777fabece3292 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 23:28:11 +0000 Subject: [PATCH 119/226] sdg(phase-9): implement T12.6-1/-2, the version command's surface and independence (FP-074) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 45 ++- test/helpers/adapters/forms.ts | 32 ++ test/helpers/adapters/model.ts | 12 + test/self/s5-output-adapters.test.ts | 58 ++++ test/suite/registry/index.ts | 2 + test/suite/registry/section-12.6.ts | 417 +++++++++++++++++++++++++++ test/suite/registry/traceability.ts | 2 + test/suite/section-12.6.test.ts | 9 + 8 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 test/suite/registry/section-12.6.ts create mode 100644 test/suite/section-12.6.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 17baf35..e1d5520 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3266,11 +3266,54 @@ certify against FP-091's fixtures once those land. stage G), 264 passed, certification green (violators failing as certified).] -- [ ] FP-074 — Implement T12.6-1 and T12.6-2: the `version` command. +- [x] FP-074 — Implement T12.6-1 and T12.6-2: the `version` command. [R2 #8; TEST-SPEC §12.6] Form-exact `{"product","interface"}` with `interface` exactly `"1"`; workspace/configuration independence with the `build`-exits-2 discriminating pair. New §12.6 registry module (may share a module/wrapper with §12.7 tests); map `"12.6"`. + [Done 2026-08-14: new registry module section-12.6.ts (SUITE-57) + thin + wrapper, spread into the manifest. The version document decodes through a + new form-exact decoder (forms.ts `decodeVersionDocument`, model.ts + `VersionDocument`): exactly {"product","interface"}, both strings — H-3 + fixes 12.6's document form — with S-5 DECODERS guards (positive control, + empty-informational-product alsoGood, missing-member/null-product/ + numeric-interface/extra-member/error-document-shape rejections). + T12.6-1: bare and flagged forms each exit 0 with a single JSON document + as the entire stdout, decoded form-exactly, `interface` pinned "1"; + values identical across the bare, flagged, and repeated runs ("fixed per + build" as product-to-itself value identity, H-4 — whole-document byte + determinism stays T12.0-7's); unknown flag → exit 2 with the 12.7 error + document as the entire stdout (JSON in effect on the JSON-only surface, + no --json given) and nonempty stderr (T12.0-2; code/path values stay + T12.7-3's). T12.6-2: the valid-workspace answer is the byte reference + (decoded once); byte-identical stdout at exit 0 in the + no-discoverable-configuration directory, with invalid configuration + present, and with --config naming a nonexistent and a malformed file; + the no-config context's premise pinned in-test (`build` there fails + 14.14 via expectConfigurationError — an ancestor config accidentally + reachable would silently weaken the context) and the discriminating pair + asserted on the invalid-config fixture (`build` exit 2, stable code + configuration-error, on the very fixture `version` answers from at exit + 0). Traceability: both → ["12.6"] (the plan's mapping; the premise/pair + 14.14 rides staging integrity, the FP-016/FP-073 precedent — T12.6-2 is + in no TEST-SPEC 14 per-condition record, the never-`version` + reporter-matrix clause living at T14-4). In CERTIFICATIONS.md's + Exclusions only, no fixture scope. Verified: typecheck/format clean; + both tests red-as-diagnosed against the built product at their first + exit-0 assertion (exit 2 "unknown command 'version'" — the whole 12.6 + surface is patch-new); soundness proven by a scratch conforming mock + running both registered bodies green via entry.run(mockBinding), with + eleven deviation mocks each failing at the intended assertion: + wrong-form and extra-member (the form-exact decode), wrong-interface + (the "1" pin), unstable-values (the fixedness compare), no-error-doc + (the error-document decode), quiet-stderr (the stderr assertion), + consults-config (the no-config context's exit), consults-dashdash-config + (the never-consulted --config arm), context-answer (the byte compare), + accepts-invalid (the discriminating pair), no-premise (the staging + premise). `npm run test:self`: 266 passed, planned mid-loop reds + narrowed to 3 — certification-document ×2 (→ FP-091) and S-1 unmapped + now exactly {12.7} (→ stage G: FP-075..077); certification green + (violators failing as certified).] - [ ] FP-075 — Implement T12.7-1: 12.7 value forms — range, byte-form paths, the `{"unavailable": true}` uniqueness walk, finding form. Uses diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts index 541160b..fc2b407 100644 --- a/test/helpers/adapters/forms.ts +++ b/test/helpers/adapters/forms.ts @@ -20,6 +20,7 @@ // - the pinned findings-order comparator and duplicate collapse (12.7) // - findings arrays and the findings-only report {"findings": […]} // - the exit-2 error document {"error": …} holding one finding form (12.0) +// - the version document {"product","interface"} (12.6) // - the three-state datum decode: plain value / `null` / // {"unavailable": true} (11.4, 12.7) // - the scoped inventory decodes: the `recorded` datum, the `findings` @@ -74,6 +75,7 @@ import type { PreviewFileEntry, PreviewReport, SourceRange, + VersionDocument, ViewAttributeEntry, ViewFilesReport, ViewImportEntry, @@ -490,6 +492,36 @@ export function decodeErrorDocument( }; } +// --- the version document (12.6, 12.7) ---------------------------------------- + +/** + * The `version` document — `{"product", "interface"}` exactly, both strings + * (SPEC 12.6, 12.7): the product version and the machine-interface version. + * 12.6 is a JSON-only surface, so this single document is `version`'s only + * output form, with or without `--json` (12.0). Form-exact (H-3): 12.7 fixes + * the document form of 12.6, no adapter in the path. Value contracts — the + * machine-interface value exactly `"1"` (the string form of 12.6's stated + * value) and per-build fixedness — stay with the caller (T12.6-1/2). + */ +export function decodeVersionDocument( + doc: unknown, + context?: string, +): VersionDocument { + const site = rootSite("12.7 version document", context); + const obj = expectObject(doc, site); + expectOnlyMembers(obj, ["product", "interface"], site); + return { + product: expectString( + requiredKey(obj, "product", site), + at(site, "product"), + ), + interface: expectString( + requiredKey(obj, "interface", site), + at(site, "interface"), + ), + }; +} + // --- the three-state datum decode (11.4, 12.7) -------------------------------- /** diff --git a/test/helpers/adapters/model.ts b/test/helpers/adapters/model.ts index dae5093..6779735 100644 --- a/test/helpers/adapters/model.ts +++ b/test/helpers/adapters/model.ts @@ -296,6 +296,18 @@ export interface ErrorDocument { readonly error: Finding; } +/** + * The `version` document — `{"product", "interface"}` exactly (SPEC.md 12.6, + * 12.7): the product version and the machine-interface version, both + * strings. The reported machine-interface value is the string form of 12.6's + * stated value, `"1"` — a caller value assertion (T12.6-1); the product + * version is informational, with no requirement beyond per-build fixedness. + */ +export interface VersionDocument { + readonly product: string; + readonly interface: string; +} + /** * An occurrence record's source graph node — one datum: the node's identity * together with that node's own source range (SPEC.md 5.7, 1.7, 12.7). diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index 0cb6ef3..e885720 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -64,6 +64,7 @@ import { decodeReachableReport, decodeSessionListReport, decodeSessionStatusReport, + decodeVersionDocument, decodeViewFilesReport, decodeViewReport, expectNonNegativeInteger, @@ -2641,6 +2642,63 @@ const DECODERS: readonly DecoderSpec[] = [ }, ], }, + { + // The version document (SPEC 12.6, 12.7): {"product", "interface"} + // exactly, both strings. Form-exact (H-3); value contracts — `interface` + // exactly "1", per-build fixedness — stay with T12.6-1/2, so the decoder + // admits any string values (the empty informational `product` included: + // 12.6 places no requirement on it beyond per-build fixedness). + name: "12.7 version document", + decode: decodeVersionDocument, + good: { product: "xspec 1.2.3", interface: "1" }, + verify: (decoded: ReturnType<typeof decodeVersionDocument>) => { + expect(decoded.product).toBe("xspec 1.2.3"); + expect(decoded.interface).toBe("1"); + }, + alsoGood: [ + { + label: + "an empty informational product version (12.6: no requirement " + + "beyond per-build fixedness) — the value contract on `interface` " + + "is the caller's", + doc: { product: "", interface: "2" }, + verify: (decoded: ReturnType<typeof decodeVersionDocument>): void => { + expect(decoded.product).toBe(""); + expect(decoded.interface).toBe("2"); + }, + }, + ], + bad: [ + { label: "missing product member", doc: { interface: "1" } }, + { label: "missing interface member", doc: { product: "xspec 1.2.3" } }, + { + label: "null product (the form carries two strings, 12.7)", + doc: { product: null, interface: "1" }, + }, + { + label: + "numeric interface (the string form of 12.6's stated value, " + + "never the number)", + doc: { product: "xspec 1.2.3", interface: 1 }, + }, + { + label: 'an extra member (12.7: exactly {"product", "interface"})', + doc: { product: "xspec 1.2.3", interface: "1", commit: "abc123" }, + }, + { + label: "the error document passed off as the version document", + doc: { + error: { + code: null, + message: "unknown flag", + locations: [], + path: null, + identities: [], + }, + }, + }, + ], + }, { // The scoped inventory decode (SPEC 11.6, 12.7): exactly the `recorded` // member as a three-state datum — a plain list of path values, `null`, diff --git a/test/suite/registry/index.ts b/test/suite/registry/index.ts index 708c34b..e45d2ac 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -65,6 +65,7 @@ import { section120iTests } from "./section-12.0-i.js"; import { section120iiTests } from "./section-12.0-ii.js"; import { section121to122Tests } from "./section-12.1-12.2.js"; import { section123to125Tests } from "./section-12.3-12.5.js"; +import { section126Tests } from "./section-12.6.js"; import { section131to132Tests } from "./section-13.1-13.2.js"; import { section133Tests } from "./section-13.3.js"; import { section134Tests } from "./section-13.4.js"; @@ -134,6 +135,7 @@ export const productTestSuite = new ProductTestSuite([ ...section120iiTests, ...section121to122Tests, ...section123to125Tests, + ...section126Tests, ...section131to132Tests, ...section133Tests, ...section134Tests, diff --git a/test/suite/registry/section-12.6.ts b/test/suite/registry/section-12.6.ts new file mode 100644 index 0000000..ec1e440 --- /dev/null +++ b/test/suite/registry/section-12.6.ts @@ -0,0 +1,417 @@ +// TEST-SPEC §12.6 (`xspec version`) — SUITE-57: T12.6-1, T12.6-2. +// +// Registered product-facing bodies (C-2 "one code path"): each builds its own +// fresh workspace (H-1), drives the product strictly as a subprocess (H-2), +// asserts exact exit codes and stream separation (H-5), and rejects a product +// only via diagnosed assertion failures (H-8). +// +// SPEC 12.6: `version` reports the product version and the machine-interface +// version. The surface is JSON-only — a single JSON document, in the form of +// 12.7, is its only output form, with or without `--json` (12.0). Both values +// are fixed per build; the machine-interface version is `1`, reported exactly +// as the string `"1"` (12.7 pins the document form `{"product", +// "interface"}`, both strings). `version` is workspace-independent: it +// consults no workspace and no configuration — `--config` is accepted (12.0) +// and not consulted — answers identically in any working directory, no +// discoverable workspace, missing configuration, and invalid configuration +// included, and cannot fail for workspace or configuration reasons: +// configuration-error precedence (14.14) does not reach it. Usage errors +// keep exit 2 (12.0). +// +// Conservative operationalizations (noted per H-3/H-4/H-5): +// - The document is decoded through the form-exact 12.7 decoder +// (helpers/adapters/forms.ts `decodeVersionDocument`): exactly the members +// `{"product", "interface"}`, both strings — 12.7 fixes the document form +// of 12.6, so no adapter may re-map it (H-3); `interface` exactly `"1"` is +// T12.6-1's value assertion. +// - "Fixed per build" is asserted as value identity across repeated +// invocations of the one build under test (H-4, product-to-itself): the +// decoded `product` and `interface` values — not whole-document bytes, +// which T12.0-7's determinism sweep owns — are identical across the bare, +// flagged, and repeated runs. Fixedness across *different* builds is +// unobservable to a single product binding and is not asserted. +// - T12.6-1's unknown-flag arm runs WITHOUT `--json`: 12.6 is a JSON-only +// surface, so JSON output is in effect for the erroneous invocation +// (12.0), the 12.7 error document is the entire stdout, and the usage +// diagnostic is standard-error content (T12.0-2) — discriminating against +// a product that reports the error as bare stderr text with empty stdout. +// Error-finding values (`code`/`path` null for a plain usage error) are +// T12.7-3's assertions, not repeated here. +// - T12.6-2's byte-identity: the valid-workspace answer is the reference — +// asserted once to be a single JSON document in the version form — and +// every other context's entire stdout must be byte-identical to it (H-4, +// product-to-itself), so a context-dependent answer fails at the byte +// compare and a context-dependent refusal fails at the exit assertion. +// - T12.6-2's no-configuration context pins its staging premise in-test: +// `build` in that directory must fail as a 14.14 configuration error +// (T7-1's contract) — otherwise a configuration file accidentally +// reachable by upward search (H-1 makes the temporary root's ancestors +// hold none) would silently weaken the context into a configured one. +// - T12.6-2's discriminating pair: the invalid-configuration fixture is +// proven genuinely invalid by `expectConfigurationError` on `build` (exit +// 2, stable code `configuration-error`; the shared 14.14 protocol) — the +// very fixture `version` must answer from at exit 0, so a product routing +// configuration-error precedence through `version` fails its exit +// assertion against a fixture whose invalidity is asserted, not assumed. + +import { decodeVersionDocument } from "../../helpers/adapters/index.js"; +import { + assertBytesEqual, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import type { WorkspaceDecl } from "../../helpers/workspace.js"; +import { + assertSameJson, + expectConfigurationError, + expectErrorDocument, + expectExit, +} from "./support.js"; + +// --------------------------------------------------------------------------- +// Shared fixture material +// --------------------------------------------------------------------------- + +// The canonical valid configuration (SPEC 7): exactly one spec group. +const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`; + +// The invalid configuration: SPECS_ONLY_CONFIG with exactly one deviation — +// an unknown top-level key (14.14; the T7-2 single-deviation discipline), so +// `build`'s refusal is attributable to the configuration alone while the +// staged source stays valid. +const INVALID_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + bogus: true +}) +`; + +// A malformed `--config` target: not well-formed TypeScript, so any product +// that consults the named file at all fails on it (14.14) — `version` must +// accept the flag and never consult the file (SPEC 12.6, 12.0). +const MALFORMED_CONFIG_TARGET = "this is ( not TypeScript {{{\n"; + +/** A minimal single-section source: one node `a` under the file root. */ +const VALID_SOURCE = '<S id="a">\nText for a.\n</S>\n'; + +/** Stage a fresh workspace, run `body`, dispose (H-1). */ +async function withWorkspace<T>( + decl: WorkspaceDecl, + body: (workspace: TestWorkspace) => Promise<T>, +): Promise<T> { + const workspace = await TestWorkspace.create(decl); + try { + return await body(workspace); + } finally { + await workspace.dispose(); + } +} + +/** + * Run `version` expecting the JSON-only answer: exit 0 exactly (a success + * report, SPEC 12.0) with a single JSON document as the entire stdout — the + * surface's only output form, with or without `--json` (SPEC 12.6, H-5). + * Returns the raw result for byte comparison; decoding stays with callers. + */ +async function expectVersionAnswer( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + context: string, +): Promise<RunResult> { + const result = await expectExit( + product, + workspace, + argv, + 0, + `${context} — \`version\` is an informational report, exit 0; it cannot ` + + `fail for workspace or configuration reasons (SPEC 12.6, 12.0)`, + ); + parseJsonStdout( + result, + `${context} — 12.6 is a JSON-only surface: a single JSON document is ` + + `its entire standard output, with or without --json (SPEC 12.6, ` + + `12.0, H-5)`, + ); + return result; +} + +// --------------------------------------------------------------------------- +// T12.6-1 — surface and values +// --------------------------------------------------------------------------- + +const T12_6_1 = defineProductTest({ + id: "T12.6-1", + title: + "surface and values: `version` emits, with and without `--json`, a " + + "single JSON document as its entire stdout in the literal 12.7 form — " + + '{"product", "interface"} exactly, both strings, `interface` exactly ' + + '"1" (form-exact, H-3) — with both values identical across invocations ' + + "of one build (fixed per build); usage errors keep exit 2: an unknown " + + "flag on `version` yields the 12.7 error document as the entire stdout " + + "with a standard-error diagnostic (SPEC 12.6, 12.7, 12.0)", + run: async (product) => { + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/A.mdx": VALID_SOURCE, + }, + }, + async (workspace) => { + // Bare form: the single JSON document is the surface's only output + // form (SPEC 12.6), decoded form-exactly (H-3). + const bareContext = "T12.6-1 `version`"; + const bare = await expectVersionAnswer( + product, + workspace, + ["version"], + bareContext, + ); + const bareDoc = decodeVersionDocument( + parseJsonStdout(bare, bareContext), + bareContext, + ); + if (bareDoc.interface !== "1") { + fail( + `${bareContext}: the machine-interface version is 1, reported ` + + `exactly as the string "1" — the string form of 12.6's stated ` + + `value (SPEC 12.6, 12.7); got ` + + `${JSON.stringify(bareDoc.interface)}`, + ); + } + + // Flagged form: `--json` is accepted and inert on a JSON-only + // surface — the same document form at the same exit code (SPEC + // 12.6, 12.0; the byte-level flag-parity compare is T12.0-1's). + const flaggedContext = "T12.6-1 `version --json`"; + const flagged = await expectVersionAnswer( + product, + workspace, + ["version", "--json"], + flaggedContext, + ); + const flaggedDoc = decodeVersionDocument( + parseJsonStdout(flagged, flaggedContext), + flaggedContext, + ); + + // Repeat invocation of the same build: both values are fixed per + // build, so every invocation reports the identical values (SPEC + // 12.6; H-4, product-to-itself). + const repeatContext = "T12.6-1 `version` (repeat invocation)"; + const repeat = await expectVersionAnswer( + product, + workspace, + ["version"], + repeatContext, + ); + const repeatDoc = decodeVersionDocument( + parseJsonStdout(repeat, repeatContext), + repeatContext, + ); + + assertSameJson( + flaggedDoc, + bareDoc, + "T12.6-1: the product and machine-interface values with `--json` " + + "vs without — both values are fixed per build, identical " + + "across invocations of one build (SPEC 12.6; H-4, " + + "product-to-itself)", + ); + assertSameJson( + repeatDoc, + bareDoc, + "T12.6-1: the product and machine-interface values across " + + "repeated invocations — both values are fixed per build " + + "(SPEC 12.6; H-4, product-to-itself)", + ); + + // Unknown flag: usage errors keep exit 2 (SPEC 12.6, 12.0). JSON + // output is in effect — 12.6 is a JSON-only surface, no `--json` + // needed — so the exit-2 invocation emits the 12.7 error document + // as its entire stdout, the diagnostic riding stderr (T12.0-2; + // error-finding values are T12.7-3's assertions). + const errorContext = "T12.6-1 `version --definitely-not-a-flag`"; + const errored = await expectExit( + product, + workspace, + ["version", "--definitely-not-a-flag"], + 2, + `${errorContext} — an unknown flag is a usage error, exit 2 ` + + `(SPEC 12.6, 12.0)`, + ); + expectErrorDocument( + errored, + `${errorContext} — 12.6 is a JSON-only surface, so JSON output ` + + `is in effect for the erroneous invocation and the 12.7 error ` + + `document is the entire stdout (SPEC 12.0, 12.7, T12.0-2)`, + ); + if (errored.stderrBytes.length === 0) { + fail( + `${errorContext}: usage error messages are standard-error ` + + `content (SPEC 12.0, T12.0-2), but stderr is empty`, + ); + } + }, + ); + }, +}); + +// --------------------------------------------------------------------------- +// T12.6-2 — workspace independence +// --------------------------------------------------------------------------- + +const T12_6_2 = defineProductTest({ + id: "T12.6-2", + title: + "workspace independence: byte-identical answers at exit 0 inside a " + + "valid workspace, in a directory with no discoverable configuration " + + "(where `build` exits 2, T7-1), with invalid configuration present, and " + + "with `--config` naming a nonexistent and a malformed file — accepted, " + + "never consulted; configuration-error precedence never reaches " + + "`version`: the same invalid-configuration fixture makes `build` exit " + + "2, the discriminating pair (SPEC 12.6, 14.14, 12.0; H-4 " + + "product-to-itself)", + run: async (product) => { + // Context 1 — inside a valid workspace: the reference answer, asserted + // once to be a single JSON document in the version form; every other + // context's entire stdout must be byte-identical to these bytes (H-4). + const referenceContext = "T12.6-2 `version` inside a valid workspace"; + const reference = await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/A.mdx": VALID_SOURCE, + }, + }, + async (workspace) => { + const result = await expectVersionAnswer( + product, + workspace, + ["version"], + referenceContext, + ); + // Form sanity on the reference only — the byte compares below carry + // it to every other context; value pins ("1", fixedness) are + // T12.6-1's. + decodeVersionDocument( + parseJsonStdout(result, referenceContext), + referenceContext, + ); + return result; + }, + ); + + const expectAnswerBytes = async ( + workspace: TestWorkspace, + argv: readonly string[], + context: string, + ): Promise<void> => { + const result = await expectVersionAnswer( + product, + workspace, + argv, + context, + ); + assertBytesEqual( + result.stdoutBytes, + reference.stdoutBytes, + `${context} — \`version\` answers identically in any working ` + + `directory: no discoverable workspace, missing configuration, ` + + `and invalid configuration included; byte-identical to the ` + + `valid-workspace answer (SPEC 12.6; H-4, product-to-itself)`, + ); + }; + + // Contexts 2, 4, 5 — a directory with no discoverable configuration + // (T7-1: the fresh temporary root's ancestors hold no xspec.config.ts), + // also hosting the two `--config` targets: a nonexistent path and a + // malformed file, each accepted and never consulted (SPEC 12.6, 12.0). + await withWorkspace( + { files: { "malformed-config.ts": MALFORMED_CONFIG_TARGET } }, + async (workspace) => { + // Staging premise, pinned in-test: no configuration is reachable + // here — the other commands exit 2 as a 14.14 configuration error + // (T7-1). A configuration file accidentally reachable by upward + // search would otherwise silently weaken this context. + await expectConfigurationError( + product, + workspace, + ["build"], + "T12.6-2 `build --json` in the no-configuration directory — the " + + "context's staging premise: no xspec.config.ts is reachable by " + + "upward search, so the other commands exit 2 there (SPEC 14.14, " + + "7, T7-1)", + ); + + await expectAnswerBytes( + workspace, + ["version"], + "T12.6-2 `version` in a directory with no discoverable " + + "configuration", + ); + await expectAnswerBytes( + workspace, + ["version", "--config", "missing/xspec.config.ts"], + "T12.6-2 `version --config missing/xspec.config.ts` (a " + + "nonexistent file — accepted, never consulted: a product " + + "consulting it would fail to read it, SPEC 12.6, 12.0)", + ); + await expectAnswerBytes( + workspace, + ["version", "--config", "malformed-config.ts"], + "T12.6-2 `version --config malformed-config.ts` (a malformed " + + "file — accepted, never consulted: a product consulting it " + + "would refuse it as 14.14, SPEC 12.6, 12.0)", + ); + }, + ); + + // Context 3 — invalid configuration present, plus the discriminating + // pair: `build` exits 2 as a configuration error on the very fixture + // `version` must answer from — configuration-error precedence (14.14) + // never reaches `version` (SPEC 12.6). + await withWorkspace( + { + files: { + "xspec.config.ts": INVALID_CONFIG, + "specs/A.mdx": VALID_SOURCE, + }, + }, + async (workspace) => { + await expectAnswerBytes( + workspace, + ["version"], + "T12.6-2 `version` with invalid configuration present", + ); + await expectConfigurationError( + product, + workspace, + ["build"], + "T12.6-2 `build --json` on the same invalid-configuration " + + "fixture — the discriminating pair: the configuration is " + + "genuinely invalid (14.14, exit 2) on the very fixture " + + "`version` answers from at exit 0 (SPEC 12.6, 14.14)", + ); + }, + ); + }, +}); + +/** TEST-SPEC §12.6, in canonical ID order (SUITE-57). */ +export const section126Tests: readonly ProductTestEntry[] = [T12_6_1, T12_6_2]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 06b3f7b..a41fe55 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -357,6 +357,8 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T12.3-2": ["12.3"], "T12.4-1": ["12.4"], "T12.5-1": ["12.5"], + "T12.6-1": ["12.6"], + "T12.6-2": ["12.6"], "T13.1-1": ["13.1"], "T13.1-2": ["13.1"], "T13.2-1": ["13.2"], diff --git a/test/suite/section-12.6.test.ts b/test/suite/section-12.6.test.ts new file mode 100644 index 0000000..4757d75 --- /dev/null +++ b/test/suite/section-12.6.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §12.6 (SUITE-57): thin Vitest wrapper over the registered +// bodies — the identical bodies the certification runner executes against +// fixture products (C-2 "one code path"). Expected to fail as diagnosed +// assertion failures until the product exists (H-8). + +import { declareProductTests } from "./declare.js"; +import { section126Tests } from "./registry/section-12.6.js"; + +declareProductTests(section126Tests); From 79edb59d68372e8af3b1ebb3d118559d9988c6a4 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 14 Aug 2026 23:58:26 +0000 Subject: [PATCH 120/226] sdg(phase-9): implement T12.7-1, the 12.7 value forms, and wire the marker walk suite-wide (FP-075) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 58 +- test/helpers/adapters/forms.ts | 26 +- test/self/s5-output-adapters.test.ts | 45 ++ test/suite/registry/index.ts | 2 + test/suite/registry/section-12.7.ts | 922 +++++++++++++++++++++++++++ test/suite/registry/traceability.ts | 6 + test/suite/section-12.7.test.ts | 9 + 7 files changed, 1066 insertions(+), 2 deletions(-) create mode 100644 test/suite/registry/section-12.7.ts create mode 100644 test/suite/section-12.7.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index e1d5520..739be37 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3315,10 +3315,66 @@ certify against FP-091's fixtures once those land. now exactly {12.7} (→ stage G: FP-075..077); certification green (violators failing as certified).] -- [ ] FP-075 — Implement T12.7-1: 12.7 value forms — range, byte-form +- [x] FP-075 — Implement T12.7-1: 12.7 value forms — range, byte-form paths, the `{"unavailable": true}` uniqueness walk, finding form. Uses FP-001's three-state datum decode; S-5 guards that walk (FP-001). [R2 #9, R2 #41; TEST-SPEC §12.7] New §12.7 registry module; map `"12.7"`. + [Done 2026-08-14: new registry module section-12.7.ts (+ wrapper, + index spread, traceability "T12.7-1": ["12.7"] — the FP-016/T12.0-13 + precedent: every staged condition's primary test lives elsewhere in + TEST-SPEC 14's record, so no "14"). Five arms: (A) located findings — + one 14.3 whose two bearers order by start within one file and one 14.9 + (spec import cycle, bindings deliberately unused so no dependency cycle + rides beside) whose two participating import declarations order by file + bytes across files; exact code/path-null/location-file projections plus + containment in disjoint byte windows in the expected sequence, which + observes the location order by value. (B) the 14.12 finding's pinned + enumeration [rule, source, kind token, target] with locations []/path + null via `check --json`. (C) one located 14.11 whose identities must + name the foreign module (distinctive-stem containment, the T4.4-1 + operationalization). (D) a review refusal (`review create` onto an + existing name, audit strategy — git-less) as a nonempty findings-only + report with every finding's `code` null (cardinality unpinned by SPEC + for review refusals — noted). (E, Linux-leg staging per TEST-SPEC; the + T11.2-3 conditional-staging discipline, no skip) a non-UTF-8 DIRECTORY + `specs/d<0xFF>/` — the one way an import's resolved target can be a + non-UTF-8 path, `./Tgt.xspec` resolving against it — holding In.mdx + (imports `../OK.xspec` + `./Tgt.xspec`, embeds `{text(OK.ok)}`, and an + id-less `<S>` — the located finding INSIDE a non-UTF-8 file; 11.2: + validation is parse-local) and Tgt.mdx: byte-form paths asserted + byte-exactly (composed from the staging bytes) at every output the 12.0 + rule names — build's 14.1 location file and 14.19 concerned paths, the + occurrence's referencing file (source exactly the marker, target a + plain identity), the view's file members and the `./Tgt.xspec` import's + resolved target (the `../OK.xspec` one plain beside it), inventory + sources and derived module paths (exit 0 — 11.6: inventory parses no + sources and carries no finding but 14.23; markdown null with emission + disabled) — with the embed occurrence's range byte-exact (the range-form + value assertion). The T12.7-1 walk now runs over every 12.7 document the + suite captures: assertUnavailabilityMarkerForms integrated at every + forms.ts document-decode entry point (scoped decoders included, whose + unread members it covers), called explicitly on the arm-E captures, and + S-5 gains the integration guard (scoped inventory/view decodes reject a + near-marker in an unread member; exact-marker positive control). + Verified: typecheck/format clean; suite red-as-diagnosed at arm A's + first decode (the FP-001-class product gap — the product still emits + condition-member findings; §11 surfaces and review create are patch-new, + probes: occurrences/view/inventory exit 2 "unknown command", refusal + emits {"refused":…}, build skips invalid-path files' content 14.1); + staging premises proven by direct product probes (arm A: exactly one + old-shape 14.9 + one 14.3, the cycle located at A's declaration [31,56); + arm B: build 0/check 1 with exactly the one 14.12 naming rule and edge; + arm C: exactly one 14.11 at [103,120) within the window, message naming + FOREIGNMOD; arm D: create 0 then refuse 1); soundness proven by a + scratch conforming mock running the registered body green through all + five arms via entry.run(mockBinding), with eight deviation mocks each + failing diagnosed at the intended assertion (locations-order, + range-form, identities-order, no-foreign, coded-refusal, nullmarker, + lossy-paths, near-marker — the last caught by the integrated walk). + `npm run test:self`: 268 passed, planned mid-loop reds narrowed to 2 — + certification-document ×2 (→ FP-091); S-1 fully green (the "12.7" key + mapped; unmapped set now empty), S-5 and certification green (T12.7-* + stay certification Exclusions, no fixture scope).] - [ ] FP-076 — Implement T12.7-2: findings-array ordering + document forms. [R2 #9; TEST-SPEC §12.7] - [ ] FP-077 — Implement T12.7-3: the exit-2 error document, incl. the diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts index fc2b407..d0baa6c 100644 --- a/test/helpers/adapters/forms.ts +++ b/test/helpers/adapters/forms.ts @@ -39,7 +39,12 @@ // - the rename/move preview document {"findings","mapping","files","delta"} // (6.6) with the ten edit classes and the pinned orders // - the unavailability-marker structural walk T12.7-1 relies on: no object -// of any form other than the marker carries a member named "unavailable" +// of any form other than the marker carries a member named "unavailable". +// Every public DOCUMENT decoder below runs the walk over the whole raw +// document before decoding members — the scoped decoders included, whose +// unread members the walk still covers — so the T12.7-1 walk runs over +// every 12.7 document the suite captures (captures go through these +// entry points; S-5 guards both the walk and this integration) import { Buffer, isUtf8 } from "node:buffer"; import type { @@ -452,6 +457,7 @@ export function decodeFindingsReport( doc: unknown, context?: string, ): FindingsReport { + assertUnavailabilityMarkerForms(doc, context); const site = rootSite("12.7 findings report", context); const obj = expectObject(doc, site); expectOnlyMembers(obj, ["findings"], site); @@ -481,6 +487,7 @@ export function decodeErrorDocument( doc: unknown, context?: string, ): ErrorDocument { + assertUnavailabilityMarkerForms(doc, context); const site = rootSite("12.7 error document", context); const obj = expectObject(doc, site); expectOnlyMembers(obj, ["error"], site); @@ -507,6 +514,7 @@ export function decodeVersionDocument( doc: unknown, context?: string, ): VersionDocument { + assertUnavailabilityMarkerForms(doc, context); const site = rootSite("12.7 version document", context); const obj = expectObject(doc, site); expectOnlyMembers(obj, ["product", "interface"], site); @@ -600,6 +608,7 @@ export function decodeInventoryRecordedDatum( doc: unknown, context?: string, ): DecodedDatum<readonly PathValue[]> { + assertUnavailabilityMarkerForms(doc, context); const site = rootSite("11.6 inventory (recorded datum)", context); const obj = expectObject(doc, site); const recordedSite = at(site, "recorded"); @@ -650,6 +659,7 @@ export function decodeInventoryFindings( doc: unknown, context?: string, ): Finding[] { + assertUnavailabilityMarkerForms(doc, context); const site = rootSite("11.6 inventory (findings)", context); const obj = expectObject(doc, site); return decodeFindingsArray( @@ -677,6 +687,7 @@ export function decodeInventoryAnchoring( doc: unknown, context?: string, ): InventoryAnchoring { + assertUnavailabilityMarkerForms(doc, context); const site = rootSite("11.6 inventory (anchoring)", context); const obj = expectObject(doc, site); return { @@ -955,6 +966,7 @@ export function decodeInventoryResolvedMap( doc: unknown, context?: string, ): InventoryResolvedMap { + assertUnavailabilityMarkerForms(doc, context); const site = rootSite("11.6 inventory (resolved map)", context); const obj = expectObject(doc, site); @@ -1146,6 +1158,7 @@ export function decodeInventoryDocument( doc: unknown, context?: string, ): InventoryDocument { + assertUnavailabilityMarkerForms(doc, context); const site = rootSite("11.6 inventory (document)", context); const obj = expectObject(doc, site); expectOnlyMembers(obj, INVENTORY_DOCUMENT_MEMBERS, site); @@ -1311,6 +1324,7 @@ export function decodeOccurrencesReport( doc: unknown, context?: string, ): OccurrencesReport { + assertUnavailabilityMarkerForms(doc, context); const site = rootSite("12.7 occurrences document", context); const obj = expectObject(doc, site); expectOnlyMembers(obj, ["findings", "occurrences"], site); @@ -1395,6 +1409,7 @@ function decodeAtSectionForm(value: unknown, site: DecodeSite): AtSection { * adapter in the path. */ export function decodeAtReport(doc: unknown, context?: string): AtReport { + assertUnavailabilityMarkerForms(doc, context); const site = rootSite("12.7 at document", context); const obj = expectObject(doc, site); expectOnlyMembers(obj, ["findings", "resolution"], site); @@ -1469,6 +1484,7 @@ export function decodeViewFilesReport( doc: unknown, context?: string, ): ViewFilesReport { + assertUnavailabilityMarkerForms(doc, context); const site = rootSite("12.7 view document (files)", context); const obj = expectObject(doc, site); expectOnlyMembers(obj, ["findings", "views"], site); @@ -1782,6 +1798,7 @@ export function decodeViewReport( options: { readonly text: boolean }, context?: string, ): ViewReport { + assertUnavailabilityMarkerForms(doc, context); const site = rootSite("12.7 view document", context); const obj = expectObject(doc, site); expectOnlyMembers(obj, ["findings", "views"], site); @@ -2032,6 +2049,7 @@ export function decodePreviewReport( doc: unknown, context?: string, ): PreviewReport { + assertUnavailabilityMarkerForms(doc, context); const site = rootSite("12.7 preview document", context); const obj = expectObject(doc, site); expectOnlyMembers(obj, ["findings", "mapping", "files", "delta"], site); @@ -2129,6 +2147,12 @@ export function decodePreviewReport( * object of any form other than the unavailability marker carries a member * named `unavailable` — every object with that member is exactly * `{"unavailable": true}`. Diagnoses name the offending JSON path. + * + * Every public document decoder in this module runs this walk over the + * whole raw document before decoding members (the scoped decoders included, + * whose unread members the walk still covers), so it runs over every 12.7 + * document the suite captures (T12.7-1; S-5 guards the walk and the + * integration). Tests may additionally call it directly. */ export function assertUnavailabilityMarkerForms( doc: unknown, diff --git a/test/self/s5-output-adapters.test.ts b/test/self/s5-output-adapters.test.ts index e885720..f72eaa9 100644 --- a/test/self/s5-output-adapters.test.ts +++ b/test/self/s5-output-adapters.test.ts @@ -3987,6 +3987,51 @@ test("S-5: the marker walk rejects near-markers anywhere in the tree, naming the ); }); +test("S-5: the 12.7 document decoders run the marker walk over the whole document (T12.7-1)", () => { + // The walk is integrated at every forms.ts document-decode entry point, so + // it covers members a SCOPED decode otherwise leaves unread — the cases a + // per-member decode alone can never reject. The inventory recorded-datum + // decode reads only `recorded`; a near-marker in the unread `journal` + // member must still reject. + expectDiagnosed( + "scoped inventory decode, near-marker in an unread member", + () => + decodeInventoryRecordedDatum( + { recorded: [], journal: { unavailable: "soon", note: 1 } }, + "walk integration", + ), + ); + // The scoped view decode reads each per-file wrapper's `file` and member + // presence only; a near-marker inside the unread `root` tree must still + // reject. + expectDiagnosed("scoped view decode, near-marker in an unread subtree", () => + decodeViewFilesReport( + { + findings: [], + views: [ + { + file: "specs/A.mdx", + root: { identity: { unavailable: false } }, + imports: [], + occurrences: [], + comments: [], + }, + ], + }, + "walk integration", + ), + ); + // Positive control: a scoped decode over a document whose only + // unavailable-bearing object is an exact marker passes the integrated walk + // (the marker is a legitimate value, never a rejection). + expect( + decodeInventoryRecordedDatum( + { recorded: { unavailable: true }, extra: { fine: true } }, + "walk integration", + ), + ).toEqual({ state: "unavailable" }); +}); + // --- the bare edge-endpoint walk (T1.7-1) ------------------------------------ test("S-5: the bare edge-endpoint walk accepts edge surfaces carrying identities alone", () => { diff --git a/test/suite/registry/index.ts b/test/suite/registry/index.ts index e45d2ac..bc95ff1 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -66,6 +66,7 @@ import { section120iiTests } from "./section-12.0-ii.js"; import { section121to122Tests } from "./section-12.1-12.2.js"; import { section123to125Tests } from "./section-12.3-12.5.js"; import { section126Tests } from "./section-12.6.js"; +import { section127Tests } from "./section-12.7.js"; import { section131to132Tests } from "./section-13.1-13.2.js"; import { section133Tests } from "./section-13.3.js"; import { section134Tests } from "./section-13.4.js"; @@ -136,6 +137,7 @@ export const productTestSuite = new ProductTestSuite([ ...section121to122Tests, ...section123to125Tests, ...section126Tests, + ...section127Tests, ...section131to132Tests, ...section133Tests, ...section134Tests, diff --git a/test/suite/registry/section-12.7.ts b/test/suite/registry/section-12.7.ts new file mode 100644 index 0000000..650c159 --- /dev/null +++ b/test/suite/registry/section-12.7.ts @@ -0,0 +1,922 @@ +// TEST-SPEC §12.7 (JSON document forms) — SUITE-58: T12.7-1. +// +// Registered product-facing bodies (C-2 "one code path"): each builds its own +// fresh workspace (H-1), drives the product strictly as a subprocess (H-2), +// asserts exact exit codes and stream separation (H-5), and rejects a product +// only via diagnosed assertion failures (H-8). +// +// SPEC 12.7 fixes the machine interface's value forms — the range, path, +// unavailability-marker, and finding forms every JSON output uses — and this +// section's assertions are form-exact (H-3): member names, `null`-vs-omission, +// `[]`-vs-`null`, and orderings asserted literally through the forms.ts +// decode layer, never adapted. T12.7-1 is the value-form test; T12.7-2 +// (arrays/document forms) and T12.7-3 (the error document) follow it. +// +// Conservative operationalizations (noted per H-3/H-5/H-9): +// - "A source range is {"start", "end"}, non-negative integers, everywhere +// the 12.7 surfaces carry one" is enforced by `decodeRangeForm` at every +// range site of every captured document, and asserted by value where this +// test controls the bytes: the embed occurrence's range is byte-exact +// (composed from the same parts the staged file is — the T5.7-2 +// discipline), and each finding location's range must fall within its +// offending construct's byte window (the construct's own range end-widened +// by one byte, the shared `byteWindow` tolerance for line-granular +// locations; SPEC 14 pins "per offending construct", so containment in +// disjoint windows in the expected order also observes the location +// ORDER — file path bytes, then start, then end). +// - The location-order clause is staged as (a) one condition-9 finding whose +// participating import declarations lie in two files (file-byte order +// across locations) and (b) one condition-3 finding whose two bearers lie +// in one file (start order); `decodeFindingForm` additionally rejects +// unordered locations in every captured document. +// - The byte-form path clause is Linux-leg (TEST-SPEC: "a non-UTF-8 path +// (Linux leg)"): file names are byte strings there, so the arm's staging is +// platform-conditional exactly as T11.2-3's is — conditional STAGING, never +// a test skip (H-9); the suite's CI leg is Linux. The marked byte form is +// composed from the SAME bytes that stage the files, never measured from +// product output. A non-UTF-8 DIRECTORY component stages the import whose +// resolved target is a non-UTF-8 path: an import specifier is UTF-8 source +// text, so only a relative specifier resolved AGAINST a non-UTF-8 +// directory (SPEC 2.1: `./Tgt.xspec` from `specs/d<0xFF>/In.mdx` +// designates `specs/d<0xFF>/Tgt.mdx`) can yield one. +// - The valid-UTF-8-never-byte-form half is asserted cross-platform: every +// exact path value this test pins in arms A–D is a plain string, and +// `decodePathValue` rejects a byte-form presentation of valid-UTF-8 bytes +// wherever any captured document carries one; the Linux arm additionally +// pins the plain spellings beside the marked ones in the same documents +// (`specs/OK.mdx` among byte-form siblings, the `../OK.xspec` import's +// plain resolved target beside the byte-form `./Tgt.xspec` one). +// - The marker-uniqueness walk (`assertUnavailabilityMarkerForms`, S-5 +// guarded) runs over every 12.7 document the suite captures — integrated +// at every forms.ts document-decode entry point — and this test drives it +// explicitly over its own captured documents, which carry genuine markers +// (every identity of an invalid-path file; the occurrence records' +// `source`), so the walk's accepting side is exercised on marker-bearing +// answers, and marker exactness at the datum sites is value-asserted +// (`source` exactly `{"unavailable": true}`). +// - The review-refusal finding's cardinality is unpinned (SPEC 10.7/14 state +// no per-reason finding count for review-operation refusals, unlike the +// 6.4/6.5 reasons): the arm asserts a nonempty findings-only report every +// finding of which carries `code` null — exactly the T12.7-1 clause ("null +// where 14 assigns none"), with the five-member form enforced by decode. +// - The 14.11 identities clause ("a cross-module call names the foreign +// module") is asserted by distinctive-stem containment, the T4.4-1 +// operationalization: every rendering of the foreign module's identity — +// file name, workspace-relative path, `.xspec` specifier, root-node +// identity — contains its stem, and the stem occurs in no other module of +// the fixture, so SOME identities element containing it names that module; +// SPEC 12.7 pins the entity named, not its rendering. +// - The 14.12 identities enumeration IS pinned exactly (SPEC 14.12 fixes +// content and order: rule name, source identity, kind token, target +// identity; locations `[]`, path `null`). +// - `inventory` on the Linux arm's workspace exits 0: SPEC 11.6 — the +// inventory parses no sources, 14.23 is the only finding it ever carries, +// and the staged workspace has readable (absent-therefore-empty) recorded +// state, so the answer is finding-free and carries no unavailable datum +// (12.0's exit partition). The sources/derived byte-form paths ride the +// scoped resolved-map decode; the full inventory form is T11.6-3's. + +import { Buffer } from "node:buffer"; +import type { + Finding, + OccurrenceRecord, + PathValue, + SourceRange, +} from "../../helpers/adapters/index.js"; +import { + assertUnavailabilityMarkerForms, + decodeFindingsReport, + decodeInventoryResolvedMap, + decodeOccurrencesReport, + decodeViewReport, +} from "../../helpers/adapters/index.js"; +import { + assertExitCode, + fail, + parseJsonStdout, +} from "../../helpers/assertions.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import type { ProductBinding } from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import type { WorkspaceDecl } from "../../helpers/workspace.js"; +import { + assertConditionCounts, + assertSameJson, + buildFindings, + buildOk, + expectExit, + runCli, + runJson, +} from "./support.js"; + +// --------------------------------------------------------------------------- +// Shared machinery +// --------------------------------------------------------------------------- + +/** Whether non-UTF-8 file names are stageable (module-header note). */ +const NON_UTF8_STAGED = process.platform === "linux"; + +/** The 12.7 unavailability marker, as decoded (one-datum state). */ +const UNAVAILABLE = { unavailable: true } as const; + +/** + * Running byte-offset fixture assembler (the T5.7-2/T11.2-3 discipline): + * `add` appends a segment and returns its byte range, so every expected + * offset is composed from the same parts the staged file is. + */ +class ByteFixture { + private readonly parts: string[] = []; + private bytes = 0; + + get pos(): number { + return this.bytes; + } + + get source(): string { + return this.parts.join(""); + } + + add(segment: string): SourceRange { + const start = this.bytes; + this.parts.push(segment); + this.bytes += Buffer.byteLength(segment, "utf8"); + return { start, end: this.bytes }; + } +} + +/** Fixture self-check (T5.7-2 discipline): a claimed range slices the staged bytes to exactly `expected` — before the product is ever invoked. */ +function sliceCheck( + source: string, + range: SourceRange, + expected: string, + what: string, +): void { + const actual = Buffer.from(source, "utf8") + .subarray(range.start, range.end) + .toString("utf8"); + if (actual !== expected) { + throw new Error( + `section-12.7 fixture self-check: ${what} — the composed range ` + + `[${String(range.start)}, ${String(range.end)}) slices to ` + + `${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}.`, + ); + } +} + +/** + * A construct's containment window: its own byte range end-widened by one + * byte (the shared `byteWindow` tolerance — a product reporting a + * line-granular location spanning the construct's last line terminator + * still passes; every other staged construct lies outside the window). + */ +function widen(range: SourceRange): SourceRange { + return { start: range.start, end: range.end + 1 }; +} + +/** + * The asserted projection of a finding's value form (T12.7-1): the stable + * code (or null), the concerned path (null for located conditions), and the + * locations' files in order. Ranges are asserted separately by containment + * (`assertLocationWithin`); message and — where 14 states no content — + * identities stay unpinned (informational, SPEC 12.7). + */ +interface FindingFormExpectation { + readonly code: string | null; + readonly path: PathValue | null; + readonly locations: readonly PathValue[]; +} + +function projectFindingForm(finding: Finding): FindingFormExpectation { + return { + code: finding.code, + path: finding.path, + locations: finding.locations.map((location) => location.file), + }; +} + +/** Assert one location's range falls within the offending construct's window. */ +function assertLocationWithin( + finding: Finding, + index: number, + window: SourceRange, + context: string, +): void { + const location = finding.locations[index]; + if (location === undefined) { + fail( + `${context}: the finding must carry a locations[${String(index)}] ` + + `entry (SPEC 12.7: one {"file", "range"} per offending construct); ` + + `got ${String(finding.locations.length)} location(s) (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + if (location.range.start < window.start || location.range.end > window.end) { + fail( + `${context}: locations[${String(index)}]'s range ` + + `[${String(location.range.start)}, ${String(location.range.end)}) ` + + `must fall within the offending construct's byte window ` + + `[${String(window.start)}, ${String(window.end)}] (SPEC 12.7, 14; ` + + `message: ${JSON.stringify(finding.message)})`, + ); + } +} + +/** Stage a fresh workspace, run `body`, dispose (H-1). */ +async function withWorkspace<T>( + decl: WorkspaceDecl, + body: (workspace: TestWorkspace) => Promise<T>, +): Promise<T> { + const workspace = await TestWorkspace.create(decl); + try { + return await body(workspace); + } finally { + await workspace.dispose(); + } +} + +// The canonical valid configuration (SPEC 7): exactly one spec group. +const SPECS_ONLY_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`; + +// --------------------------------------------------------------------------- +// Arm A — located findings: path null, location order (file bytes; start) +// --------------------------------------------------------------------------- +// +// Two independent conditions, each the sole defect of its files: a spec +// import cycle A <-> B (14.9 — one finding locating every participating +// import declaration, SPEC 2.1/T14-8: the bindings are deliberately unused, +// an unused import being valid and recording no edges, so no dependency +// cycle exists beside the import cycle) and a duplicated ID within one file +// C (14.3 — one finding, one location per bearer). The cycle's locations +// span two files in file-byte order; the duplicate's span one file in start +// order. Multi-byte prefixes shift every later offset (SPEC 1.7). + +const CY_A_FILE = "specs/A.mdx"; +const CY_A = new ByteFixture(); +CY_A.add("Décor — multi-byte prefix.\n\n"); +const CY_A_IMPORT_TEXT = 'import B from "./B.xspec"'; +const CY_A_IMPORT_RANGE = CY_A.add(CY_A_IMPORT_TEXT); +CY_A.add('\n\n<S id="a">\nAlpha text.\n</S>\n'); +const CY_A_SOURCE = CY_A.source; + +const CY_B_FILE = "specs/B.mdx"; +const CY_B = new ByteFixture(); +CY_B.add("Début — multi-byte prefix.\n\n"); +const CY_B_IMPORT_TEXT = 'import A from "./A.xspec"'; +const CY_B_IMPORT_RANGE = CY_B.add(CY_B_IMPORT_TEXT); +CY_B.add('\n\n<S id="b">\nBravo text.\n</S>\n'); +const CY_B_SOURCE = CY_B.source; + +const DUP_FILE = "specs/C.mdx"; +const DUP = new ByteFixture(); +DUP.add("Préfixe — multi-byte guard.\n\n"); +const DUP_ONE_TEXT = '<S id="dup">\nFirst bearer.\n</S>'; +const DUP_ONE_RANGE = DUP.add(DUP_ONE_TEXT); +DUP.add("\n\n"); +const DUP_TWO_TEXT = '<S id="dup">\nSecond bearer.\n</S>'; +const DUP_TWO_RANGE = DUP.add(DUP_TWO_TEXT); +DUP.add("\n"); +const DUP_SOURCE = DUP.source; + +async function runLocatedFindingsArm(product: ProductBinding): Promise<void> { + sliceCheck( + CY_A_SOURCE, + CY_A_IMPORT_RANGE, + CY_A_IMPORT_TEXT, + "A's import declaration", + ); + sliceCheck( + CY_B_SOURCE, + CY_B_IMPORT_RANGE, + CY_B_IMPORT_TEXT, + "B's import declaration", + ); + sliceCheck(DUP_SOURCE, DUP_ONE_RANGE, DUP_ONE_TEXT, "the first dup bearer"); + sliceCheck(DUP_SOURCE, DUP_TWO_RANGE, DUP_TWO_TEXT, "the second dup bearer"); + + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [CY_A_FILE]: CY_A_SOURCE, + [CY_B_FILE]: CY_B_SOURCE, + [DUP_FILE]: DUP_SOURCE, + }, + }, + async (workspace) => { + const context = + "T12.7-1 (located findings) `build --json` over a spec import " + + "cycle A <-> B and a duplicated ID in C"; + const findings = await buildFindings(product, workspace, context); + assertConditionCounts( + findings, + { "14.3": 1, "14.9": 1 }, + `${context} — each condition is its files' sole defect: one ` + + `duplicate-ID finding, one cycle finding, nothing else`, + ); + assertSameJson( + findings.map(projectFindingForm), + [ + { code: "duplicate-id", path: null, locations: [DUP_FILE, DUP_FILE] }, + { code: "cycle", path: null, locations: [CY_A_FILE, CY_B_FILE] }, + ], + `${context} — the finding form's located side: exact stable code ` + + `tokens, \`path\` null for located conditions, and one ` + + `{"file", "range"} per offending construct — the duplicate's two ` + + `bearers in one file, the import cycle's two participating ` + + `declarations across two files in file-path-byte order ` + + `(SPEC 12.7, 14)`, + ); + const [dupFinding, cycleFinding] = [findings[0]!, findings[1]!]; + // Containment in DISJOINT windows in the expected sequence observes + // the within-finding location order by value: file bytes (A before B), + // then range start (the first bearer before the second). + assertLocationWithin( + dupFinding, + 0, + widen(DUP_ONE_RANGE), + `${context} — the duplicate-id finding's first location (the first ` + + `bearer construct)`, + ); + assertLocationWithin( + dupFinding, + 1, + widen(DUP_TWO_RANGE), + `${context} — the duplicate-id finding's second location (the ` + + `second bearer construct; start order within one file, SPEC 12.7)`, + ); + assertLocationWithin( + cycleFinding, + 0, + widen(CY_A_IMPORT_RANGE), + `${context} — the cycle finding's first location (A's ` + + `participating import declaration)`, + ); + assertLocationWithin( + cycleFinding, + 1, + widen(CY_B_IMPORT_RANGE), + `${context} — the cycle finding's second location (B's ` + + `participating import declaration; file-byte order across files, ` + + `SPEC 12.7)`, + ); + }, + ); +} + +// --------------------------------------------------------------------------- +// Arm B — the policy finding's contractual identities (14.12) +// --------------------------------------------------------------------------- + +const POLICY_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + policy: [ + { + name: "no-self-deps", + type: "forbidden", + from: { group: "main" }, + to: { group: "main" } + } + ] +}) +`; + +// The one violation: `p` depends locally on `a` (SPEC 2.2 string form); +// both endpoints are `main` nodes, so the forbidden rule matches exactly +// this edge and nothing else. `build` never evaluates policy (SPEC 7.5, +// 12.1) — the finding is `check`'s. +const POLICY_SOURCE = `<S id="a"> +Target leaf. +</S> + +<S id="p" d={"a"}> +Dependent leaf. +</S> +`; + +async function runPolicyFindingArm(product: ProductBinding): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": POLICY_CONFIG, + "specs/P.mdx": POLICY_SOURCE, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T12.7-1 (policy finding) `build` — policy never fails a build " + + "(SPEC 7.5, 12.1)", + ); + const context = "T12.7-1 (policy finding) `check --json`"; + const result = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${context} — the staged depends edge violates the forbidden rule, ` + + `so check reports it and exits 1 (SPEC 7.5, 14.12, 12.0)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, context), + context, + ).findings; + assertConditionCounts(findings, { "14.12": 1 }, context); + assertSameJson( + findings.map((finding) => ({ + code: finding.code, + locations: finding.locations, + path: finding.path, + identities: finding.identities, + })), + [ + { + code: "policy-violation", + locations: [], + path: null, + identities: [ + "no-self-deps", + "specs/P.mdx#p", + "depends", + "specs/P.mdx#a", + ], + }, + ], + `${context} — the finding form's contractual-identities side: a ` + + `policy finding carries the rule name, source identity, kind ` + + `token, and target identity IN THAT ORDER, with locations [] ` + + `(an unlocated condition — the offending entity is a graph ` + + `edge, not a spelling) and path null (SPEC 14.12, 12.7)`, + ); + }, + ); +} + +// --------------------------------------------------------------------------- +// Arm C — the cross-module call names the foreign module (14.11) +// --------------------------------------------------------------------------- +// +// Distinctive name stems (the T4.4-1 operationalization): every rendering of +// a module's identity — file name, workspace-relative path, `.xspec` +// specifier, root-node identity — contains its stem, and neither stem names +// any other module of the fixture, so an identities element containing +// FOREIGNMOD names the foreign (called) module. + +const FOREIGN_STEM = "FOREIGNMOD"; + +const CROSS_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"] + } +}) +`; + +const CROSS_IMPORT_PREFIX = + 'import HOME from "../specs/HOMEMOD.xspec";\n' + + 'import { text as textF } from "../specs/FOREIGNMOD.xspec";\n' + + "\n"; +const CROSS_STATEMENT = "textF(HOME.first);"; + +async function runCrossModuleArm(product: ProductBinding): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": CROSS_CONFIG, + "specs/HOMEMOD.mdx": '<S id="first">\nHome behavior.\n</S>\n', + "specs/FOREIGNMOD.mdx": '<S id="second">\nForeign behavior.\n</S>\n', + "src/app.ts": CROSS_IMPORT_PREFIX + CROSS_STATEMENT + "\n", + }, + }, + async (workspace) => { + const context = + "T12.7-1 (cross-module finding) `build --json` over a discovered " + + "code file passing HOMEMOD's node to FOREIGNMOD's `text` export"; + const findings = await buildFindings(product, workspace, context); + assertConditionCounts( + findings, + { "14.11": 1 }, + `${context} — the cross-module call is the workspace's sole defect`, + ); + const finding = findings[0]!; + assertSameJson( + projectFindingForm(finding), + { code: "cross-module-text", path: null, locations: ["src/app.ts"] }, + `${context} — the finding form: the stable code, path null (a ` + + `located condition), one location at the offending call in the ` + + `code file (SPEC 14.11, 12.7)`, + ); + assertLocationWithin( + finding, + 0, + widen({ + start: Buffer.byteLength(CROSS_IMPORT_PREFIX, "utf8"), + end: Buffer.byteLength(CROSS_IMPORT_PREFIX + CROSS_STATEMENT, "utf8"), + }), + `${context} — the 14.11 finding's location (the cross-module call ` + + `statement)`, + ); + if ( + !finding.identities.some((identity) => identity.includes(FOREIGN_STEM)) + ) { + fail( + `${context}: the finding's identities must name the foreign ` + + `module — the called module, "a spec module other than its ` + + `own" (SPEC 14.11; 12.7: identities are contractual where 14 ` + + `states a named context entity) — but no element contains the ` + + `distinctive stem ${JSON.stringify(FOREIGN_STEM)}, which every ` + + `rendering of that module's identity carries; got ` + + `${JSON.stringify(finding.identities)}`, + ); + } + }, + ); +} + +// --------------------------------------------------------------------------- +// Arm D — a review-refusal finding carries `code` null +// --------------------------------------------------------------------------- + +async function runReviewRefusalArm(product: ProductBinding): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/R.mdx": '<S id="r">\nReviewed leaf.\n</S>\n', + }, + }, + async (workspace) => { + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", "s"], + 0, + "T12.7-1 (review refusal) `review create --strategy audit --name " + + "s` — the first creation succeeds on the valid workspace " + + "(SPEC 10.1, 10.6; the audit strategy needs no git, 12.0)", + ); + const context = + "T12.7-1 (review refusal) `review create --strategy audit --name " + + "s --json` again"; + const result = await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", "s", "--json"], + 1, + `${context} — \`create\` with an existing session's exact name is ` + + `refused: exit 1, a refused review operation (SPEC 10.1, 10.7, ` + + `12.0)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout( + result, + `${context} — a refused operation's report is the findings-only ` + + `document {"findings": […]} (SPEC 12.7)`, + ), + context, + ).findings; + if (findings.length === 0) { + fail( + `${context}: the refusal must be reported as at least one ` + + `finding — an exit-1 refusal with an empty findings array ` + + `reports nothing (SPEC 10.7, 12.7, 14)`, + ); + } + for (const finding of findings) { + if (finding.code !== null) { + fail( + `${context}: a review-operation refusal carries no stable ` + + `code — \`code\` is null where 14 assigns none (SPEC 14, ` + + `12.7); got ${JSON.stringify(finding.code)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + } + }, + ); +} + +// --------------------------------------------------------------------------- +// Arm E — (Linux leg) byte-form paths at each output the 12.0 rule names +// --------------------------------------------------------------------------- +// +// A non-UTF-8 directory `specs/d<0xFF>/` (0xFF occurs in no valid UTF-8 +// sequence; the byte-wise glob rules of SPEC 7 still discover its files) +// holds In.mdx — importing the valid `../OK.xspec` AND the sibling +// `./Tgt.xspec`, embedding `{text(OK.ok)}` inside section `in`, and holding +// an id-less `<S>` (14.1, the located finding INSIDE a non-UTF-8 file: +// structure and validation are parse-local, SPEC 11.2) — and Tgt.mdx, whose +// only defect is its path. Every expected byte-form value is composed from +// the same bytes that stage the files. + +const NU_DIR_BYTES = Buffer.concat([ + Buffer.from("specs/d", "utf8"), + Buffer.from([0xff]), +]); +const IN_PATH_BYTES = Buffer.concat([ + NU_DIR_BYTES, + Buffer.from("/In.mdx", "utf8"), +]); +const TGT_PATH_BYTES = Buffer.concat([ + NU_DIR_BYTES, + Buffer.from("/Tgt.mdx", "utf8"), +]); +const IN_MODULE_BYTES = Buffer.concat([ + NU_DIR_BYTES, + Buffer.from("/In.xspec.ts", "utf8"), +]); +const TGT_MODULE_BYTES = Buffer.concat([ + NU_DIR_BYTES, + Buffer.from("/Tgt.xspec.ts", "utf8"), +]); +const IN_MARKED = { bytes: IN_PATH_BYTES.toString("hex") } as const; +const TGT_MARKED = { bytes: TGT_PATH_BYTES.toString("hex") } as const; +const IN_MODULE_MARKED = { bytes: IN_MODULE_BYTES.toString("hex") } as const; +const TGT_MODULE_MARKED = { bytes: TGT_MODULE_BYTES.toString("hex") } as const; + +const OK_FILE = "specs/OK.mdx"; +const OK_SOURCE = '<S id="ok">\nOK text.\n</S>\n'; +const OK_NODE_ID = `${OK_FILE}#ok`; + +const IN = new ByteFixture(); +IN.add("Prólogo — byte-form path survey.\n\n"); +IN.add('import OK from "../OK.xspec"\n'); +IN.add("\n"); +IN.add('import T from "./Tgt.xspec"\n'); +IN.add('\n<S id="in">\nEmbed: '); +const IN_EMBED_TEXT = "{text(OK.ok)}"; +const IN_EMBED_RANGE = IN.add(IN_EMBED_TEXT); +IN.add("\n</S>\n\n"); +const IN_NOID_TEXT = "<S>\nNo id here.\n</S>"; +const IN_NOID_RANGE = IN.add(IN_NOID_TEXT); +IN.add("\n"); +const IN_SOURCE = IN.source; + +const TGT_SOURCE = '<S id="t">\nTarget text.\n</S>\n'; + +// The workspace findings, identical for `build`, bare `view` (whose domain +// is every discovered spec source = the whole workspace), and bare +// `occurrences` (the entire discovered set): the located 14.1 (its location +// FILE in the marked byte form), then the two path-level 14.19s in +// concerned-path byte order ("…/In.mdx" < "…/Tgt.mdx") — each concerned +// path the marked byte form. `specs/OK.mdx` is condition-free. +const NU_EXPECTED_FINDINGS: readonly FindingFormExpectation[] = [ + { code: "missing-id", path: null, locations: [IN_MARKED] }, + { code: "invalid-source-path", path: IN_MARKED, locations: [] }, + { code: "invalid-source-path", path: TGT_MARKED, locations: [] }, +]; + +// The workspace's one occurrence: In.mdx's embedding resolves (the target +// `specs/OK.mdx#ok` has a defined identity) and records — `file` the marked +// byte form, the byte-exact container range, `source` exactly the +// unavailability marker (every node identity of an invalid-path file is +// undefined, withheld as one datum; SPEC 11.2, 5.7), the target's identity +// a plain string (no identity carries a non-UTF-8 path, 12.0). +const NU_EXPECTED_OCCURRENCE: OccurrenceRecord = { + file: IN_MARKED, + range: IN_EMBED_RANGE, + kind: "embeds", + source: UNAVAILABLE, + target: OK_NODE_ID, +}; + +async function runBytePathsArm(product: ProductBinding): Promise<void> { + sliceCheck(IN_SOURCE, IN_EMBED_RANGE, IN_EMBED_TEXT, "the embed container"); + sliceCheck(IN_SOURCE, IN_NOID_RANGE, IN_NOID_TEXT, "the id-less construct"); + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [OK_FILE]: OK_SOURCE, + }, + }); + try { + await workspace.file(IN_PATH_BYTES, IN_SOURCE); + await workspace.file(TGT_PATH_BYTES, TGT_SOURCE); + + // --- `build --json`: a finding's location file and concerned path in + // the marked byte form (SPEC 12.0, 12.7, 14). + const buildContext = "T12.7-1 (byte-form paths) `build --json`"; + const buildResult = await expectExit( + product, + workspace, + ["build", "--json"], + 1, + `${buildContext} — the workspace fails \`build\` on exactly the ` + + `staged conditions (SPEC 14.19, 14.1, 12.0)`, + ); + const buildDoc = parseJsonStdout(buildResult, buildContext); + assertUnavailabilityMarkerForms(buildDoc, buildContext); + const findings = decodeFindingsReport(buildDoc, buildContext).findings; + assertConditionCounts( + findings, + { "14.1": 1, "14.19": 2 }, + `${buildContext} — the id-less construct and the two invalid paths ` + + `are the workspace's only conditions`, + ); + assertSameJson( + findings.map(projectFindingForm), + NU_EXPECTED_FINDINGS, + `${buildContext} — a finding's location file (the 14.1 inside the ` + + `non-UTF-8-named file) and concerned path (each 14.19's offending ` + + `file) are presented in the marked byte form {"bytes": …} — the ` + + `path's exact bytes as lowercase hexadecimal, two digits per ` + + `byte — never a plain string (SPEC 12.0, 12.7, 14)`, + ); + assertLocationWithin( + findings[0]!, + 0, + widen(IN_NOID_RANGE), + `${buildContext} — the 14.1 finding's location (the id-less ` + + `construct inside the non-UTF-8-named file: structure and ` + + `validation are parse-local, SPEC 11.2)`, + ); + + // --- Bare `occurrences` (JSON-only; the entire discovered set): an + // occurrence's referencing file in the marked byte form (SPEC 11.3, + // 12.0, 12.7). + const occContext = "T12.7-1 (byte-form paths) bare `occurrences`"; + const occResult = await runCli(product, workspace, ["occurrences"]); + assertExitCode( + occResult, + 1, + `${occContext} — the answer carries the domain's findings and an ` + + `explicitly-unavailable source datum, so exit 1 with the full ` + + `document emitted (SPEC 11.2, 11.3)`, + ); + const occDoc = parseJsonStdout( + occResult, + `${occContext} — a single JSON document is the only output form, ` + + `with or without --json (SPEC 11)`, + ); + assertUnavailabilityMarkerForms(occDoc, occContext); + const occReport = decodeOccurrencesReport(occDoc, occContext); + assertSameJson( + occReport.findings.map(projectFindingForm), + NU_EXPECTED_FINDINGS, + `${occContext} — every domain file's finding accompanies, byte-form ` + + `paths exactly as \`build\` presents them (SPEC 11.2, 12.7)`, + ); + assertSameJson( + occReport.occurrences, + [NU_EXPECTED_OCCURRENCE], + `${occContext} — the one record: referencing \`file\` in the marked ` + + `byte form, the byte-exact container range {"start", "end"}, ` + + `\`source\` exactly the unavailability marker (one datum: every ` + + `identity of an invalid-path file is undefined), and the resolved ` + + `target's identity a plain string (SPEC 5.7, 11.2, 11.3, 12.0, ` + + `12.7)`, + ); + + // --- Bare `view` (whole domain): a view's file and an import's + // resolved target in the marked byte form, the valid-UTF-8 siblings + // plain (SPEC 11.4, 12.0, 12.7). + const viewContext = "T12.7-1 (byte-form paths) bare `view`"; + const viewResult = await runCli(product, workspace, ["view"]); + assertExitCode( + viewResult, + 1, + `${viewContext} — the answer carries findings and ` + + `explicitly-unavailable identities, so exit 1 with the full ` + + `document emitted (SPEC 11.2, 11.4)`, + ); + const viewDoc = parseJsonStdout( + viewResult, + `${viewContext} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ); + assertUnavailabilityMarkerForms(viewDoc, viewContext); + const viewReport = decodeViewReport(viewDoc, { text: false }, viewContext); + assertSameJson( + viewReport.findings.map(projectFindingForm), + NU_EXPECTED_FINDINGS, + `${viewContext} — the requested files' findings accompany the ` + + `answer, byte-form paths exactly as \`build\` presents them ` + + `(SPEC 11.2, 12.7)`, + ); + assertSameJson( + viewReport.views.map((view) => view.file), + [OK_FILE, IN_MARKED, TGT_MARKED], + `${viewContext} — per-file views in path-byte order: the ` + + `non-UTF-8-named files' \`file\` members in the marked byte form, ` + + `the valid-UTF-8 one a plain string — never the byte form ` + + `(SPEC 11.4, 12.0, 12.7)`, + ); + const inView = viewReport.views[1]!; + assertSameJson( + inView.imports.map((entry) => ({ + name: entry.name, + target: entry.target, + })), + [ + { name: "OK", target: OK_FILE }, + { name: "T", target: TGT_MARKED }, + ], + `${viewContext} — the import entries' resolved targets: ` + + `\`../OK.xspec\` designates the valid-path source as a plain ` + + `string while \`./Tgt.xspec\`, resolved against the non-UTF-8 ` + + `directory, designates a non-UTF-8 path presented in the marked ` + + `byte form (SPEC 2.1, 11.4, 12.0, 12.7)`, + ); + assertSameJson( + inView.occurrences, + [NU_EXPECTED_OCCURRENCE], + `${viewContext} — the viewed file's own occurrence record, ` + + `byte-form \`file\` and marker \`source\` exactly as ` + + `\`occurrences\` reports them (SPEC 11.4, 5.7, 12.7)`, + ); + + // --- `inventory` (JSON-only): source and derived-module paths in the + // marked byte form (SPEC 11.6, 12.0, 12.7). The inventory parses no + // sources and carries no finding but 14.23 — absent recorded state is + // empty, not unavailable — so the answer is finding-free: exit 0 + // (SPEC 11.6, 12.0). + const invContext = "T12.7-1 (byte-form paths) `inventory`"; + const invDoc = await runJson(product, workspace, ["inventory"], invContext); + assertUnavailabilityMarkerForms(invDoc, invContext); + const resolved = decodeInventoryResolvedMap(invDoc, invContext); + assertSameJson( + resolved.sources, + [ + { path: OK_FILE, groups: [{ name: "main", kind: "spec" }] }, + { path: IN_MARKED, groups: [{ name: "main", kind: "spec" }] }, + { path: TGT_MARKED, groups: [{ name: "main", kind: "spec" }] }, + ], + `${invContext} — every discovered source with its group ` + + `memberships, in path-byte order: the non-UTF-8 source paths in ` + + `the marked byte form, the valid one plain (SPEC 11.6, 12.0, 12.7)`, + ); + assertSameJson( + resolved.derived, + [ + { source: OK_FILE, module: "specs/OK.xspec.ts", markdown: null }, + { source: IN_MARKED, module: IN_MODULE_MARKED, markdown: null }, + { source: TGT_MARKED, module: TGT_MODULE_MARKED, markdown: null }, + ], + `${invContext} — the derived map: each \`NAME.mdx\` source's ` + + `generated-module path (defined by name shape alone, SPEC 13.1), ` + + `the non-UTF-8 ones in the marked byte form; \`markdown\` null ` + + `for every source while emission is disabled — null, never ` + + `omitted (SPEC 7.3, 11.6, 12.7)`, + ); + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// T12.7-1 — value forms +// --------------------------------------------------------------------------- + +const T12_7_1 = defineProductTest({ + id: "T12.7-1", + title: + 'value forms: a source range is {"start", "end"} with non-negative ' + + "integers everywhere the 12.7 surfaces carry one (byte-exact where this " + + "test stages the bytes); (Linux leg) a non-UTF-8 path is the marked " + + 'byte form {"bytes": …} — its exact bytes as lowercase hexadecimal, ' + + "two digits per byte — at each output the 12.0 rule names: an inventory " + + "source and derived-module path, an occurrence's referencing file, a " + + "view's file and an import's resolved target, and a finding's location " + + "file and concerned path, while a valid-UTF-8 path never takes the byte " + + 'form; unavailability is exactly {"unavailable": true} and no object ' + + 'of any other form carries a member named "unavailable" (the ' + + "S-5-guarded structural walk, run over every captured 12.7 document); " + + 'a finding is {"code", "message", "locations", "path", ' + + '"identities"} — `code` the stable token or null where 14 assigns ' + + 'none (a review-refusal finding), `locations` one {"file", "range"} ' + + "per offending construct ordered by file bytes then start then end and " + + "[] for unlocated conditions, `path` null for located conditions and " + + "the concerned path otherwise, `identities` contractual where 14 states " + + "them: a policy finding [rule, source, kind token, target] with " + + "locations [] and path null (14.12), a cross-module call naming the " + + "foreign module (14.11) (SPEC 12.7, 12.0, 14, 11.2-11.6)", + run: async (product) => { + await runLocatedFindingsArm(product); + await runPolicyFindingArm(product); + await runCrossModuleArm(product); + await runReviewRefusalArm(product); + if (NON_UTF8_STAGED) { + await runBytePathsArm(product); + } + }, +}); + +/** TEST-SPEC §12.7, in canonical ID order (SUITE-58). */ +export const section127Tests: readonly ProductTestEntry[] = [T12_7_1]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index a41fe55..50ddf27 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -359,6 +359,12 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T12.5-1": ["12.5"], "T12.6-1": ["12.6"], "T12.6-2": ["12.6"], + // T12.7-1: the FP-016/T12.0-13 precedent — the staged conditions (14.1, + // 14.3, 14.9, 14.11, 14.12, 14.19) all have their primary tests in + // TEST-SPEC 14's per-condition record elsewhere (T12.7-1 appears in no + // staging record there), so no "14"; 11.2-11.6/10.7 are carriage context + // with home coverage at T11.2-*/T11.3-*/T11.4-*/T11.6-*/T10.7-*. + "T12.7-1": ["12.7"], "T13.1-1": ["13.1"], "T13.1-2": ["13.1"], "T13.2-1": ["13.2"], diff --git a/test/suite/section-12.7.test.ts b/test/suite/section-12.7.test.ts new file mode 100644 index 0000000..cd3a708 --- /dev/null +++ b/test/suite/section-12.7.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §12.7 (SUITE-58): thin Vitest wrapper over the registered +// bodies — the identical bodies the certification runner executes against +// fixture products (C-2 "one code path"). Expected to fail as diagnosed +// assertion failures until the product exists (H-8). + +import { declareProductTests } from "./declare.js"; +import { section127Tests } from "./registry/section-12.7.js"; + +declareProductTests(section127Tests); From 015e04ac02986e2d95ce445f1bd49f8ffb7db861 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 16 Aug 2026 22:37:55 +0000 Subject: [PATCH 121/226] sdg(phase-9): implement T12.7-2, the findings-array ordering and document forms (FP-076) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 60 +- test/suite/registry/section-12.7.ts | 1012 ++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 8 + 3 files changed, 1075 insertions(+), 5 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 739be37..f9bb5d7 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3375,8 +3375,66 @@ certify against FP-091's fixtures once those land. certification-document ×2 (→ FP-091); S-1 fully green (the "12.7" key mapped; unmapped set now empty), S-5 and certification green (T12.7-* stay certification Exclusions, no fixture scope).] -- [ ] FP-076 — Implement T12.7-2: findings-array ordering + document forms. +- [x] FP-076 — Implement T12.7-2: findings-array ordering + document forms. [R2 #9; TEST-SPEC §12.7] + [Done 2026-08-16: four arms in section-12.7.ts. (A) Ordering+collapse: one + workspace stages 14.1 ×3 (two id-less sections in E1.mdx — range-start + order between one file's findings — plus one in a `main`+`extra` two-group + file, the identically-staged-duplicate collapse: exact counts pin one + finding, membership pinned via the inventory `sources` entry), 14.3, 14.5, + 14.9 (import cycle, unused bindings), 14.15 (named-only import designating + an existing source), 14.19 ×2 plain-`#` paths (+ Linux a non-UTF-8 + `specs/A<0xFF>.mdx` whose marked byte form sorts BEFORE the plain strings + — one byte order over both presentation forms); the numeric code order + inverts both token-alphabetical (`cycle` < `missing-id`) and + ordinal-decimal-string ("15" < "3") orders, the exact projected sequence + asserted on `build --json` AND on the gated `query nodes` read (13.3: + the same findings-only {"findings"} document — the gated-read form), the + inventory additionally pinning the entry's named unset-`outDir`-is-null + example. (B) The T14-7 dual refusal: a section move staged to both + collide (`keep.sub` present in the target) and create a dependency cycle + (moved node depends on `keep`, would become its child; no third reason + applicable — nothing references the moved node) → exactly + [refused-id-collision, refused-cycle], 14's LISTED order inverting the + alphabetical, paths null, concerns SOME-quantified in byte windows + (FP-007's latitude). (C) Identities tie-break: two forbidden rules + (declared "rb" then "ra") on one edge → two 14.12 findings equal up to + the rule name, ["ra"…] before ["rb"…] by identity bytes — failing a + configuration-order emission (the current product emits rb-first, probe + below). (D) Document forms on one valid workspace: finding-free + `check --json` {"findings": []}; `occurrences` with the byte-exact + record (source node's own construct range, 5.7); `view` bare vs `--text` + through decodeViewReport's decoder-enforced conditional presence, the + eight-member node form byte-exact (root attributes [], opening/closing + stated null; spelled attribute entries; interpreted defaults tags + []/coverage "required" on the attribute-free leaf; import entry; + expanded text values content-asserted); `at` with `occurrence` null + (offset in no occurrence); `version` decode. Comparator levels beyond + the stageable (locations proper-prefix, null-before-path, message) + admit no product-independent discriminating fixture (module header + note, the T6.6-4 precedent) — the full pinned comparator rides every + captured findings array via decodeFindingsArray. No new adapters (S-5 + untouched); traceability "T12.7-2": ["12.7"] (the T12.7-1 no-"14" + precedent: every staged condition/reason has its primary in 14's + records elsewhere). Verified: typecheck/format clean; suite + red-as-diagnosed at arm A's first decode (the FP-001-class gap — the + product emits condition-member findings; §11 surfaces/version/inventory + exit 2 "unknown command"); staging premises proven by direct probes + against the built product (arm A: exactly the staged old-shape + multiset incl. the two-group file's finding reported ONCE, gated query + {"findings"} exit 1; arm B: build 0, the dual move refused exit 1 + naming the collision, the cycle ground proven alone via a + non-colliding `keep.fresh` variant refusing on the 14.9 cycle; arm C: + 14.12 ×2 in rb-first config order — the discriminated ordering; arm D: + build/check 0 with {"findings": []}); soundness proven by a scratch + conforming mock running the registered body green through all four + arms via entry.run(mockBinding), with ten deviation mocks each failing + diagnosed at the intended assertion (alpha-codes, ordinal-string, + dup-uncollapsed, refusal-alpha, ids-config-order, text-always, + occurrence-omitted, findings-null, root-attrs-null, outdir-omitted). + `npm run test:self`: 268 passed, unchanged 2 planned mid-loop reds + (certification-document ×2 → FP-091), S-1/S-5/certification green + (T12.7-* stay certification Exclusions, no fixture scope).] - [ ] FP-077 — Implement T12.7-3: the exit-2 error document, incl. the `configuration-error` stable code and the anchoring-form concerned path. Pairs with FP-002's protocol. [R2 #9; TEST-SPEC §12.7] diff --git a/test/suite/registry/section-12.7.ts b/test/suite/registry/section-12.7.ts index 650c159..9a3c7da 100644 --- a/test/suite/registry/section-12.7.ts +++ b/test/suite/registry/section-12.7.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §12.7 (JSON document forms) — SUITE-58: T12.7-1. +// TEST-SPEC §12.7 (JSON document forms) — SUITE-58: T12.7-1, T12.7-2. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -9,8 +9,9 @@ // unavailability-marker, and finding forms every JSON output uses — and this // section's assertions are form-exact (H-3): member names, `null`-vs-omission, // `[]`-vs-`null`, and orderings asserted literally through the forms.ts -// decode layer, never adapted. T12.7-1 is the value-form test; T12.7-2 -// (arrays/document forms) and T12.7-3 (the error document) follow it. +// decode layer, never adapted. T12.7-1 is the value-form test; T12.7-2 is +// the findings-array-ordering and document-forms test; T12.7-3 (the error +// document) follows. // // Conservative operationalizations (noted per H-3/H-5/H-9): // - "A source range is {"start", "end"}, non-negative integers, everywhere @@ -75,6 +76,78 @@ // state, so the answer is finding-free and carries no unavailable datum // (12.0's exit partition). The sources/derived byte-form paths ride the // scoped resolved-map decode; the full inventory form is T11.6-3's. +// +// T12.7-2's conservative operationalizations (per H-3/H-9): +// - The comparator's cross-class code ordering (numbered conditions, then +// refusal reasons, then code-less findings) admits no single-array staging: +// no report mixes refusal reasons with numbered conditions (SPEC 14: the +// reasons are defined only over a workspace passing `build`'s validations, +// and the invalid-workspace refusal reports numbered findings alone), and a +// code-less finding arises only in review-refusal reports, where it is the +// only finding class (10.7, 14). The test stages each stageable class's +// internal order by value — numbered conditions across six codes whose +// numeric order inverts both the token-alphabetical order (`cycle` < +// `missing-id`) and the ordinal-decimal-string order ("15" < "3"), and the +// T14-7 refusal pair whose listed order inverts the token-alphabetical +// order (`refused-cycle` < `refused-id-collision` alphabetically, yet +// collision ranks 3rd and cycle 6th in 14's listing) — while the full +// pinned comparator, cross-class ranks included, is enforced over every +// findings array the suite captures (`decodeFindingsArray`, S-5-guarded). +// - The locations proper-prefix rule, the `null`-before-path rule, and the +// message tie-break admit no product-independent discriminating fixture: +// two same-code findings agreeing on every earlier key while differing +// exactly there cannot be staged — located conditions carry `path` null and +// path-level conditions carry `locations` [] (so a same-code pair differing +// in path-nullity already differs at the locations key), no condition +// yields two findings sharing code, locations, path, AND identities, and +// messages are unpinned wording (12.7) — the T6.6-4 tie-break precedent: +// the harness asserts the full comparator over whatever arrays are emitted. +// The staged tie-break levels: locations element-wise (three missing-id +// findings — range-start order inside one file, then file-byte order +// across files), concerned path (the 14.19s in one byte order — on the +// Linux leg a marked byte-form path sorting BEFORE the plain strings, +// failing any plain-first partition), and identities element-wise (two +// policy findings identical to each other except the rule name, declared +// in the opposite configuration order). +// - The duplicate-collapse staging: one defect file discovered through two +// spec groups (membership pinned via the inventory's `sources` entry — +// SPEC 7 allows a file in two same-kind groups). A per-group-iterating +// product reports the defect once per membership; SPEC 14's cardinality +// (one finding per violating construct) plus 12.7's collapse pin exactly +// one finding, and the decode additionally rejects adjacent identical +// findings wherever they appear. +// - The multi-reason refusal is TEST-SPEC 14's own dual staging (T14-7): a +// section move staged to both collide (`<new-id>` present in the target +// file) and create a dependency cycle (the moved node depends on `keep` +// and would become its child — a dependency on its own ancestor, SPEC +// 5.3), reporting both findings. The code sequence is pinned exactly +// (order, count, and completeness: no reason beside the staged two); each +// finding's location is asserted SOME-quantified within its construct's +// byte window (FP-007's latitude note: cardinality beyond the concerned +// participant is T14-8's business), `path` null (located findings, 12.7). +// No third reason is applicable: the new ID `keep.sub` is intrinsically +// valid, differs from the old identity, sits structurally under the +// existing target parent `keep` (outside the moved subtree), the target +// path is occupied by the discovered origin source itself, and nothing +// references the moved node, so no rewritten reference can fail to +// resolve. +// - Document forms delegated per the TEST-SPEC entry's own citations: the +// refused preview's four-member form (T6.6-3), the full inventory and +// preview forms (T11.6-*, T6.6-4/5), a root's stated-null `tags`/ +// `coverage` (T11.4-3), an absent `targetTags` (T11.6-2). The unset +// `outDir` null — the entry's named null-never-omission example — IS +// asserted here, on the ordering workspace's inventory. The gated-read +// `{"findings": […]}` form is asserted on the same staged array via +// `query nodes` (13.3: a failing workspace's read reports exactly the +// findings `build` would report), so the pinned order is observed on a +// second surface. +// - Interpreted per-node values asserted on the document-forms fixture are +// the spelled ones plus the 11.2-defined defaults of an attribute-free +// non-root (`tags` [] — a list-valued member with no elements, never +// null — and `coverage` "required"); the root's `tags`/`coverage` null +// distinction stays T11.4-3's. Own/subtree text values are asserted as +// plain strings containing the embedded target's text (1.6: expanded +// values) — byte-exact expansion is T11.2-1's business. import { Buffer } from "node:buffer"; import type { @@ -82,12 +155,16 @@ import type { OccurrenceRecord, PathValue, SourceRange, + ViewNode, + ViewReport, } from "../../helpers/adapters/index.js"; import { assertUnavailabilityMarkerForms, + decodeAtReport, decodeFindingsReport, decodeInventoryResolvedMap, decodeOccurrencesReport, + decodeVersionDocument, decodeViewReport, } from "../../helpers/adapters/index.js"; import { @@ -102,6 +179,7 @@ import { TestWorkspace } from "../../helpers/workspace.js"; import type { WorkspaceDecl } from "../../helpers/workspace.js"; import { assertConditionCounts, + assertFindingMentionsLocation, assertSameJson, buildFindings, buildOk, @@ -880,6 +958,889 @@ async function runBytePathsArm(product: ProductBinding): Promise<void> { } } +// --------------------------------------------------------------------------- +// T12.7-2 arm A — findings-array ordering by value, and duplicate collapse +// --------------------------------------------------------------------------- +// +// One workspace stages six numbered conditions whose numeric order inverts +// both the token-alphabetical and the ordinal-decimal-string orders (module +// header note), each condition its files' sole defect: +// 14.1 missing-id x3 — two id-less sections in E1.mdx (range-start +// order between findings of one file) and one +// in dual/D.mdx (file-byte order; the +// two-group collapse staging) +// 14.3 duplicate-id x1 — two bearers in C.mdx +// 14.5 unknown-dependency x1 — an unresolved `d` in K.mdx +// 14.9 cycle x1 — the spec import cycle IA <-> IB (unused +// bindings: valid, no edges, so no dependency +// cycle exists beside it) +// 14.15 invalid-import x1 — a named-only (non-default) import in M.mdx, +// designating the existing OK.mdx so the +// binding form is the declaration's one defect +// 14.19 invalid-source-path x2 (x3 Linux) — `#`-containing paths ha#1/ha#2 +// and, Linux, a non-UTF-8 name whose marked +// byte form sorts BEFORE the plain strings +// ("specs/A\xFF…" < "specs/ha…" byte-wise): +// one byte order over both presentation forms + +const ORD_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"], + extra: ["specs/dual/*.mdx"] + } +}) +`; + +const ORD_E1_FILE = "specs/E1.mdx"; +const ORD_E1 = new ByteFixture(); +ORD_E1.add("Éléments — multi-byte prefix.\n\n"); +const ORD_E1_FIRST_TEXT = "<S>\nFirst unnamed.\n</S>"; +const ORD_E1_FIRST_RANGE = ORD_E1.add(ORD_E1_FIRST_TEXT); +ORD_E1.add("\n\n"); +const ORD_E1_SECOND_TEXT = "<S>\nSecond unnamed.\n</S>"; +const ORD_E1_SECOND_RANGE = ORD_E1.add(ORD_E1_SECOND_TEXT); +ORD_E1.add("\n"); +const ORD_E1_SOURCE = ORD_E1.source; + +// The collapse staging: discovered through BOTH spec groups (`main` and +// `extra`), its sole defect one id-less section (module header note). +const ORD_DUAL_FILE = "specs/dual/D.mdx"; +const ORD_DUAL_SOURCE = "<S>\nDual-group unnamed.\n</S>\n"; + +const ORD_C_FILE = "specs/C.mdx"; +const ORD_C_SOURCE = + '<S id="dup">\nFirst bearer.\n</S>\n\n<S id="dup">\nSecond bearer.\n</S>\n'; + +const ORD_K_FILE = "specs/K.mdx"; +const ORD_K_SOURCE = '<S id="k" d={"nope"}>\nK text.\n</S>\n'; + +const ORD_IA_FILE = "specs/IA.mdx"; +const ORD_IA_SOURCE = 'import B from "./IB.xspec"\n\n<S id="ia">\nIA.\n</S>\n'; +const ORD_IB_FILE = "specs/IB.mdx"; +const ORD_IB_SOURCE = 'import A from "./IA.xspec"\n\n<S id="ib">\nIB.\n</S>\n'; + +const ORD_M_FILE = "specs/M.mdx"; +const ORD_M_SOURCE = + 'import { x } from "./OK.xspec"\n\n<S id="m">\nM text.\n</S>\n'; +const ORD_OK_FILE = "specs/OK.mdx"; +const ORD_OK_SOURCE = '<S id="ok">\nOK text.\n</S>\n'; + +const ORD_HASH1_FILE = "specs/ha#1.mdx"; +const ORD_HASH2_FILE = "specs/ha#2.mdx"; +const ORD_HASH1_SOURCE = '<S id="v1">\nValid content one.\n</S>\n'; +const ORD_HASH2_SOURCE = '<S id="v2">\nValid content two.\n</S>\n'; + +// (Linux leg) The non-UTF-8-named source: 0x41 ("A") then 0xFF, so its exact +// bytes sort before every staged plain 14.19 path ("specs/h…"), composed from +// the same bytes that stage the file (the T12.7-1 arm-E discipline). +const ORD_NU_PATH_BYTES = Buffer.concat([ + Buffer.from("specs/A", "utf8"), + Buffer.from([0xff]), + Buffer.from(".mdx", "utf8"), +]); +const ORD_NU_MARKED = { bytes: ORD_NU_PATH_BYTES.toString("hex") } as const; +const ORD_NU_SOURCE = '<S id="v3">\nValid content three.\n</S>\n'; + +/** The pinned 12.7 findings order over the staged conditions (SPEC 12.7, 14). */ +const ORD_EXPECTED_FINDINGS: readonly FindingFormExpectation[] = [ + { code: "missing-id", path: null, locations: [ORD_E1_FILE] }, + { code: "missing-id", path: null, locations: [ORD_E1_FILE] }, + { code: "missing-id", path: null, locations: [ORD_DUAL_FILE] }, + { code: "duplicate-id", path: null, locations: [ORD_C_FILE, ORD_C_FILE] }, + { code: "unknown-dependency", path: null, locations: [ORD_K_FILE] }, + { code: "cycle", path: null, locations: [ORD_IA_FILE, ORD_IB_FILE] }, + { code: "invalid-import", path: null, locations: [ORD_M_FILE] }, + ...(NON_UTF8_STAGED + ? [ + { + code: "invalid-source-path", + path: ORD_NU_MARKED, + locations: [], + } satisfies FindingFormExpectation, + ] + : []), + { code: "invalid-source-path", path: ORD_HASH1_FILE, locations: [] }, + { code: "invalid-source-path", path: ORD_HASH2_FILE, locations: [] }, +]; + +const ORD_EXPECTED_COUNTS: Readonly<Record<string, number>> = { + "14.1": 3, + "14.3": 1, + "14.5": 1, + "14.9": 1, + "14.15": 1, + "14.19": NON_UTF8_STAGED ? 3 : 2, +}; + +function assertOrderedFindings( + findings: readonly Finding[], + context: string, +): void { + assertConditionCounts( + findings, + ORD_EXPECTED_COUNTS, + `${context} — each staged condition is its files' sole defect, the ` + + `two-group file's defect reported once (identically-staged duplicate ` + + `findings collapse to one; SPEC 12.7, 14)`, + ); + assertSameJson( + findings.map(projectFindingForm), + ORD_EXPECTED_FINDINGS, + `${context} — the findings array in the pinned 12.7 order: by code ` + + `with numbered conditions in NUMERIC order (missing-id(1) first ` + + `though alphabetically last; invalid-import(15) after cycle(9) ` + + `though "15" < "9" as decimal strings), then by locations ` + + `element-wise (both E1 findings before dual/D's — file-byte order — ` + + `and C's two in-file locations riding one finding), then by ` + + `concerned path in ONE byte order over both presentation forms ` + + `(the marked byte-form path before the plain "specs/ha#…" strings ` + + `on the Linux leg), null-path located findings carrying path null ` + + `(SPEC 12.7, 14)`, + ); + // Range-start order between same-file findings, observed by containment in + // disjoint windows in the expected sequence (the T12.7-1 technique). + assertLocationWithin( + findings[0]!, + 0, + widen(ORD_E1_FIRST_RANGE), + `${context} — the first missing-id finding's location (E1's first ` + + `id-less construct; range-start order between findings of one file, ` + + `SPEC 12.7)`, + ); + assertLocationWithin( + findings[1]!, + 0, + widen(ORD_E1_SECOND_RANGE), + `${context} — the second missing-id finding's location (E1's second ` + + `id-less construct)`, + ); +} + +async function runConditionOrderingArm(product: ProductBinding): Promise<void> { + sliceCheck( + ORD_E1_SOURCE, + ORD_E1_FIRST_RANGE, + ORD_E1_FIRST_TEXT, + "E1's first id-less construct", + ); + sliceCheck( + ORD_E1_SOURCE, + ORD_E1_SECOND_RANGE, + ORD_E1_SECOND_TEXT, + "E1's second id-less construct", + ); + + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": ORD_CONFIG, + [ORD_E1_FILE]: ORD_E1_SOURCE, + [ORD_DUAL_FILE]: ORD_DUAL_SOURCE, + [ORD_C_FILE]: ORD_C_SOURCE, + [ORD_K_FILE]: ORD_K_SOURCE, + [ORD_IA_FILE]: ORD_IA_SOURCE, + [ORD_IB_FILE]: ORD_IB_SOURCE, + [ORD_M_FILE]: ORD_M_SOURCE, + [ORD_OK_FILE]: ORD_OK_SOURCE, + [ORD_HASH1_FILE]: ORD_HASH1_SOURCE, + [ORD_HASH2_FILE]: ORD_HASH2_SOURCE, + }, + }); + try { + if (NON_UTF8_STAGED) { + await workspace.file(ORD_NU_PATH_BYTES, ORD_NU_SOURCE); + } + + // --- `build --json`: the several-conditions findings array, ordered and + // collapsed per 12.7; the build report is `{"findings": […]}` exactly + // (decoder-enforced). + const buildContext = "T12.7-2 (condition ordering) `build --json`"; + assertOrderedFindings( + await buildFindings(product, workspace, buildContext), + buildContext, + ); + + // --- The gated read: on a workspace failing `build`'s validations, + // `query` reports exactly those findings and exits 1 without answering + // (SPEC 13.3) — its report the same findings-only document + // `{"findings": […]}`, in the same pinned order (12.7). `query` is a + // JSON-only surface (11), so the single JSON document needs no `--json`. + const queryContext = + "T12.7-2 (condition ordering) gated `query nodes` on the failing " + + "workspace"; + const queryResult = await expectExit( + product, + workspace, + ["query", "nodes"], + 1, + `${queryContext} — a failing workspace's read reports the findings a ` + + `\`build\` would now report and exits 1 without answering ` + + `(SPEC 13.3, 12.0)`, + ); + assertOrderedFindings( + decodeFindingsReport( + parseJsonStdout(queryResult, queryContext), + `${queryContext} — a refusing read's report is the findings-only ` + + `document {"findings": […]} (SPEC 12.7, 13.3)`, + ).findings, + queryContext, + ); + + // --- `inventory` (JSON-only; parses no sources, so the answer is + // finding-free, exit 0 — SPEC 11.6): the collapse premise — the dual + // file's membership in BOTH spec groups, configuration order — and the + // entry's named null-never-omission example: the `markdown` key absent + // resolves to {"emit": false, "outDir": null}, `outDir` null, never + // omitted (SPEC 7.3, 11.6, 12.7; the full resolved view is T11.6-2's). + const invContext = "T12.7-2 (condition ordering) `inventory`"; + const invDoc = await runJson(product, workspace, ["inventory"], invContext); + const resolved = decodeInventoryResolvedMap(invDoc, invContext); + assertSameJson( + resolved.configuration.markdown, + { emit: false, outDir: null }, + `${invContext} — an unset \`outDir\` is null: null is never omission ` + + `(SPEC 12.7, 7.3, 11.6)`, + ); + const dualEntry = resolved.sources.find( + (entry) => entry.path === ORD_DUAL_FILE, + ); + if (dualEntry === undefined) { + fail( + `${invContext}: the discovered source ${JSON.stringify( + ORD_DUAL_FILE, + )} must appear in the inventory's sources (SPEC 11.6) — the ` + + `collapse staging's premise; got paths ` + + `${JSON.stringify(resolved.sources.map((entry) => entry.path))}`, + ); + } + assertSameJson( + dualEntry.groups, + [ + { name: "main", kind: "spec" }, + { name: "extra", kind: "spec" }, + ], + `${invContext} — the collapse staging's premise: the defect file is ` + + `discovered through BOTH spec groups (memberships in configuration ` + + `order, SPEC 7, 11.6), so a per-group-iterating product reports ` + + `its finding twice where 12.7 collapses to one`, + ); + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// T12.7-2 arm B — the multi-reason refusal: refusal reasons in 14's listed +// order +// --------------------------------------------------------------------------- +// +// TEST-SPEC 14's dual staging (T14-7): a section move staged to both collide +// (`<new-id>` present in the target file) and create a dependency cycle. The +// listed order — refused-id-collision (3rd) before refused-cycle (6th) — +// inverts the token-alphabetical order, so a token-sorting product fails. +// No third reason is applicable (module header note). + +const MR_FILE = "specs/MR.mdx"; +const MR = new ByteFixture(); +MR.add("Préambule — multi-byte prefix.\n\n"); +MR.add('<S id="keep">\nKeep text.\n\n'); +const MR_SUB_TEXT = '<S id="keep.sub">\nExisting sub text.\n</S>'; +const MR_SUB_RANGE = MR.add(MR_SUB_TEXT); +MR.add("\n</S>\n\n"); +MR.add('<S id="mv" '); +const MR_D_TEXT = 'd={"keep"}'; +const MR_D_RANGE = MR.add(MR_D_TEXT); +MR.add(">\nMoved candidate text.\n</S>\n"); +const MR_SOURCE = MR.source; + +async function runRefusalOrderingArm(product: ProductBinding): Promise<void> { + sliceCheck(MR_SOURCE, MR_SUB_RANGE, MR_SUB_TEXT, "the remaining bearer"); + sliceCheck(MR_SOURCE, MR_D_RANGE, MR_D_TEXT, "the cycle's `d` spelling"); + + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [MR_FILE]: MR_SOURCE, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T12.7-2 (refusal ordering) premise `build` — the refusal reasons " + + "are defined only over a workspace passing build's validations " + + "(SPEC 6.4, 6.5, 14)", + ); + const context = + "T12.7-2 (refusal ordering) `move specs/MR.mdx#mv " + + "specs/MR.mdx#keep.sub --json`"; + const result = await expectExit( + product, + workspace, + ["move", `${MR_FILE}#mv`, `${MR_FILE}#keep.sub`, "--json"], + 1, + `${context} — the move both collides (keep.sub remains after the ` + + `subtree removal) and would create a dependency cycle (the moved ` + + `node depends on \`keep\` and would become its child, SPEC 5.3), ` + + `so it is refused: exit 1, every applicable reason reported ` + + `together (SPEC 6.5, 14, 12.0)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout( + result, + `${context} — a refused operation's report is the findings-only ` + + `document {"findings": […]} (SPEC 12.7, 14)`, + ), + context, + ).findings; + assertSameJson( + findings.map((finding) => ({ + code: finding.code, + path: finding.path, + })), + [ + { code: "refused-id-collision", path: null }, + { code: "refused-cycle", path: null }, + ], + `${context} — the multi-reason refusal report: one finding per ` + + `applicable reason and no reason beside them (SPEC 14), in 14's ` + + `LISTED order — refused-id-collision (3rd listed) before ` + + `refused-cycle (6th listed), the inverse of their alphabetical ` + + `order — with \`path\` null on located findings (SPEC 12.7)`, + ); + assertFindingMentionsLocation( + findings[0]!, + { file: MR_FILE, window: widen(MR_SUB_RANGE) }, + `${context} — the collision finding locates the remaining bearer ` + + `\`keep.sub\`'s construct (SPEC 14: every colliding bearer)`, + ); + assertFindingMentionsLocation( + findings[1]!, + { file: MR_FILE, window: widen(MR_D_RANGE) }, + `${context} — the cycle finding locates the participating ` + + `reference spelling \`d={"keep"}\` (SPEC 14: the would-be ` + + `cycle's full path in source)`, + ); + }, + ); +} + +// --------------------------------------------------------------------------- +// T12.7-2 arm C — the identities tie-break: two policy findings equal up to +// the rule name +// --------------------------------------------------------------------------- +// +// Two forbidden rules with identical selectors match the one staged edge, so +// `check` reports two findings identical in code (policy-violation), +// locations ([]), and path (null), ordered by identities element-wise — the +// rule name, their first element. The rules are declared in the OPPOSITE +// order ("rb" first), so a configuration-order emission fails. + +const IDS_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + policy: [ + { + name: "rb", + type: "forbidden", + from: { group: "main" }, + to: { group: "main" } + }, + { + name: "ra", + type: "forbidden", + from: { group: "main" }, + to: { group: "main" } + } + ] +}) +`; + +const IDS_FILE = "specs/P.mdx"; +const IDS_SOURCE = `<S id="a"> +Target leaf. +</S> + +<S id="p" d={"a"}> +Dependent leaf. +</S> +`; + +async function runIdentitiesOrderingArm( + product: ProductBinding, +): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": IDS_CONFIG, + [IDS_FILE]: IDS_SOURCE, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T12.7-2 (identities ordering) `build` — policy never fails a " + + "build (SPEC 7.5, 12.1)", + ); + const context = "T12.7-2 (identities ordering) `check --json`"; + const result = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${context} — the staged depends edge violates both forbidden ` + + `rules: one finding per rule and offending edge (SPEC 7.5, ` + + `14.12, 12.0)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, context), + context, + ).findings; + assertConditionCounts(findings, { "14.12": 2 }, context); + assertSameJson( + findings.map((finding) => ({ + code: finding.code, + locations: finding.locations, + path: finding.path, + identities: finding.identities, + })), + [ + { + code: "policy-violation", + locations: [], + path: null, + identities: ["ra", "specs/P.mdx#p", "depends", "specs/P.mdx#a"], + }, + { + code: "policy-violation", + locations: [], + path: null, + identities: ["rb", "specs/P.mdx#p", "depends", "specs/P.mdx#a"], + }, + ], + `${context} — two findings identical in code, locations ([]), and ` + + `path (null) sort by identities element-wise: "ra" before "rb" ` + + `by identity bytes though "rb" is declared first, so a ` + + `configuration-order emission fails; each finding's identities ` + + `are 14.12's exact enumeration [rule, source, kind token, ` + + `target] (SPEC 12.7, 14.12)`, + ); + }, + ); +} + +// --------------------------------------------------------------------------- +// T12.7-2 arm D — document forms and member presence +// --------------------------------------------------------------------------- +// +// One small valid workspace drives each form-catalog surface this test owns +// (module header note names the delegations): a finding-free `check` report +// (`{"findings": []}` — a finding-free `findings` is [], never null), +// `occurrences` (`{"findings", "occurrences"}` with the one byte-exact +// record), `view` without and with `--text` (the eight node members with +// `ownText`/`subtreeText` present exactly under the flag — decoder-enforced +// conditional presence — attribute entries `{"name", "range", "text"}`, +// imports `{"range", "name", "target"}`, a root's `attributes` [] and its +// absent opening/closing as the stated null), `at` (`{"findings", +// "resolution"}` with `occurrence` null when the offset lies in none), and +// `version` (`{"product", "interface"}`; values are T12.6-1's). + +const DF_F_FILE = "specs/F.mdx"; +const DF_W_FILE = "specs/W.mdx"; + +const DF_F = new ByteFixture(); +DF_F.add("Façade — multi-byte prefix.\n\n"); +const DF_IMPORT_TEXT = 'import W from "./W.xspec"'; +const DF_IMPORT_RANGE = DF_F.add(DF_IMPORT_TEXT); +DF_F.add("\n\n"); +const DF_F_START = DF_F.pos; +DF_F.add("<S "); +const DF_ATTR_ID_RANGE = DF_F.add('id="f"'); +DF_F.add(" "); +const DF_ATTR_TAGS_RANGE = DF_F.add('tags="alpha beta"'); +DF_F.add(" "); +const DF_ATTR_COV_RANGE = DF_F.add('coverage="none"'); +const DF_F_GT_RANGE = DF_F.add(">"); +DF_F.add("\n"); +const DF_BODY_RANGE = DF_F.add("Body text."); +DF_F.add("\n\n"); +const DF_LEAF_START = DF_F.pos; +DF_F.add("<S "); +const DF_LEAF_ATTR_ID_RANGE = DF_F.add('id="f.leaf"'); +const DF_LEAF_GT_RANGE = DF_F.add(">"); +DF_F.add("\nEmbed: "); +const DF_EMBED_TEXT = "{text(W.w)}"; +const DF_EMBED_RANGE = DF_F.add(DF_EMBED_TEXT); +DF_F.add("\n"); +const DF_LEAF_CLOSE_RANGE = DF_F.add("</S>"); +DF_F.add("\n"); +const DF_F_CLOSE_RANGE = DF_F.add("</S>"); +DF_F.add("\n"); +const DF_F_SOURCE = DF_F.source; + +const DF_F_RANGE: SourceRange = { + start: DF_F_START, + end: DF_F_CLOSE_RANGE.end, +}; +const DF_F_OPENING: SourceRange = { + start: DF_F_START, + end: DF_F_GT_RANGE.end, +}; +const DF_LEAF_RANGE: SourceRange = { + start: DF_LEAF_START, + end: DF_LEAF_CLOSE_RANGE.end, +}; +const DF_LEAF_OPENING: SourceRange = { + start: DF_LEAF_START, + end: DF_LEAF_GT_RANGE.end, +}; + +const DF_W_SOURCE = '<S id="w">\nW text.\n</S>\n'; + +// The workspace's one occurrence: f.leaf's embedding of W's `w` (byte-exact +// container span; the source graph node's own construct range — SPEC 5.7). +const DF_EXPECTED_OCCURRENCE: OccurrenceRecord = { + file: DF_F_FILE, + range: DF_EMBED_RANGE, + kind: "embeds", + source: { identity: `${DF_F_FILE}#f.leaf`, range: DF_LEAF_RANGE }, + target: `${DF_W_FILE}#w`, +}; + +/** The asserted projection of one view node's non-text members. */ +function projectViewNode(node: ViewNode): unknown { + return { + identity: node.identity, + range: node.range, + opening: node.opening, + closing: node.closing, + attributes: node.attributes, + tags: node.tags, + coverage: node.coverage, + childCount: node.children.length, + }; +} + +/** Assert a decoded text member is a plain string containing `expected`. */ +function assertTextContains( + value: string | { readonly unavailable: true } | undefined, + expected: string, + context: string, +): void { + if (typeof value !== "string" || !value.includes(expected)) { + fail( + `${context}: expected a defined text value — a plain string carrying ` + + `the embedded target's text ${JSON.stringify(expected)} (SPEC 1.6: ` + + `own and subtree text are the expanded values; 11.2: defined here, ` + + `every embedding resolving) — got ${JSON.stringify(value)}`, + ); + } +} + +function assertDocumentFormsViews( + report: ViewReport, + text: boolean, + context: string, +): void { + assertSameJson( + report.findings, + [], + `${context} — a finding-free answer's findings member is [], never ` + + `null (SPEC 12.7)`, + ); + assertSameJson( + report.views.map((view) => view.file), + [DF_F_FILE, DF_W_FILE], + `${context} — per-file views in path-byte order (SPEC 11.4, 12.7)`, + ); + const fView = report.views[0]!; + const root = fView.root; + assertSameJson( + { + identity: root.identity, + opening: root.opening, + closing: root.closing, + attributes: root.attributes, + childCount: root.children.length, + }, + { + identity: DF_F_FILE, + opening: null, + closing: null, + attributes: [], + childCount: 1, + }, + `${context} — the root node: identity the file path (SPEC 1.5), ` + + `opening/closing the stated null (a root has neither tag range, ` + + `SPEC 11.4 — null, never omitted), and attributes [] — an empty ` + + `list is [], never null (SPEC 12.7); the root's tags/coverage ` + + `null distinction is T11.4-3's`, + ); + const fNode = root.children[0]!; + assertSameJson( + projectViewNode(fNode), + { + identity: `${DF_F_FILE}#f`, + range: DF_F_RANGE, + opening: DF_F_OPENING, + closing: DF_F_CLOSE_RANGE, + attributes: [ + { name: "id", range: DF_ATTR_ID_RANGE, text: 'id="f"' }, + { name: "tags", range: DF_ATTR_TAGS_RANGE, text: 'tags="alpha beta"' }, + { name: "coverage", range: DF_ATTR_COV_RANGE, text: 'coverage="none"' }, + ], + tags: ["alpha", "beta"], + coverage: "none", + childCount: 1, + }, + `${context} — the section node \`f\`: the eight-member node form with ` + + `byte-exact construct/opening/closing ranges, one attribute entry ` + + `{"name", "range", "text"} per spelled attribute in tag order, and ` + + `the interpreted tags/coverage (SPEC 11.4, 12.7)`, + ); + const leafNode = fNode.children[0]!; + assertSameJson( + projectViewNode(leafNode), + { + identity: `${DF_F_FILE}#f.leaf`, + range: DF_LEAF_RANGE, + opening: DF_LEAF_OPENING, + closing: DF_LEAF_CLOSE_RANGE, + attributes: [ + { name: "id", range: DF_LEAF_ATTR_ID_RANGE, text: 'id="f.leaf"' }, + ], + tags: [], + coverage: "required", + childCount: 0, + }, + `${context} — the leaf node: an attribute-free non-root's interpreted ` + + `defaults are tags [] (an empty list, never null — 11.4 states ` + + `structural absence for roots alone) and coverage "required" ` + + `(SPEC 11.2, 2.5, 2.6, 12.7)`, + ); + assertSameJson( + fView.imports, + [{ range: DF_IMPORT_RANGE, name: "W", target: DF_W_FILE }], + `${context} — the import entry {"range", "name", "target"}: the ` + + `declaration's byte-exact range, its default binding name, its ` + + `resolved target (SPEC 11.4, 12.7)`, + ); + assertSameJson( + fView.occurrences, + [DF_EXPECTED_OCCURRENCE], + `${context} — the viewed file's occurrence records (SPEC 11.4, 5.7)`, + ); + assertSameJson( + fView.comments, + [], + `${context} — a comment-free file's comments member is [] (SPEC 11.4, ` + + `12.7)`, + ); + const wView = report.views[1]!; + assertSameJson( + { + wChild: wView.root.children[0]!.identity, + imports: wView.imports, + occurrences: wView.occurrences, + comments: wView.comments, + }, + { + wChild: `${DF_W_FILE}#w`, + imports: [], + occurrences: [], + comments: [], + }, + `${context} — the second view: W's section node, with empty imports/` + + `occurrences/comments each [] (SPEC 11.4, 12.7)`, + ); + if (text) { + const fWithText = report.views[0]!.root.children[0]!; + assertTextContains( + fWithText.children[0]!.ownText, + "W text.", + `${context} — the leaf's ownText under --text`, + ); + assertTextContains( + fWithText.subtreeText, + "W text.", + `${context} — \`f\`'s subtreeText under --text`, + ); + } +} + +async function runDocumentFormsArm(product: ProductBinding): Promise<void> { + sliceCheck(DF_F_SOURCE, DF_IMPORT_RANGE, DF_IMPORT_TEXT, "F's import"); + sliceCheck(DF_F_SOURCE, DF_EMBED_RANGE, DF_EMBED_TEXT, "F's embed"); + sliceCheck(DF_F_SOURCE, DF_ATTR_ID_RANGE, 'id="f"', "f's id attribute"); + sliceCheck( + DF_F_SOURCE, + DF_ATTR_TAGS_RANGE, + 'tags="alpha beta"', + "f's tags attribute", + ); + sliceCheck( + DF_F_SOURCE, + DF_ATTR_COV_RANGE, + 'coverage="none"', + "f's coverage attribute", + ); + sliceCheck( + DF_F_SOURCE, + DF_LEAF_ATTR_ID_RANGE, + 'id="f.leaf"', + "the leaf's id attribute", + ); + sliceCheck( + DF_F_SOURCE, + DF_F_OPENING, + '<S id="f" tags="alpha beta" coverage="none">', + "f's opening tag", + ); + sliceCheck( + DF_F_SOURCE, + DF_LEAF_RANGE, + '<S id="f.leaf">\nEmbed: {text(W.w)}\n</S>', + "the leaf construct", + ); + + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [DF_F_FILE]: DF_F_SOURCE, + [DF_W_FILE]: DF_W_SOURCE, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T12.7-2 (document forms) `build` — the staged workspace is valid", + ); + + // --- A finding-free `check` report: `{"findings": []}` exactly. + const checkContext = "T12.7-2 (document forms) `check --json`"; + const checkDoc = await runJson( + product, + workspace, + ["check", "--json"], + `${checkContext} — a finding-free workspace's check exits 0 ` + + `(SPEC 12.0, 12.2)`, + ); + assertSameJson( + decodeFindingsReport(checkDoc, checkContext).findings, + [], + `${checkContext} — the check report is {"findings": […]} with a ` + + `finding-free findings member [], never null (SPEC 12.7)`, + ); + + // --- `occurrences`: `{"findings", "occurrences"}` with the byte-exact + // record (JSON-only, no `--json` needed; SPEC 11.3, 11). + const occContext = "T12.7-2 (document forms) bare `occurrences`"; + const occReport = decodeOccurrencesReport( + await runJson( + product, + workspace, + ["occurrences"], + `${occContext} — a complete, finding-free answer exits 0 ` + + `(SPEC 11.2)`, + ), + occContext, + ); + assertSameJson( + { findings: occReport.findings, occurrences: occReport.occurrences }, + { findings: [], occurrences: [DF_EXPECTED_OCCURRENCE] }, + `${occContext} — the occurrences document: findings [] and the one ` + + `record {"file", "range", "kind", "source", "target"} with the ` + + `byte-exact container span and the source node's own construct ` + + `range (SPEC 11.3, 5.7, 12.7)`, + ); + + // --- `view` without `--text`: the node text members are ABSENT (the + // stated conditional presence — the decoder rejects them under + // text: false and requires them under text: true; SPEC 11.4, 12.7). + const viewContext = "T12.7-2 (document forms) bare `view`"; + assertDocumentFormsViews( + decodeViewReport( + await runJson( + product, + workspace, + ["view"], + `${viewContext} — a complete, finding-free answer exits 0 ` + + `(SPEC 11.2, 11.4)`, + ), + { text: false }, + viewContext, + ), + false, + viewContext, + ); + + // --- `view --text`: both text members present on every node. + const viewTextContext = "T12.7-2 (document forms) `view --text`"; + assertDocumentFormsViews( + decodeViewReport( + await runJson( + product, + workspace, + ["view", "--text"], + `${viewTextContext} — every expansion resolves, so the answer ` + + `stays complete and finding-free, exit 0 (SPEC 11.2, 11.4)`, + ), + { text: true }, + viewTextContext, + ), + true, + viewTextContext, + ); + + // --- `at`: `{"findings", "resolution"}`; an offset inside `f`'s body + // text lies within no occurrence, so `occurrence` is the stated null — + // present, never omitted (SPEC 11.5, 12.7). + const atOffset = DF_BODY_RANGE.start + 3; + const atContext = `T12.7-2 (document forms) \`at ${DF_F_FILE} ${String(atOffset)}\``; + const atReport = decodeAtReport( + await runJson( + product, + workspace, + ["at", DF_F_FILE, String(atOffset)], + `${atContext} — every within-file offset resolves; a complete, ` + + `finding-free answer exits 0 (SPEC 11.5, 11.2)`, + ), + atContext, + ); + assertSameJson( + { findings: atReport.findings, resolution: atReport.resolution }, + { + findings: [], + resolution: { + section: { identity: `${DF_F_FILE}#f`, range: DF_F_RANGE }, + occurrence: null, + }, + }, + `${atContext} — the at document: resolution {"section", ` + + `"occurrence"} with the innermost enclosing section construct ` + + `(byte-exact range) and occurrence null — the offset lies in no ` + + `occurrence, and null is never omission (SPEC 11.5, 12.7)`, + ); + + // --- `version`: `{"product", "interface"}` exactly (JSON-only). The + // decode pins the two-member form; values are T12.6-1's. + const versionContext = "T12.7-2 (document forms) bare `version`"; + decodeVersionDocument( + await runJson(product, workspace, ["version"], versionContext), + versionContext, + ); + }, + ); +} + // --------------------------------------------------------------------------- // T12.7-1 — value forms // --------------------------------------------------------------------------- @@ -918,5 +1879,48 @@ const T12_7_1 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T12.7-2 — findings arrays and document forms +// --------------------------------------------------------------------------- + +const T12_7_2 = defineProductTest({ + id: "T12.7-2", + title: + "findings arrays and document forms: a workspace staging several " + + "conditions reports one findings array ordered by code — numbered " + + "conditions in NUMERIC order (missing-id first though alphabetically " + + "last, invalid-import(15) after cycle(9) though before it as decimal " + + "strings) — then by locations element-wise (range-start order between " + + "one file's findings, file-byte order across files), then by concerned " + + "path in one byte order over marked byte-form and plain paths alike " + + "(Linux leg), with identically-staged duplicate findings collapsed to " + + "one (a defect file discovered through two spec groups reports once); " + + "the T14-7 multi-reason refusal (a section move staged to both collide " + + "and create a dependency cycle) reports its reasons in 14's LISTED " + + "order — refused-id-collision before refused-cycle, the inverse of " + + "their alphabetical order; two policy findings equal up to the rule " + + "name sort by identities element-wise, not configuration order; " + + "document forms are asserted literally (H-3): build/check/gated-read/" + + 'refused-operation reports are {"findings": […]} (a finding-free ' + + 'findings is [], never null), occurrences is {"findings", ' + + '"occurrences"}, view is {"findings", "views"} with the eight-member ' + + "node form plus ownText/subtreeText exactly when --text is given, " + + 'attribute entries {"name", "range", "text"}, imports {"range", ' + + '"name", "target"}, a root\'s attributes [] and its opening/closing ' + + 'the stated null, at is {"findings", "resolution"} with occurrence ' + + 'null when the offset lies in none, version is {"product", ' + + '"interface"}, and an unset outDir is null, never omitted (the ' + + "refused preview's four-member form is T6.6-3's, the full inventory/" + + "preview forms T11.6-*'s and T6.6-4/5's, a root's tags/coverage null " + + "T11.4-3's, an absent targetTags T11.6-2's) (SPEC 12.7, 14, 13.3, " + + "11.3-11.5, 12.6, 7.3)", + run: async (product) => { + await runConditionOrderingArm(product); + await runRefusalOrderingArm(product); + await runIdentitiesOrderingArm(product); + await runDocumentFormsArm(product); + }, +}); + /** TEST-SPEC §12.7, in canonical ID order (SUITE-58). */ -export const section127Tests: readonly ProductTestEntry[] = [T12_7_1]; +export const section127Tests: readonly ProductTestEntry[] = [T12_7_1, T12_7_2]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 50ddf27..f85d4ff 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -365,6 +365,14 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { // staging record there), so no "14"; 11.2-11.6/10.7 are carriage context // with home coverage at T11.2-*/T11.3-*/T11.4-*/T11.6-*/T10.7-*. "T12.7-1": ["12.7"], + // T12.7-2: same precedent — the staged conditions (14.1, 14.3, 14.5, + // 14.9, 14.12, 14.15, 14.19) and the refusal reasons have their primaries + // in TEST-SPEC 14's records elsewhere (the refusal-reason record lists + // T14-7 staged at T6.4-3/T6.5-4/T6.5-6/T6.6-3, not this test), so no + // "14"; 13.3 (the gated read), 11.3-11.6, 12.6, 6.5/6.6, and 7.3 are + // carriage context with home coverage at T13.3-*/T11.*/T12.6-*/T6.5-*/ + // T6.6-*/T11.6-2. + "T12.7-2": ["12.7"], "T13.1-1": ["13.1"], "T13.1-2": ["13.1"], "T13.2-1": ["13.2"], From 2a5bbfab599c47668d36ceb758b5fcdc908e5f75 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 16 Aug 2026 22:55:45 +0000 Subject: [PATCH 122/226] sdg(phase-9): implement T12.7-3, the exit-2 error document (FP-077) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 37 ++- test/suite/registry/section-12.7.ts | 414 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 9 + 3 files changed, 454 insertions(+), 6 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index f9bb5d7..81efccb 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3435,9 +3435,44 @@ certify against FP-091's fixtures once those land. `npm run test:self`: 268 passed, unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091), S-1/S-5/certification green (T12.7-* stay certification Exclusions, no fixture scope).] -- [ ] FP-077 — Implement T12.7-3: the exit-2 error document, incl. the +- [x] FP-077 — Implement T12.7-3: the exit-2 error document, incl. the `configuration-error` stable code and the anchoring-form concerned path. Pairs with FP-002's protocol. [R2 #9; TEST-SPEC §12.7] + [Done 2026-08-16: T12.7-3 registered in section-12.7.ts, four arms over + FP-002's protocol (`expectErrorDocument` → the existing S-5-guarded + `decodeErrorDocument`; no new adapters). (A) Configuration-error concerned + paths, each pinned byte-exactly as + {code: "configuration-error", path, locations: []} (locations [] per SPEC + 14's unlocated-condition class): the found xspec.config.ts from the root + (`xspec.config.ts`) and from nested/inner (`../../xspec.config.ts` — + ascent spelled `..`, failing a workspace-relative reporter); a + `--config ./cfg/broken.config.ts` argument reporting the canonical + `cfg/broken.config.ts` (11.6: no `.` segments — failing a verbatim echo); + `--config missing.config.ts` reporting the named file, never `.`; bare + `inventory` under the invalid configuration (JSON-only surface, no + --json). (B) Failed upward search with no --config → path exactly `.` + from the root AND from nested/inner (T7-1's no-ancestor-config premise). + (C) One finding however many defects: three independent 14.14 defects + (unknown top-level key, out-of-root glob, unknown markdown field) in one + declarative file — cardinality enforced by the decode (one JSON document + as the entire stdout, one `error` member, one finding form). (D) Plain + usage errors carry code AND path null: `inventory --definitely-not-a-flag` + (JSON-only, unknown flag, no --json) and `definitely-not-a-command + --json`; every exit-2 arm asserts non-empty stderr (diagnostics; + invariance stays T12.0-2's). Traceability "T12.7-3": ["12.7"] (the + T12.7-1 no-"14" precedent — 14.14's primaries are T7-1..T7.5-1); T12.7-3 + stays a certification Exclusion (named in the Exclusions' 12.7-sweep + paragraph). Verified: typecheck/format clean; suite red-as-diagnosed at + arm A's first decode (the FP-002-class gap — probes show every staged + invocation class exits 2 with the diagnostic on stderr and byte-EMPTY + stdout; `inventory` still exit 2 "unknown command"); soundness proven by + a scratch conforming mock running the registered body green through all + four arms via entry.run(mockBinding), with 11 deviation mocks each + failing diagnosed at the intended assertion (ws-relative, echo-config, + missing-dot, inv-human, search-abs, multi-docs, error-array, usage-coded, + usage-path, stderr-quiet, located). `npm run test:self`: 268 passed, + unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091), + S-1/S-5/certification green.] - [ ] FP-078 — Implement T13.4-8: writes create missing directories (file-form move, section-form move target, first emission under nested diff --git a/test/suite/registry/section-12.7.ts b/test/suite/registry/section-12.7.ts index 9a3c7da..83804ca 100644 --- a/test/suite/registry/section-12.7.ts +++ b/test/suite/registry/section-12.7.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §12.7 (JSON document forms) — SUITE-58: T12.7-1, T12.7-2. +// TEST-SPEC §12.7 (JSON document forms) — SUITE-58: T12.7-1…T12.7-3. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -10,8 +10,8 @@ // section's assertions are form-exact (H-3): member names, `null`-vs-omission, // `[]`-vs-`null`, and orderings asserted literally through the forms.ts // decode layer, never adapted. T12.7-1 is the value-form test; T12.7-2 is -// the findings-array-ordering and document-forms test; T12.7-3 (the error -// document) follows. +// the findings-array-ordering and document-forms test; T12.7-3 is the +// error-document test. // // Conservative operationalizations (noted per H-3/H-5/H-9): // - "A source range is {"start", "end"}, non-negative integers, everywhere @@ -148,6 +148,54 @@ // distinction stays T11.4-3's. Own/subtree text values are asserted as // plain strings containing the embedded target's text (1.6: expanded // values) — byte-exact expansion is T11.2-1's business. +// +// T12.7-3's conservative operationalizations (per H-3/H-5/H-9): +// - The anchoring form is asserted byte-exactly where SPEC 14 + 11.6 fix the +// spelling as a pure function of invocation input: the found configuration +// file from the workspace root (`xspec.config.ts`) and from a nested +// working directory two levels down (`../../xspec.config.ts` — ascent +// spelled `..`, joined with `/`, failing a product that reports the path +// workspace-relative); a `--config`-named file whose argument is spelled +// with a leading `./` segment reporting the canonical +// `cfg/broken.config.ts` (11.6: no `.` segments — failing a +// verbatim-echoing product), present and missing alike (SPEC 14: "the +// path `--config` names — it is that file"); and the failed upward search +// with no `--config` concerning the working directory itself, spelled `.` +// — from the root and from a nested cwd equally (the search starts at the +// invocation working directory). +// - The failed-search premise is T7-1's: the workspace is a fresh unique +// temporary directory (H-1) whose filesystem ancestors (the OS temp +// directory and its parents) hold no `xspec.config.ts`, so the upward +// search exhausts without a hit. +// - The configuration-error finding pins locations [] beside code and path: +// SPEC 14 classes configuration conditions among those "without an +// in-source location" (they carry the file or path they concern instead), +// and T12.7-1 pins `locations` [] for unlocated conditions. +// - One-finding-however-many-defects is enforced through the document +// decode: exactly one JSON document as the entire stdout (H-5), decoded +// as {"error": …} with the single member holding ONE finding form — a +// product reporting the three independently-staged 14.14 defects (an +// unknown top-level key, a glob resolving outside the workspace root, an +// unknown `markdown` field) as several findings, an array-valued `error`, +// a `findings` member, or concatenated documents fails the decode; which +// defect the one finding's message describes is unpinned (12.7: the +// message is deterministic but otherwise unpinned). +// - A plain usage error pins exactly what the entry states: `code` null and +// `path` null. Its locations and identities stay unpinned (the finding +// form permits informational identities, 12.7, and the entry pins neither +// for usage errors). +// - "Diagnostics on stderr" is asserted as non-empty stderr on every exit-2 +// arm; stderr byte-invariance across output forms and the /config/i +// actionability operationalization are T12.0-2's and T7-*'s business. +// - Configuration-error runs use `build --json` (the T12.0-2/T7-* +// precedent); the JSON-only-surface clause rides `inventory` twice — a +// configuration error on the bare surface, a plain usage error with an +// unknown flag and no `--json` — and the erroneous-arguments clause rides +// an unknown command beside `--json`. Every arm's workspace stages a +// valid source under a canonical spec group so the arm's staged defect is +// its sole one (the T7-2 attribution discipline): a product that wrongly +// proceeds exits 0 with a real answer and fails the exit-code assertion +// attributably, never exits 2 for a side reason. import { Buffer } from "node:buffer"; import type { @@ -174,7 +222,8 @@ import { } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; -import type { ProductBinding } from "../../helpers/subprocess.js"; +import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; +import { runProduct } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import type { WorkspaceDecl } from "../../helpers/workspace.js"; import { @@ -183,6 +232,7 @@ import { assertSameJson, buildFindings, buildOk, + expectErrorDocument, expectExit, runCli, runJson, @@ -1841,6 +1891,324 @@ async function runDocumentFormsArm(product: ProductBinding): Promise<void> { ); } +// --------------------------------------------------------------------------- +// T12.7-3 — the exit-2 error document (12.0, 12.7, 14) +// --------------------------------------------------------------------------- +// +// SPEC 12.0: with JSON output in effect, an invocation failing with a usage +// or configuration error (exit 2) emits as its entire stdout a single JSON +// document reporting the error — the error document of 12.7, `{"error": …}` +// holding ONE finding form. SPEC 14: a configuration error's concerned path +// is reported in the anchoring form of 11.6, identified relative to the +// invocation working directory — where a configuration file is concerned +// (the file the upward search found, or the path `--config` names) it is +// that file; for missing configuration with no `--config`, the directory +// the failed search started from, the invocation working directory, +// spelled `.`. + +/** A minimal valid source, matched by SPECS_ONLY_CONFIG's spec group. */ +const ERR_SOURCE = '<S id="a">\nAlpha.\n</S>\n'; + +/** + * The single-deviation invalid configuration (the T7-2 attribution + * discipline): the canonical valid file plus one unknown top-level key, so + * the refusal is attributable to that one 14.14 defect and nothing else + * (SPEC 7: unknown keys anywhere in the defineConfig argument are a + * configuration error). + */ +const ERR_UNKNOWN_KEY_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + definitelyUnknownKey: true +}) +`; + +/** + * Three independent 14.14 defects in one well-formed declarative-form file + * (SPEC 7): an unknown top-level key, a glob resolving outside the + * workspace root, and an unknown `markdown` field — "a configuration file + * with several distinct defects" (T12.7-3), each a configuration error on + * its own. + */ +const ERR_MULTI_DEFECT_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + definitelyUnknownKey: true, + specs: { + main: ["specs/**/*.mdx"], + outside: ["../escapee/**/*.mdx"] + }, + markdown: { emit: true, definitelyUnknownField: false } +}) +`; + +/** + * Diagnostics are standard-error content (SPEC 12.0; T12.7-3: "each the + * error document on stdout, diagnostics on stderr"): non-empty stderr on + * every exit-2 arm. Stderr byte-invariance across output forms and the + * /config/i actionability operationalization stay T12.0-2's and T7-*'s. + */ +function assertStderrDiagnostic(result: RunResult, context: string): void { + if (result.stderrBytes.length > 0) return; + fail( + `${context}: usage and configuration error messages are standard-error ` + + `content (SPEC 12.0), so the exit-2 diagnostics must appear on ` + + `stderr beside the JSON error document on stdout — got empty stderr ` + + `from ${result.commandLine}`, + ); +} + +/** + * Run an invocation with JSON output in effect that must fail as a + * configuration error: exit 2 exactly (SPEC 14.14, 12.0), stderr + * diagnostics present, and stdout exactly the single 12.7 error document + * whose one finding carries the stable code `configuration-error`, + * locations [] (SPEC 14: configuration conditions carry no in-source + * location), and the concerned path exactly `expectedPath` — the anchoring + * form of 11.6, identified relative to the invocation working directory + * (SPEC 14, 12.7). + */ +async function expectAnchoredConfigurationError( + product: ProductBinding, + cwd: string, + argv: readonly string[], + expectedPath: string, + context: string, +): Promise<void> { + const result = await runProduct(product, { cwd, argv }); + assertExitCode( + result, + 2, + `${context} — missing or invalid configuration is a configuration ` + + `error, reported by every command that loads configuration as a ` + + `usage-error outcome (SPEC 14.14, 12.0)`, + ); + assertStderrDiagnostic(result, context); + const finding = expectErrorDocument(result, context); + assertSameJson( + projectFindingForm(finding), + { code: "configuration-error", path: expectedPath, locations: [] }, + `${context} — the error document's one finding: the stable code ` + + `"configuration-error" (SPEC 14 condition 14), locations [] (a ` + + `configuration error is an unlocated condition, SPEC 14), and the ` + + `concerned path in the anchoring form of 11.6, identified relative ` + + `to the invocation working directory (SPEC 14, 12.7)`, + ); +} + +/** + * Arm: configuration-error concerned paths — the found and the + * `--config`-named configuration file, each in the canonical anchoring + * spelling (SPEC 14, 11.6), on `build --json` and on the bare JSON-only + * `inventory` surface. + */ +async function runErrorConfigPathsArm(product: ProductBinding): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": ERR_UNKNOWN_KEY_CONFIG, + "cfg/broken.config.ts": ERR_UNKNOWN_KEY_CONFIG, + "specs/A.mdx": ERR_SOURCE, + }, + dirs: ["nested/inner"], + }, + async (workspace) => { + // The upward-search-found file from the workspace root: zero ascent + // segments, one descending segment, no `.` segment and no trailing + // separator (SPEC 11.6's canonical spelling). + await expectAnchoredConfigurationError( + product, + workspace.root, + ["build", "--json"], + "xspec.config.ts", + "T12.7-3 `build --json` from the workspace root (invalid " + + "configuration found in place)", + ); + // From a nested working directory two levels down, the search finds + // the same file — identified relative to the INVOCATION working + // directory: ascent spelled `..`, joined with `/` (SPEC 14, 11.6) — + // failing a product that reports the path workspace-relative. + await expectAnchoredConfigurationError( + product, + workspace.path("nested/inner"), + ["build", "--json"], + "../../xspec.config.ts", + "T12.7-3 `build --json` from nested/inner (invalid configuration " + + "found by upward search)", + ); + // The `--config`-named file (SPEC 14: "the path --config names — it + // is that file"), the argument deliberately spelled with a leading + // `./` segment: the canonical anchoring spelling carries no `.` + // segments (SPEC 11.6), so the concerned path is + // "cfg/broken.config.ts" — failing a product that echoes the + // argument verbatim. + await expectAnchoredConfigurationError( + product, + workspace.root, + ["build", "--json", "--config", "./cfg/broken.config.ts"], + "cfg/broken.config.ts", + "T12.7-3 `build --json --config ./cfg/broken.config.ts` (invalid " + + "named configuration)", + ); + // A missing `--config`-named file is missing configuration WITH + // --config given: the concerned path is still the named file, never + // "." (SPEC 14 reserves "." for a failed upward search with no + // --config). + await expectAnchoredConfigurationError( + product, + workspace.root, + ["build", "--json", "--config", "missing.config.ts"], + "missing.config.ts", + "T12.7-3 `build --json --config missing.config.ts` (missing named " + + "configuration)", + ); + // A JSON-only surface without `--json`: bare `inventory` under the + // invalid configuration — JSON output is in effect (SPEC 12.0, 11), + // and configuration errors keep their precedence on the inventory + // (SPEC 11.6), so the error arrives as the error document. + await expectAnchoredConfigurationError( + product, + workspace.root, + ["inventory"], + "xspec.config.ts", + "T12.7-3 bare `inventory` (JSON-only surface, no --json) under the " + + "invalid configuration", + ); + }, + ); +} + +/** + * Arm: a failed upward search with no `--config` concerns the directory it + * started from — the invocation working directory, spelled `.` (SPEC 14, + * 11.6) — whatever that directory's position in the tree. + */ +async function runErrorSearchFailureArm( + product: ProductBinding, +): Promise<void> { + // The workspace is a fresh unique temporary directory whose filesystem + // ancestors (the OS temp directory and its parents) hold no + // xspec.config.ts — the T7-1 premise — so the upward search exhausts + // without a hit. + await withWorkspace( + { files: { "specs/A.mdx": ERR_SOURCE }, dirs: ["nested/inner"] }, + async (workspace) => { + await expectAnchoredConfigurationError( + product, + workspace.root, + ["build", "--json"], + ".", + "T12.7-3 `build --json` with no xspec.config.ts reachable by " + + "upward search and no --config", + ); + // From a nested working directory the failed search still concerns + // the working directory itself, spelled "." (SPEC 11.6 spells the + // working directory "."), never that directory's path from anywhere + // else. + await expectAnchoredConfigurationError( + product, + workspace.path("nested/inner"), + ["build", "--json"], + ".", + "T12.7-3 `build --json` from nested/inner with no xspec.config.ts " + + "reachable by upward search and no --config", + ); + }, + ); +} + +/** + * Arm: one finding however many defects — a configuration file with + * several distinct defects yields a single condition-14 finding (SPEC + * 12.7: "One invocation reports one error"). The cardinality rides the + * decode: one JSON document as the entire stdout, `{"error": …}` with the + * one member holding one finding form. + */ +async function runErrorSingleFindingArm( + product: ProductBinding, +): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": ERR_MULTI_DEFECT_CONFIG, + "specs/A.mdx": ERR_SOURCE, + }, + }, + async (workspace) => { + await expectAnchoredConfigurationError( + product, + workspace.root, + ["build", "--json"], + "xspec.config.ts", + "T12.7-3 `build --json` over a configuration file with three " + + "distinct defects (one condition-14 finding, however many " + + "defects are present)", + ); + }, + ); +} + +/** + * Arm: plain usage errors carry `code` null and `path` null, and JSON is + * in effect for a JSON-only surface without `--json` (`inventory` with an + * unknown flag) and whenever `--json` appears among the arguments, the + * arguments themselves erroneous included (an unknown command beside + * `--json`) — each the error document on stdout, diagnostics on stderr + * (SPEC 12.0, 12.7; T12.0-2). + */ +async function runErrorUsageArm(product: ProductBinding): Promise<void> { + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/A.mdx": ERR_SOURCE, + }, + }, + async (workspace) => { + const cases: readonly { argv: readonly string[]; label: string }[] = [ + { + argv: ["inventory", "--definitely-not-a-flag"], + label: + "T12.7-3 `inventory --definitely-not-a-flag` (JSON-only " + + "surface, unknown flag, no --json)", + }, + { + argv: ["definitely-not-a-command", "--json"], + label: + "T12.7-3 `definitely-not-a-command --json` (unknown command " + + "beside --json)", + }, + ]; + for (const { argv, label } of cases) { + const result = await runCli(product, workspace, argv); + assertExitCode( + result, + 2, + `${label} — an unknown command or flag is a usage error, and the ` + + `error is determined by the invocation's syntax alone ` + + `(SPEC 12.0)`, + ); + assertStderrDiagnostic(result, label); + const finding = expectErrorDocument(result, label); + if (finding.code !== null || finding.path !== null) { + fail( + `${label}: a plain usage error's finding carries code null and ` + + `path null — it describes the invocation the consuming tool ` + + `composed, no SPEC 14 condition code and no concerned ` + + `workspace path (SPEC 12.7, 14; T14-6); got code ` + + `${JSON.stringify(finding.code)}, path ` + + `${JSON.stringify(finding.path)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + } + }, + ); +} + // --------------------------------------------------------------------------- // T12.7-1 — value forms // --------------------------------------------------------------------------- @@ -1922,5 +2290,41 @@ const T12_7_2 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T12.7-3 — error document +// --------------------------------------------------------------------------- + +const T12_7_3 = defineProductTest({ + id: "T12.7-3", + title: + "error document: an exit-2 invocation with JSON output in effect emits " + + '{"error": …} holding one finding form as the entire stdout — a ' + + 'configuration error carries the stable code "configuration-error", ' + + "locations [], and its concerned path in the anchoring form of 11.6 " + + "relative to the invocation working directory (the found " + + "xspec.config.ts from the root; ../../xspec.config.ts from a nested " + + "cwd; a --config-named file in the canonical spelling — a ./-spelled " + + 'argument reports without the "." segment — present or missing alike; ' + + '"." for a failed upward search with no --config); a plain usage error ' + + "carries code and path null; one finding however many defects (a " + + "configuration file with three distinct defects yields a single " + + "condition-14 finding); JSON is in effect for a JSON-only surface " + + "without --json (inventory with an unknown flag; bare inventory under " + + "an invalid configuration) and whenever --json appears among the " + + "arguments, the arguments themselves erroneous included (an unknown " + + "command beside --json) — each the error document on stdout with " + + "diagnostics on stderr (SPEC 12.0, 12.7, 14, 11.6)", + run: async (product) => { + await runErrorConfigPathsArm(product); + await runErrorSearchFailureArm(product); + await runErrorSingleFindingArm(product); + await runErrorUsageArm(product); + }, +}); + /** TEST-SPEC §12.7, in canonical ID order (SUITE-58). */ -export const section127Tests: readonly ProductTestEntry[] = [T12_7_1, T12_7_2]; +export const section127Tests: readonly ProductTestEntry[] = [ + T12_7_1, + T12_7_2, + T12_7_3, +]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index f85d4ff..9227bae 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -373,6 +373,15 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { // carriage context with home coverage at T13.3-*/T11.*/T12.6-*/T6.5-*/ // T6.6-*/T11.6-2. "T12.7-2": ["12.7"], + // T12.7-3: same precedent — the asserted configuration-error code's + // condition (14.14) has its primary tests in TEST-SPEC 14's per-condition + // record at T7-1..T7.5-1 (T12.7-3 appears in no staging record there; the + // T14-6 code-null parenthetical cites this test as it cites T12.7-1, + // which set the no-"14" precedent), so no "14"; 12.0 (JSON-in-effect, + // stream separation, stderr diagnostics) and 11.6 (the anchoring form) + // are carriage context with home coverage at T12.0-2/T11.6-1, and 7's + // configuration location/validity at T7-*. + "T12.7-3": ["12.7"], "T13.1-1": ["13.1"], "T13.1-2": ["13.1"], "T13.2-1": ["13.2"], From 3bced2ee8292f4391c2e10b55ceb588e22d9e490 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 16 Aug 2026 23:10:14 +0000 Subject: [PATCH 123/226] sdg(phase-9): implement T13.4-8, writes create missing directories (FP-078) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 42 +++- test/suite/registry/section-13.4.ts | 310 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 4 + 3 files changed, 354 insertions(+), 2 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 81efccb..0325796 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3474,10 +3474,50 @@ certify against FP-091's fixtures once those land. unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091), S-1/S-5/certification green.] -- [ ] FP-078 — Implement T13.4-8: writes create missing directories +- [x] FP-078 — Implement T13.4-8: writes create missing directories (file-form move, section-form move target, first emission under nested `outDir`). [R2 #10; TEST-SPEC §13.4] Registry `section-13.4.ts`; map `"13.4"`. + [Done 2026-08-16: T13.4-8 registered in the existing section-13.4.ts + (the wrapper already declares the module array) with the three named + cases, each staged with its directories absent beforehand + (premise-asserted at the last instant before the write) and its fresh + components asserted as real directories afterward via lstat kind (a + symlink component would violate 13.4's never-traverse rule): file-form + `move specs/A.mdx new/deep/b.mdx` (destination in the configured spec + group, `new/` still absent after the premise build) exit 0 with the + moved file byte-identical at the destination (import- and + reference-free staging, so relocation changes no bytes; H-4 "6.5 move + edits"), origin absent, and the regenerated module `new/deep/b.xspec.ts` + plus emitted `new/deep/b.md` under the fresh directories (13.1's + in-source-directory name rule and 13.2's next-to-source default — the + two SPEC-pinned per-source derived paths; companion sets are latitude, + content other tests' subject); section-form `move specs/S.mdx#mv + fresh/sub/T.mdx#mv` (kept-ID cross-file move, valid per 6.5; target + path under absent `fresh/`) exit 0 with the created target file's + entire initial content byte-pinned to the moved construct + one U+000A + (6.5: created empty, top-level insertion at the start of the empty + file, no import additions required) plus module and Markdown under the + fresh directories; first emission under nested nonexistent + `outDir: "out/md"` (two sources, one nested deeper) — `build` exit 0, + the whole chain out → out/md → out/md/specs → out/md/specs/sub real + directories, both destinations written. Traceability "T13.4-8": + ["13.4"] (6.5/7.3/13.1/13.2 are carriage context with home coverage at + T6.5-*/T7.3-1/T13.1-*/T13.2-1; no numbered condition asserted — + FP-078's stated map); no certification scope (T13.4-5 remains §13.4's + only in-scope test). NOT red against this repo's product: move + directory-creation and nested-outDir emission predate the patch — + direct CLI probes of all three stagings returned exactly the staged + trees, bytes included, so the pass is genuine (the FP-010/FP-012/FP-014 + precedent); teeth live in the exact pins (exit 0, real-directory kinds, + byte-identical moved/created files, pinned derived paths) and in S-7, + where the new body fails diagnosed at its first `buildOk` against the + empty stub. Verified: typecheck/format clean; section-13.4 went + 1 failed/5 passed → 1 failed/6 passed (T13.4-8 green; T13.4-6 keeps its + pre-existing FP-001-class form-exact product red, confirmed unchanged + by a stashed control run); `npm run test:self` 268 passed, unchanged 2 + planned mid-loop reds (certification-document ×2 → FP-091), + S-1/S-5/S-7/certification green.] - [ ] FP-079 — Implement T14-6: stable codes — all 23 condition tokens read from each condition's stated reporter; `code` `null` for plain usage diff --git a/test/suite/registry/section-13.4.ts b/test/suite/registry/section-13.4.ts index 52aeb93..26719f4 100644 --- a/test/suite/registry/section-13.4.ts +++ b/test/suite/registry/section-13.4.ts @@ -1,7 +1,8 @@ // TEST-SPEC §13.4 (derived and durable files) — SUITE-47: T13.4-1 (plain // committable files + sorted keys), T13.4-2 (derived reproducibility), // T13.4-3 (orphan knowledge boundary), T13.4-4 (derived paths belong to -// xspec), T13.4-5 (durable protection), T13.4-6 (symlink write rules). +// xspec), T13.4-5 (durable protection), T13.4-6 (symlink write rules), +// T13.4-8 (writes create missing directories). // T13.4-7 registers no test body: its TEST-SPEC entry is a cross-reference — // T7-6 (section-7-discovery.ts) carries the `.xspec.` / `.xspec/` / // emit-destination source exclusion. (A registered no-op body would pass @@ -89,6 +90,23 @@ // above the workspace root are unrestricted (13.4), so `build`, a // journaled `rename`, and `check` must behave normally and land their // effects in the real root. +// - T13.4-8 stagings are import- and reference-free, so the file-form +// relocation changes no bytes of the moved file (SPEC 6.5: beyond the +// stated edits a move changes no bytes, and none applies) and the created +// target file's entire initial content is the moved section construct's +// own characters followed by one U+000A (SPEC 6.5: the target file is +// created empty; a top-level `new-id` inserts at the end of the file — +// the start of a line in an empty file, so no preceding terminator — and +// no import addition is required); both are asserted byte-exactly per H-4 +// ("6.5 move edits"). "Present as real directories afterward" is asserted +// via lstat kind — a symbolic link at a fresh component would violate +// 13.4's writes-never-traverse-links rule. The "regenerated derived files +// under the fresh directories" are asserted as the two SPEC-pinned +// per-source paths — the module `NAME.xspec.ts` in the source's directory +// (13.1) and the emitted `NAME.md` (13.2; next to the source by default, +// under `outDir` in the emission arm) — companion sets being +// implementation latitude (13.1) and content another test's subject +// (T13.1-*, T13.2-1, T3-*). import { Buffer } from "node:buffer"; import * as fsp from "node:fs/promises"; @@ -287,6 +305,19 @@ async function readFileDiagnosed( return await workspace.readBytes(rel); } +/** Assert the filesystem kind at a workspace-relative path, diagnosed. */ +async function assertKindIs( + workspace: TestWorkspace, + rel: string, + expected: "file" | "dir" | "absent", + context: string, +): Promise<void> { + const kind = await workspace.kind(rel); + if (kind !== expected) { + fail(`${context}; expected ${expected} at ${rel}, found ${kind}`); + } +} + /** `review status <name> --json`, decoded (SPEC 10.7). */ async function sessionStatus( product: ProductBinding, @@ -1666,6 +1697,282 @@ const T13_4_6 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T13.4-8 — writes create missing directories +// --------------------------------------------------------------------------- + +// File-form move arm: the destination `new/deep/b.mdx` lies in a configured +// spec group (SPEC 6.5's not-out-of-the-workspace refusal must not apply) +// while `new/` is absent — nothing stages it and no source lives there, so +// the premise build cannot create it either. +const NEW_GROUP_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx", "new/**/*.mdx"] + }, + markdown: { emit: true } +}) +`; + +// Section-form move arm: the created target path `fresh/sub/T.mdx` lies in a +// configured spec group, `fresh/` absent (as above). +const FRESH_GROUP_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx", "fresh/**/*.mdx"] + }, + markdown: { emit: true } +}) +`; + +// Emission arm: a nested `markdown.outDir` whose whole chain is nonexistent +// (`out/` absent; SPEC 7.3 — resolves within the root, workspace-relative +// paths preserved beneath it). +const NESTED_OUT_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + markdown: { emit: true, outDir: "out/md" } +}) +`; + +// The relocated file: import- and reference-free, so relocation rewrites +// nothing and the moved file is byte-identical at its destination (module +// header; SPEC 6.5). +const RELOCATED_MDX = ['<S id="a">', "Alpha text.", "</S>", ""].join("\n"); + +// The section-form origin: `mv` is the moved subtree (kept-ID cross-file +// move, valid per SPEC 6.5), `stay` keeps the origin file non-empty. +const MOVED_CONSTRUCT = ['<S id="mv">', "Moved text.", "</S>"].join("\n"); +const SECTION_ORIGIN_MDX = [ + '<S id="stay">', + "Stay text.", + "</S>", + "", + MOVED_CONSTRUCT, + "", +].join("\n"); +// The created target file's entire initial content (module header; SPEC 6.5). +const CREATED_TARGET_BYTES = `${MOVED_CONSTRUCT}\n`; + +const T13_4_8 = defineProductTest({ + id: "T13.4-8", + title: + "a missing intermediate directory never refuses or fails a write — the nonexistent workspace-relative directory components of a written path come into existence as real directories, each case staged with its directories absent beforehand: a file-form move to `new/deep/b.mdx` (destination in a configured spec group, `new/` absent) succeeds with the moved file byte-identical and its regenerated derived files under the fresh directories; a section-form move whose created target file lies under an absent directory succeeds likewise; a first emission under the nested nonexistent `markdown.outDir` writes every destination, creating the chain (SPEC 13.4, 6.5, 7.3, 13.1, 13.2)", + run: async (product) => { + // --- File-form move: destination directories `new/deep/` absent --- + await withWorkspace( + { + files: { + "xspec.config.ts": NEW_GROUP_CONFIG, + "specs/A.mdx": RELOCATED_MDX, + }, + }, + async (workspace) => { + await buildOk(product, workspace, "T13.4-8 (file-form move) `build`"); + await assertKindIs( + workspace, + "new", + "absent", + "T13.4-8 (file-form move): staging premise — the destination's " + + "directory components do not exist before the move (TEST-SPEC " + + "13.4: staged with its directories absent beforehand)", + ); + await expectExit( + product, + workspace, + ["move", A_ROOT, "new/deep/b.mdx"], + 0, + "T13.4-8 (file-form move) `move specs/A.mdx new/deep/b.mdx` — a " + + "missing intermediate directory never refuses or fails a " + + "write: a nonexistent component is never a refusal cause (SPEC " + + "13.4, 6.5)", + ); + for (const dir of ["new", "new/deep"]) { + await assertKindIs( + workspace, + dir, + "dir", + "T13.4-8 (file-form move): the fresh destination directory " + + "components come into existence as real directories (SPEC " + + "13.4)", + ); + } + assertBytesEqual( + await readFileDiagnosed( + workspace, + "new/deep/b.mdx", + "T13.4-8 (file-form move): the moved file under the fresh " + + "directories (SPEC 13.4, 6.5)", + ), + RELOCATED_MDX, + "T13.4-8 (file-form move): the moved file at its destination — " + + "import- and reference-free, so relocation changes none of its " + + "bytes (SPEC 6.5; H-4)", + ); + await assertKindIs( + workspace, + A_ROOT, + "absent", + "T13.4-8 (file-form move): the origin path after the relocation " + + "(SPEC 6.5)", + ); + await assertKindIs( + workspace, + "new/deep/b.xspec.ts", + "file", + "T13.4-8 (file-form move): the regenerated module under the " + + "fresh directories — generated in the source file's directory " + + "(SPEC 13.4, 13.1, 6.5)", + ); + await assertKindIs( + workspace, + "new/deep/b.md", + "file", + "T13.4-8 (file-form move): the re-emitted Markdown under the " + + "fresh directories — emitted next to the source (SPEC 13.4, " + + "13.2, 7.3)", + ); + }, + ); + + // --- Section-form move: the created target file (SPEC 6.5) lies under + // the absent directory `fresh/sub/` --- + await withWorkspace( + { + files: { + "xspec.config.ts": FRESH_GROUP_CONFIG, + "specs/S.mdx": SECTION_ORIGIN_MDX, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T13.4-8 (section-form move) `build`", + ); + await assertKindIs( + workspace, + "fresh", + "absent", + "T13.4-8 (section-form move): staging premise — the created " + + "target file's directory components do not exist before the " + + "move (TEST-SPEC 13.4)", + ); + await expectExit( + product, + workspace, + ["move", "specs/S.mdx#mv", "fresh/sub/T.mdx#mv"], + 0, + "T13.4-8 (section-form move) `move specs/S.mdx#mv " + + "fresh/sub/T.mdx#mv` — the created target file's missing " + + "directories never refuse or fail the write (SPEC 13.4, 6.5; " + + "a cross-file section move keeping its ID is valid)", + ); + for (const dir of ["fresh", "fresh/sub"]) { + await assertKindIs( + workspace, + dir, + "dir", + "T13.4-8 (section-form move): the created target file's fresh " + + "directory components come into existence as real " + + "directories (SPEC 13.4)", + ); + } + assertBytesEqual( + await readFileDiagnosed( + workspace, + "fresh/sub/T.mdx", + "T13.4-8 (section-form move): the created target file under " + + "the fresh directories (SPEC 13.4, 6.5)", + ), + CREATED_TARGET_BYTES, + "T13.4-8 (section-form move): the created target file's entire " + + "initial content — created empty, the moved construct inserted " + + "at the start of the new file followed by one U+000A, no " + + "import additions required (SPEC 6.5; H-4)", + ); + await assertKindIs( + workspace, + "fresh/sub/T.xspec.ts", + "file", + "T13.4-8 (section-form move): the created target's regenerated " + + "module under the fresh directories (SPEC 13.4, 13.1)", + ); + await assertKindIs( + workspace, + "fresh/sub/T.md", + "file", + "T13.4-8 (section-form move): the created target's emitted " + + "Markdown under the fresh directories (SPEC 13.4, 13.2, 7.3)", + ); + }, + ); + + // --- First emission under a nested nonexistent `markdown.outDir`: no + // build has ever run and the whole `out/md/…` chain is absent; the + // nested source pins the chain below the outDir too (SPEC 7.3 preserves + // workspace-relative paths) --- + await withWorkspace( + { + files: { + "xspec.config.ts": NESTED_OUT_CONFIG, + "specs/A.mdx": RELOCATED_MDX, + "specs/sub/B.mdx": B_MDX, + }, + }, + async (workspace) => { + await assertKindIs( + workspace, + "out", + "absent", + "T13.4-8 (first emission): staging premise — the `outDir` chain " + + "does not exist before the first emission (TEST-SPEC 13.4)", + ); + await buildOk( + product, + workspace, + "T13.4-8 (first emission) `build` — a first emission under a " + + "nested nonexistent `markdown.outDir` never refuses or fails " + + "(SPEC 13.4, 7.3)", + ); + for (const dir of [ + "out", + "out/md", + "out/md/specs", + "out/md/specs/sub", + ]) { + await assertKindIs( + workspace, + dir, + "dir", + "T13.4-8 (first emission): every directory component of the " + + "emit destinations comes into existence as a real directory " + + "— the chain is created (SPEC 13.4, 7.3)", + ); + } + for (const destination of [ + "out/md/specs/A.md", + "out/md/specs/sub/B.md", + ]) { + await assertKindIs( + workspace, + destination, + "file", + "T13.4-8 (first emission): every destination is written under " + + "the created chain, workspace-relative paths preserved (SPEC " + + "13.4, 13.2, 7.3)", + ); + } + }, + ); + }, +}); + /** TEST-SPEC §13.4, in canonical ID order (SUITE-47). */ export const section134Tests: readonly ProductTestEntry[] = [ T13_4_1, @@ -1674,4 +1981,5 @@ export const section134Tests: readonly ProductTestEntry[] = [ T13_4_4, T13_4_5, T13_4_6, + T13_4_8, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 9227bae..2b26db1 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -395,6 +395,10 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T13.4-4": ["13.4"], "T13.4-5": ["13.4"], "T13.4-6": ["13.4", "14"], + // T13.4-8's 6.5/7.3/13.1/13.2 citations are carriage context with home + // coverage at T6.5-*/T7.3-1/T13.1-*/T13.2-1; no numbered condition is + // asserted (success paths only). + "T13.4-8": ["13.4"], "T13.5-1": ["13.5"], "T13.5-2": ["13.5"], "T13.5-3": ["13.5"], From 56dde0b5e17d2266763002d808b997470b13bd13 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 16 Aug 2026 23:27:04 +0000 Subject: [PATCH 124/226] sdg(phase-9): implement T14-6, the stable-code sweep (FP-079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For each of the 23 SPEC 14 conditions, staged via its primary test's fixture and read from one stated reporter of T14-4's matrix, the finding must carry the exact token as its `code` — strict equality against the harness-pinned table, at least one finding, every finding the staged token. Plain usage errors (T12.7-3's staging) and review-operation refusals (T12.7-1's) assert `code` null. The five specially-reported conditions' stagings are hoisted out of T14-4's dedicated arms into shared constants (a pure lift). Traceability: "T14-6": ["14"]. Red-as-diagnosed at the first arm's form-exact findings decode (the FP-001-class product gap); test:self unchanged — 268 passed, the 2 planned certification-document reds (FP-091). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 42 +- test/suite/registry/section-14.ts | 1002 +++++++++++++++++---------- test/suite/registry/traceability.ts | 4 + 3 files changed, 690 insertions(+), 358 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 0325796..3b68409 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3519,10 +3519,50 @@ certify against FP-091's fixtures once those land. planned mid-loop reds (certification-document ×2 → FP-091), S-1/S-5/S-7/certification green.] -- [ ] FP-079 — Implement T14-6: stable codes — all 23 condition tokens read +- [x] FP-079 — Implement T14-6: stable codes — all 23 condition tokens read from each condition's stated reporter; `code` `null` for plain usage errors and review refusals. [R2 #11; TEST-SPEC §14] Registry `section-14.ts`; map `"14"`. + [Done 2026-08-16: T14-6 registered in section-14.ts (the wrapper already + declares the module's array; traceability "T14-6": ["14"] — 12.7/12.0 + carriage context, the T12.7-* precedent). Each condition staged via its + primary-fixture form and read from ONE stated reporter of T14-4's + matrix: the 18 both-reporter conditions ride T14-4's own SWEEP_ENTRIES + stagings read via `build --json`; the five specially-reported + conditions (14.10 stale → `check`; 14.12 policy → `check`; 14.14 + unknown key → `build --json`'s exit-2 error document; 14.21 garbage + session → `check`; 14.23 shape-blind record corruption → `inventory` + at exit 1) ride stagings hoisted out of T14-4's dedicated arms into + shared module constants (STALE_DECL/STALE_EDIT, POLICY_DECL, + VALID_SPECS_DECL, GARBAGE_SESSION_*, BOGUS_KEY_DECL — a pure lift, + T14-4's assertions untouched). Per-arm assertion + (`assertExactCodeToken`): at least one finding and EVERY finding's + `code` strictly equal to the harness-pinned token + (CONDITION_CODE_TOKENS[N-1]) — sound because every staging stages + exactly one condition (T14-4 pins the counts; 14.3's per-occurrence + tolerance and 14.10's several stale files collapse into it) — so an + omitted, misspelled, null, wrong-condition, or numeral-decorated code + fails even with exit class and locations right; count precision and + reporter breadth stay T14-4's and the home tests'. The `code`-null + arms mirror T14-6's own citations: T12.7-3's unknown command beside + `--json` (error document, code null) and T12.7-1 Arm D's + duplicate-name `review create --strategy audit` refusal + (findings-only report, every finding code null). No certification + scope (CERTIFICATIONS.md certifies the code contracts + representatively via CONF-AVAIL's datum-form violators). Verified: + typecheck/format clean; T14-6 red-as-diagnosed at its first sweep + arm's form-exact decode ("expected no member \"condition\"" — the + FP-001-class product gap), T14-1..T14-5 failing exactly as before + (T14-4 still at its 14.10 arm — the hoist is semantics-preserving); + direct CLI probes of the T14-6-specific reads against the built + product: the review-create refusal fires (old-shape `{"refused":…}` → + the findings-report decode gap), `inventory` is unknown-command exit 2 + (patch-new surface), unknown-command and bogus-config `--json` exit 2 + with empty stdout (the FP-002-class error-document gap) — every arm + red at a known product-gap class with its staging premise holding. + `npm run test:self` 268 passed, unchanged 2 planned mid-loop reds + (certification-document ×2 → FP-091); S-1/S-5/S-7/certification green + (S-7 sweeps T14-6 against the stub, diagnosed).] - [ ] FP-080 — Implement T14-7: refusal reasons — each stable refusal code with concerned file/range/identity; all-applicable-reasons-together; the invalid-workspace refusal reporting numbered findings alone. [R2 #12; diff --git a/test/suite/registry/section-14.ts b/test/suite/registry/section-14.ts index fd578a8..39c4a95 100644 --- a/test/suite/registry/section-14.ts +++ b/test/suite/registry/section-14.ts @@ -1,5 +1,5 @@ // TEST-SPEC §14 (validation errors: the reporting contract) — SUITE-49: -// T14-1 … T14-5. +// T14-1 … T14-6. // // Sections 1–13 exercise each numbered condition in its home context; these // are the reporting-contract tests: multi-error completeness with @@ -7,8 +7,10 @@ // conditions 14.5/14.6/14.7 plus the consumer-side type error (T14-2), // masking by unparseable files and by configuration errors (T14-3), the // reporter matrix — which of `build`/`check`/`review`/the machine-interface -// surfaces reports which condition (T14-4) — and grammar selection by file -// name (T14-5). +// surfaces reports which condition (T14-4) — grammar selection by file +// name (T14-5), and the stable-code contract — each of the 23 conditions' +// exact token as the finding's `code`, `null` where 14 assigns none +// (T14-6). // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -88,10 +90,26 @@ // breadth and modifies-nothing compares are T13.3-3's), while the three // surfaces answer finding-free at exit 0 over the staged valid spec // source. Per-surface semantics depth is T11.2-*..T11.5-*'s subject. +// - T14-6 stages each condition via its primary test's fixture — the same +// minimal home-form stagings T14-4 sweeps, plus the five specially +// reported conditions' stagings (14.10, 14.12, 14.14, 14.21, 14.23), +// hoisted below and shared with T14-4's dedicated arms — and reads it +// from ONE stated reporter of T14-4's matrix: `build` for every +// both-reporter condition, `check` for 14.10/14.12/14.21, the exit-2 +// error document for 14.14, `inventory` for 14.23. Its assertion is the +// code value alone: at least one finding, every finding carrying the +// staged condition's exact token — sound because every staging stages +// exactly one condition (T14-4 pins the counts; 14.3's per-occurrence +// tolerance and several stale files under 14.10 both collapse into +// "every finding carries the one staged token"). Count precision and +// reporter breadth stay T14-4's and the home tests' subject; the +// `code`-null arms mirror T12.7-3's plain-usage-error and T12.7-1's +// review-refusal stagings, per T14-6's own citations. import { Buffer } from "node:buffer"; import type { Finding, GraphEdge } from "../../helpers/adapters/index.js"; import { + CONDITION_CODE_TOKENS, assertReportMentions, corruptGraphDataShapeBlind, decodeAtReport, @@ -125,6 +143,7 @@ import { buildOk, byteWindow, expectConfigurationError, + expectErrorDocument, expectExit, runCli, runJson, @@ -962,7 +981,8 @@ const SWEEP_ENTRIES: readonly SweepEntry[] = [ await buildOk( product, workspace, - "T14-4 (journal error) staging `build` (SPEC 12.1)", + "section-14 (journal error) staging `build` (SPEC 12.1; the " + + "staging is shared by T14-4's sweep and T14-6's)", ); await workspace.file(".xspec/journal", GARBAGE_JOURNAL_LINE); }, @@ -1039,6 +1059,86 @@ export default defineConfig({ }, ]; +// --------------------------------------------------------------------------- +// Stagings shared by T14-4's dedicated reporter arms and T14-6's stable-code +// sweep — one per specially-reported condition, each the minimal +// primary-fixture form of the TEST-SPEC 14 preamble's per-condition record +// --------------------------------------------------------------------------- + +// 14.10 (T12.2-2's fixture): build, then edit the source — Markdown emission +// on, so the emitted file's bytes are the compiled source and the staged +// staleness is certainly detectable. +const STALE_DECL: WorkspaceDecl = { + files: { + "xspec.config.ts": markdownConfig(true), + "specs/a.mdx": '<S id="a1">\nAlpha behavior.\n</S>\n', + }, +}; +const STALE_EDIT = '<S id="a1">\nAlpha behavior, edited.\n</S>\n'; + +// 14.12 (T7.5-2's fixture): one forbidden rule, one violating dependence. +const POLICY_DECL: WorkspaceDecl = { + files: { + "xspec.config.ts": `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + hi: ["hi/**/*.mdx"], + lo: ["lo/**/*.mdx"] + }, + policy: [ + { + name: "no-hi-to-lo", + type: "forbidden", + from: { group: "hi" }, + to: { group: "lo" } + } + ] +}) +`, + "hi/H.mdx": [ + 'import L from "../lo/L.xspec"', + "", + '<S id="h1" d={L.l1}>', + "Violating dependence.", + "</S>", + "", + ].join("\n"), + "lo/L.mdx": ['<S id="l1">', "Low one.", "</S>", ""].join("\n"), + }, +}; + +// A minimal valid workspace (one spec group, one valid source): the ground +// the 14.21/14.23 corruptions — and T14-6's code-null arms — are staged on. +const VALID_SPECS_DECL: WorkspaceDecl = { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/a.mdx": '<S id="a1">\nValid behavior.\n</S>\n', + }, +}; + +// 14.21 (T10.1-4's fixture): a session file that cannot be parsed. +const GARBAGE_SESSION_PATH = ".xspec/reviews/bad.json"; +const GARBAGE_SESSION_CONTENT = "{ this is not a parseable session"; + +// 14.14 (the T7-2 attribution discipline, as in T14-3's configuration arm): +// the canonical valid configuration plus one unknown top-level key, so the +// error is attributable to that one defect, beside a valid source. +const BOGUS_KEY_DECL: WorkspaceDecl = { + files: { + "xspec.config.ts": `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + bogus: true +}) +`, + "specs/a.mdx": '<S id="a1">\nValid behavior.\n</S>\n', + }, +}; + /** * One availability-surface probe (SPEC 11.2, 11.3–11.5): the invocation * paired with the form-exact 12.7 document decode, so asserting the decoded @@ -1111,395 +1211,307 @@ const T14_4 = defineProductTest({ timeoutMs: 480_000, run: async (product) => { // --- 14.10: check-only. A stale workspace `build`s successfully by - // regenerating (Markdown emission on: the emitted file's bytes are the - // compiled source, so the staged staleness is certainly detectable). - await withWorkspace( - { - files: { - "xspec.config.ts": markdownConfig(true), - "specs/a.mdx": '<S id="a1">\nAlpha behavior.\n</S>\n', - }, - }, - async (workspace) => { - await buildOk( - product, - workspace, - "T14-4 (14.10) staging `build` (SPEC 12.1)", - ); - await workspace.file( - "specs/a.mdx", - '<S id="a1">\nAlpha behavior, edited.\n</S>\n', - ); - const context = "T14-4 (14.10) `check --json` on the stale workspace"; - const findings = await checkFindings(product, workspace, context); - if ( - findings.length === 0 || - findings.some((finding) => finding.condition !== "14.10") - ) { - fail( - `${context}: staleness is the workspace's only staged error ` + - `condition, so \`check\` reports at least one finding and ` + - `every finding is 14.10 (SPEC 12.2, 14.10); got ` + - JSON.stringify(findings.map((finding) => finding.condition)), - ); - } - await expectExit( - product, - workspace, - ["build"], - 0, - "T14-4 (14.10) `build` on the stale workspace — `build` cannot " + - "observe staleness because it regenerates every derived file: " + - "14.10 is reported by `check` only (SPEC 14.10, 12.1)", - ); - await expectExit( - product, - workspace, - ["check"], - 0, - "T14-4 (14.10) `check` after the rebuild — the successful " + - "`build` resolved the staleness by regenerating (SPEC 12.1, 14.10)", + // regenerating (STALE_DECL: Markdown emission on, so the staged + // staleness is certainly detectable). + await withWorkspace(STALE_DECL, async (workspace) => { + await buildOk( + product, + workspace, + "T14-4 (14.10) staging `build` (SPEC 12.1)", + ); + await workspace.file("specs/a.mdx", STALE_EDIT); + const context = "T14-4 (14.10) `check --json` on the stale workspace"; + const findings = await checkFindings(product, workspace, context); + if ( + findings.length === 0 || + findings.some((finding) => finding.condition !== "14.10") + ) { + fail( + `${context}: staleness is the workspace's only staged error ` + + `condition, so \`check\` reports at least one finding and ` + + `every finding is 14.10 (SPEC 12.2, 14.10); got ` + + JSON.stringify(findings.map((finding) => finding.condition)), ); - }, - ); + } + await expectExit( + product, + workspace, + ["build"], + 0, + "T14-4 (14.10) `build` on the stale workspace — `build` cannot " + + "observe staleness because it regenerates every derived file: " + + "14.10 is reported by `check` only (SPEC 14.10, 12.1)", + ); + await expectExit( + product, + workspace, + ["check"], + 0, + "T14-4 (14.10) `check` after the rebuild — the successful " + + "`build` resolved the staleness by regenerating (SPEC 12.1, 14.10)", + ); + }); // --- 14.12: check-only. A policy-violating workspace `build`s - // successfully; `check` reports the violation. - await withWorkspace( - { - files: { - "xspec.config.ts": `import { defineConfig } from "xspec" - -export default defineConfig({ - specs: { - hi: ["hi/**/*.mdx"], - lo: ["lo/**/*.mdx"] - }, - policy: [ - { - name: "no-hi-to-lo", - type: "forbidden", - from: { group: "hi" }, - to: { group: "lo" } - } - ] -}) -`, - "hi/H.mdx": [ - 'import L from "../lo/L.xspec"', - "", - '<S id="h1" d={L.l1}>', - "Violating dependence.", - "</S>", - "", - ].join("\n"), - "lo/L.mdx": ['<S id="l1">', "Low one.", "</S>", ""].join("\n"), - }, - }, - async (workspace) => { - await buildOk( - product, - workspace, - "T14-4 (14.12) `build` over the policy-violating workspace — " + - "policy violations are `check` findings, and `build` succeeds " + - "and regenerates regardless (SPEC 14.12, 12.1, 7.5)", - ); - assertConditionCounts( - await checkFindings( - product, - workspace, - "T14-4 (14.12) `check --json`", - ), - { "14.12": 1 }, - "T14-4 (14.12) `check` reports the one violating edge — the " + - "freshly built workspace stages nothing else (SPEC 14.12, 12.2)", - ); - }, - ); + // successfully; `check` reports the violation (POLICY_DECL). + await withWorkspace(POLICY_DECL, async (workspace) => { + await buildOk( + product, + workspace, + "T14-4 (14.12) `build` over the policy-violating workspace — " + + "policy violations are `check` findings, and `build` succeeds " + + "and regenerates regardless (SPEC 14.12, 12.1, 7.5)", + ); + assertConditionCounts( + await checkFindings(product, workspace, "T14-4 (14.12) `check --json`"), + { "14.12": 1 }, + "T14-4 (14.12) `check` reports the one violating edge — the " + + "freshly built workspace stages nothing else (SPEC 14.12, 12.2)", + ); + }); // --- 14.21: reported by `check`, by `review` subcommands naming the - // session, and by `review list` — not by `build`. - await withWorkspace( - { - files: { - "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/a.mdx": '<S id="a1">\nValid behavior.\n</S>\n', - }, - }, - async (workspace) => { - await buildOk( - product, - workspace, - "T14-4 (14.21) staging `build` (SPEC 12.1)", + // session, and by `review list` — not by `build` (VALID_SPECS_DECL plus + // the garbage session file). + await withWorkspace(VALID_SPECS_DECL, async (workspace) => { + await buildOk( + product, + workspace, + "T14-4 (14.21) staging `build` (SPEC 12.1)", + ); + await workspace.file(GARBAGE_SESSION_PATH, GARBAGE_SESSION_CONTENT); + await expectExit( + product, + workspace, + ["build"], + 0, + "T14-4 (14.21) `build` beside the corrupt session — `build` does " + + "not read sessions, so 14.21 is not its finding (SPEC 14.21)", + ); + assertConditionCounts( + await checkFindings(product, workspace, "T14-4 (14.21) `check --json`"), + { "14.21": 1 }, + "T14-4 (14.21) `check` reports the one corrupt session — the " + + "just-rebuilt workspace stages nothing else (SPEC 14.21, 12.2)", + ); + for (const argv of [ + ["review", "status", "bad"], + ["review", "list"], + ] as const) { + const context = `T14-4 (14.21) \`${argv.join(" ")}\``; + const result = await runCli(product, workspace, argv); + assertExitCode( + result, + 1, + `${context} — a review subcommand naming a corrupt session, and ` + + `\`review list\` reporting one, exit 1 (SPEC 14.21, 10.1, ` + + `10.7, 12.0)`, ); - await workspace.file( - ".xspec/reviews/bad.json", - "{ this is not a parseable session", + assertReportMentions( + result, + [/corrupt/i], + `${context} — the report identifies the session as corrupt ` + + `(SPEC 10.1/14.21 vocabulary; findings are standard-output ` + + `content, 12.0; information presence, never exact wording, H-3)`, ); - await expectExit( + } + + // On a workspace failing `build`'s validations, 14.21 is reported + // by `check` alone, beside the gate's findings: no session is read + // on the failing side, so the gated `review` reads report exactly + // the gate's findings — the validation errors, no condition-21 + // finding beside them (SPEC 14.21, 13.3, 10.1; membership only, the + // module header — the every-subcommand breadth, modifies-nothing + // compares, and bytes-untouched assertions are T10.1-5's). + await workspace.file("specs/a.mdx", "<S>\nNo id.\n</S>\n"); + assertConditionCounts( + await buildFindings( product, workspace, - ["build"], - 0, - "T14-4 (14.21) `build` beside the corrupt session — `build` does " + - "not read sessions, so 14.21 is not its finding (SPEC 14.21)", - ); - assertConditionCounts( + "T14-4 (14.21, failing workspace) `build --json`", + ), + { "14.1": 1 }, + "T14-4 (14.21, failing workspace) `build` reports the validation " + + "error alone — `build` does not read sessions, so 14.21 is " + + "never its finding (SPEC 14.21, 12.1)", + ); + assertConditionCounts( + nonStale( await checkFindings( product, workspace, - "T14-4 (14.21) `check --json`", + "T14-4 (14.21, failing workspace) `check --json`", ), - { "14.21": 1 }, - "T14-4 (14.21) `check` reports the one corrupt session — the " + - "just-rebuilt workspace stages nothing else (SPEC 14.21, 12.2)", + ), + { "14.1": 1, "14.21": 1 }, + "T14-4 (14.21, failing workspace) `check` reports 14.21 beside " + + "the failing workspace's other findings — the validation error " + + "and the corrupt session together, counted exactly over the " + + "non-14.10 findings (SPEC 14.21, 12.2; module header)", + ); + for (const argv of [ + ["review", "status", "bad", "--json"], + ["review", "list", "--json"], + ] as const) { + const context = `T14-4 (14.21, failing workspace) \`${argv.join(" ")}\``; + const result = await expectExit( + product, + workspace, + argv, + 1, + `${context} — on a workspace failing \`build\`'s validations a ` + + `gated read reports the gate's findings and exits 1 without ` + + `answering (SPEC 13.3, 12.0)`, ); - for (const argv of [ - ["review", "status", "bad"], - ["review", "list"], - ] as const) { - const context = `T14-4 (14.21) \`${argv.join(" ")}\``; - const result = await runCli(product, workspace, argv); - assertExitCode( - result, - 1, - `${context} — a review subcommand naming a corrupt session, and ` + - `\`review list\` reporting one, exit 1 (SPEC 14.21, 10.1, ` + - `10.7, 12.0)`, - ); - assertReportMentions( - result, - [/corrupt/i], - `${context} — the report identifies the session as corrupt ` + - `(SPEC 10.1/14.21 vocabulary; findings are standard-output ` + - `content, 12.0; information presence, never exact wording, H-3)`, - ); - } - - // On a workspace failing `build`'s validations, 14.21 is reported - // by `check` alone, beside the gate's findings: no session is read - // on the failing side, so the gated `review` reads report exactly - // the gate's findings — the validation errors, no condition-21 - // finding beside them (SPEC 14.21, 13.3, 10.1; membership only, the - // module header — the every-subcommand breadth, modifies-nothing - // compares, and bytes-untouched assertions are T10.1-5's). - await workspace.file("specs/a.mdx", "<S>\nNo id.\n</S>\n"); assertConditionCounts( - await buildFindings( - product, - workspace, - "T14-4 (14.21, failing workspace) `build --json`", - ), + decodeFindingsReport(parseJsonStdout(result, context), context) + .findings, { "14.1": 1 }, - "T14-4 (14.21, failing workspace) `build` reports the validation " + - "error alone — `build` does not read sessions, so 14.21 is " + - "never its finding (SPEC 14.21, 12.1)", - ); - assertConditionCounts( - nonStale( - await checkFindings( - product, - workspace, - "T14-4 (14.21, failing workspace) `check --json`", - ), - ), - { "14.1": 1, "14.21": 1 }, - "T14-4 (14.21, failing workspace) `check` reports 14.21 beside " + - "the failing workspace's other findings — the validation error " + - "and the corrupt session together, counted exactly over the " + - "non-14.10 findings (SPEC 14.21, 12.2; module header)", + `${context} — exactly the gate's findings: no session file is ` + + `read on a failing workspace, so no condition-21 finding is ` + + `reported beside them — on this workspace 14.21 is \`check\`'s ` + + `alone (SPEC 14.21, 13.3, 10.1; depth: T10.1-5)`, ); - for (const argv of [ - ["review", "status", "bad", "--json"], - ["review", "list", "--json"], - ] as const) { - const context = `T14-4 (14.21, failing workspace) \`${argv.join(" ")}\``; - const result = await expectExit( - product, - workspace, - argv, - 1, - `${context} — on a workspace failing \`build\`'s validations a ` + - `gated read reports the gate's findings and exits 1 without ` + - `answering (SPEC 13.3, 12.0)`, - ); - assertConditionCounts( - decodeFindingsReport(parseJsonStdout(result, context), context) - .findings, - { "14.1": 1 }, - `${context} — exactly the gate's findings: no session file is ` + - `read on a failing workspace, so no condition-21 finding is ` + - `reported beside them — on this workspace 14.21 is \`check\`'s ` + - `alone (SPEC 14.21, 13.3, 10.1; depth: T10.1-5)`, - ); - } - }, - ); + } + }); // --- 14.23: reported by `inventory` and `rename`/`move` previews only — // `check` reports the state as 14.10's unit form, and `build` and the // refreshing reads never do: the rebuild replaces the record; the reads // leave it unconsulted (SPEC 14.23, 14.10, 13.3, 11.6, 6.6; membership // by exact counts per the module header — depth: T11.6-4, T6.6-6, - // T12.2-2, T13.3-2). - await withWorkspace( - { - files: { - "xspec.config.ts": SPECS_ONLY_CONFIG, - "specs/a.mdx": '<S id="a1">\nValid behavior.\n</S>\n', - }, - }, - async (workspace) => { - await buildOk( - product, - workspace, - "T14-4 (14.23) staging `build` — the corruption applies to a " + - "record the product itself wrote (SPEC 12.1, 13.3; H-3)", - ); - await corruptGraphDataShapeBlind(workspace.root, "T14-4 (14.23)"); + // T12.2-2, T13.3-2). Staged on VALID_SPECS_DECL. + await withWorkspace(VALID_SPECS_DECL, async (workspace) => { + await buildOk( + product, + workspace, + "T14-4 (14.23) staging `build` — the corruption applies to a " + + "record the product itself wrote (SPEC 12.1, 13.3; H-3)", + ); + await corruptGraphDataShapeBlind(workspace.root, "T14-4 (14.23)"); - const inventoryContext = "T14-4 (14.23) `inventory`"; - assertConditionCounts( - decodeInventoryFindings( - await runJsonExpecting( - product, - workspace, - ["inventory"], - 1, - `${inventoryContext} — the condition-23 finding accompanies ` + - `the answer and the invocation exits 1 (SPEC 14.23, 11.6)`, - ), - inventoryContext, + const inventoryContext = "T14-4 (14.23) `inventory`"; + assertConditionCounts( + decodeInventoryFindings( + await runJsonExpecting( + product, + workspace, + ["inventory"], + 1, + `${inventoryContext} — the condition-23 finding accompanies ` + + `the answer and the invocation exits 1 (SPEC 14.23, 11.6)`, ), - { "14.23": 1 }, - `${inventoryContext} — the unreadable record is the inventory ` + - `answer's one finding on the otherwise clean workspace (SPEC ` + - `14.23, 11.6)`, - ); - - const previewContext = - "T14-4 (14.23) `rename specs/a.mdx a1 a2 --preview --json`"; - assertConditionCounts( - decodePreviewReport( - await runJsonExpecting( - product, - workspace, - ["rename", "specs/a.mdx", "a1", "a2", "--preview", "--json"], - 1, - `${previewContext} — the condition-23 finding accompanies the ` + - `answer and the invocation exits 1 (SPEC 14.23, 6.6)`, - ), - previewContext, - ).findings, - { "14.23": 1 }, - `${previewContext} — the preview consults the record for its ` + - `delta, so the otherwise valid plan's report carries exactly ` + - `the condition-23 finding (SPEC 14.23, 6.6; the delta's ` + - `unavailability and the plan's completeness are T6.6-6's)`, - ); + inventoryContext, + ), + { "14.23": 1 }, + `${inventoryContext} — the unreadable record is the inventory ` + + `answer's one finding on the otherwise clean workspace (SPEC ` + + `14.23, 11.6)`, + ); - assertConditionCounts( - await checkFindings( + const previewContext = + "T14-4 (14.23) `rename specs/a.mdx a1 a2 --preview --json`"; + assertConditionCounts( + decodePreviewReport( + await runJsonExpecting( product, workspace, - "T14-4 (14.23) `check --json`", + ["rename", "specs/a.mdx", "a1", "a2", "--preview", "--json"], + 1, + `${previewContext} — the condition-23 finding accompanies the ` + + `answer and the invocation exits 1 (SPEC 14.23, 6.6)`, ), - { "14.10": 1 }, - "T14-4 (14.23) `check` reports the state as staleness — exactly " + - "one condition-10 finding, the unit form: never 14.23, never " + - "the mismatch form or a per-file finding beside it on the " + - "freshly built, otherwise clean workspace (SPEC 14.23, 14.10; " + - "depth: T12.2-2)", - ); + previewContext, + ).findings, + { "14.23": 1 }, + `${previewContext} — the preview consults the record for its ` + + `delta, so the otherwise valid plan's report carries exactly ` + + `the condition-23 finding (SPEC 14.23, 6.6; the delta's ` + + `unavailability and the plan's completeness are T6.6-6's)`, + ); - await expectExit( - product, - workspace, - ["query", "nodes"], - 0, - "T14-4 (14.23) `query nodes` on the corrupt-record state — the " + - "refreshing reads never report 14.23: they leave the record " + - "unconsulted and answer finding-free, exit 0 (SPEC 14.23, 13.3; " + - "depth: T13.3-2)", - ); + assertConditionCounts( + await checkFindings(product, workspace, "T14-4 (14.23) `check --json`"), + { "14.10": 1 }, + "T14-4 (14.23) `check` reports the state as staleness — exactly " + + "one condition-10 finding, the unit form: never 14.23, never " + + "the mismatch form or a per-file finding beside it on the " + + "freshly built, otherwise clean workspace (SPEC 14.23, 14.10; " + + "depth: T12.2-2)", + ); - await expectExit( - product, - workspace, - ["build"], - 0, - "T14-4 (14.23) `build` on the corrupt-record state — `build` " + - "never reports 14.23: its rebuild replaces the record (SPEC " + - "14.23, 12.1)", - ); - await expectExit( - product, - workspace, - ["check"], - 0, - "T14-4 (14.23) `check` after the rebuild — the successful " + - "`build` replaced the unreadable state (SPEC 14.23, 12.1, 13.3)", - ); - }, - ); + await expectExit( + product, + workspace, + ["query", "nodes"], + 0, + "T14-4 (14.23) `query nodes` on the corrupt-record state — the " + + "refreshing reads never report 14.23: they leave the record " + + "unconsulted and answer finding-free, exit 0 (SPEC 14.23, 13.3; " + + "depth: T13.3-2)", + ); + + await expectExit( + product, + workspace, + ["build"], + 0, + "T14-4 (14.23) `build` on the corrupt-record state — `build` " + + "never reports 14.23: its rebuild replaces the record (SPEC " + + "14.23, 12.1)", + ); + await expectExit( + product, + workspace, + ["check"], + 0, + "T14-4 (14.23) `check` after the rebuild — the successful " + + "`build` replaced the unreadable state (SPEC 14.23, 12.1, 13.3)", + ); + }); // --- 14.14: reported by `build` and `check` alike — as the // every-command usage error of its entry (exit 2, not a finding). - await withWorkspace( - { - files: { - "xspec.config.ts": `import { defineConfig } from "xspec" + // Staged on BOGUS_KEY_DECL. + await withWorkspace(BOGUS_KEY_DECL, async (workspace) => { + await expectConfigurationError( + product, + workspace, + ["build"], + "T14-4 (14.14) `build` under an unknown configuration key " + + "(SPEC 14.14, 7, 12.0)", + ); + await expectConfigurationError( + product, + workspace, + ["check"], + "T14-4 (14.14) `check` under the same configuration (SPEC 14.14, " + + "7, 12.0)", + ); -export default defineConfig({ - specs: { - main: ["specs/**/*.mdx"] - }, - bogus: true -}) -`, - "specs/a.mdx": '<S id="a1">\nValid behavior.\n</S>\n', - }, - }, - async (workspace) => { - await expectConfigurationError( - product, - workspace, - ["build"], - "T14-4 (14.14) `build` under an unknown configuration key " + - "(SPEC 14.14, 7, 12.0)", - ); - await expectConfigurationError( + // Never `version`: it loads no configuration, so configuration-error + // precedence cannot reach it — on the same invalid configuration + // that makes `build`/`check` exit 2, `version` answers at exit 0 + // with a single JSON document as its entire stdout (12.6 is + // JSON-only). Membership only; the byte-identity and document-form + // depth is T12.6-1/2's. + const versionContext = + "T14-4 (14.14) `version` under the same invalid configuration"; + parseJsonStdout( + await expectExit( product, workspace, - ["check"], - "T14-4 (14.14) `check` under the same configuration (SPEC 14.14, " + - "7, 12.0)", - ); - - // Never `version`: it loads no configuration, so configuration-error - // precedence cannot reach it — on the same invalid configuration - // that makes `build`/`check` exit 2, `version` answers at exit 0 - // with a single JSON document as its entire stdout (12.6 is - // JSON-only). Membership only; the byte-identity and document-form - // depth is T12.6-1/2's. - const versionContext = - "T14-4 (14.14) `version` under the same invalid configuration"; - parseJsonStdout( - await expectExit( - product, - workspace, - ["version"], - 0, - `${versionContext} — \`version\` loads no configuration and ` + - `cannot fail for workspace or configuration reasons: 14.14 is ` + - `delivered by every command that loads configuration, never ` + - `\`version\` (SPEC 12.6, 14.14)`, - ), - `${versionContext} — a JSON-only surface: a single JSON document ` + - `is its only output form, with or without --json (SPEC 12.6, 12.0)`, - ); - }, - ); + ["version"], + 0, + `${versionContext} — \`version\` loads no configuration and ` + + `cannot fail for workspace or configuration reasons: 14.14 is ` + + `delivered by every command that loads configuration, never ` + + `\`version\` (SPEC 12.6, 14.14)`, + ), + `${versionContext} — a JSON-only surface: a single JSON document ` + + `is its only output form, with or without --json (SPEC 12.6, 12.0)`, + ); + }); // --- Every other condition: reported by both `build` and `check`, and // per its staging's kind by the machine-interface answers (SPEC 11.2; @@ -1759,11 +1771,287 @@ const T14_5 = defineProductTest({ }, }); -/** TEST-SPEC §14 T14-1…T14-5, in canonical ID order (SUITE-49). */ +// --------------------------------------------------------------------------- +// T14-6 — stable codes +// --------------------------------------------------------------------------- + +/** + * The 1-based SPEC 14 ordinal of a `"14.N"` condition identity (the sweep + * entries' vocabulary). A malformed identity is a harness defect, not a + * product failure — hence a plain error, never `fail` (H-8 taxonomy). + */ +function conditionOrdinal(condition: string): number { + const ordinal = Number(condition.slice("14.".length)); + if ( + !condition.startsWith("14.") || + !Number.isInteger(ordinal) || + ordinal < 1 || + ordinal > CONDITION_CODE_TOKENS.length + ) { + throw new Error( + `section-14 harness defect: no SPEC 14 condition ${JSON.stringify(condition)} exists`, + ); + } + return ordinal; +} + +/** + * The T14-6 per-condition assertion: at least one finding, and EVERY finding + * carries the staged condition's exact stable code token as its `code` — + * strict string equality against the harness-pinned SPEC 14 token table + * (model.ts CONDITION_CODE_TOKENS: index N-1 holds condition 14.N's token). + * The form-exact decode already admits only known tokens or null (S-5), so + * with this equality an omitted, misspelled, null, wrong-condition, or + * numeral-decorated code fails even where exit class and located + * information are right (SPEC 14, 12.7; T14-6). Every T14-6 staging stages + * exactly one condition, so "every finding" is the whole report. + */ +function assertExactCodeToken( + findings: readonly Finding[], + ordinal: number, + context: string, +): void { + const token = CONDITION_CODE_TOKENS[ordinal - 1]; + if (token === undefined) { + throw new Error( + `section-14 harness defect: no SPEC 14 condition ${String(ordinal)} exists`, + ); + } + if (findings.length === 0) { + fail( + `${context}: the staged condition ${String(ordinal)} must be reported — with ` + + `its finding absent altogether, the stable-code assertion is absent ` + + `with it (SPEC 14; T14-6 is a positive identity check); got an ` + + `empty findings array`, + ); + } + for (const finding of findings) { + if (finding.code !== token) { + fail( + `${context}: the finding must carry condition ${String(ordinal)}'s stable ` + + `code — the exact token ${JSON.stringify(token)} as its \`code\` member, ` + + `the token string alone, the ordinal numeral no part of the value ` + + `(SPEC 14, 12.7); got ${JSON.stringify(finding.code)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + } +} + +/** Assert a code-less finding: `code` null where SPEC 14 assigns none. */ +function assertCodeNull(finding: Finding, why: string, context: string): void { + if (finding.code !== null) { + fail( + `${context}: ${why} carries no stable code — \`code\` is null where ` + + `14 assigns none (SPEC 14, 12.7); got ${JSON.stringify(finding.code)} ` + + `(message: ${JSON.stringify(finding.message)})`, + ); + } +} + +const T14_6 = defineProductTest({ + id: "T14-6", + title: + "stable codes: for each of the 23 conditions, staged via its primary test's fixture and read from its stated reporter, the finding carries the exact token 14 lists (`missing-id` … `unreadable-record`) as its `code` in the JSON report form — the value is the token string alone, the ordinal numeral no part of it — so a product omitting or misspelling a code fails even where exit class and located information are right; a plain usage error and a review-operation refusal carry no stable code — `code` null (SPEC 14, 12.7, 12.0)", + timeoutMs: 300_000, + run: async (product) => { + // --- The 18 conditions `build` reports, staged as T14-4 sweeps them + // (their minimal primary-fixture forms) and read from `build --json` — + // a stated reporter for every one of them: "every other condition + // reported by both `build` and `check`", 14.13/14.22 "by both `build` + // and `check` and by the gated reads" (T14-4's matrix). + for (const entry of SWEEP_ENTRIES) { + const ordinal = conditionOrdinal(entry.condition); + await withWorkspace(entry.decl, async (workspace) => { + await entry.prepare?.(product, workspace); + const context = `T14-6 (${entry.label}) \`build --json\``; + assertExactCodeToken( + await buildFindings(product, workspace, context), + ordinal, + `${context} — condition ${entry.condition}'s stable code, read ` + + `from \`build\``, + ); + }); + } + + // --- 14.10 `stale-output`: `check` is its sole reporter (SPEC 14.10). + await withWorkspace(STALE_DECL, async (workspace) => { + await buildOk( + product, + workspace, + "T14-6 (14.10) staging `build` (SPEC 12.1)", + ); + await workspace.file("specs/a.mdx", STALE_EDIT); + const context = "T14-6 (14.10) `check --json` on the stale workspace"; + assertExactCodeToken( + await checkFindings(product, workspace, context), + 10, + `${context} — staleness is the only staged condition, so every ` + + `finding carries its code`, + ); + }); + + // --- 14.12 `policy-violation`: `check` only (SPEC 14.12), on the + // freshly built policy-violating workspace. + await withWorkspace(POLICY_DECL, async (workspace) => { + await buildOk( + product, + workspace, + "T14-6 (14.12) staging `build` (SPEC 12.1, 14.12: `build` succeeds " + + "regardless of policy)", + ); + const context = "T14-6 (14.12) `check --json`"; + assertExactCodeToken( + await checkFindings(product, workspace, context), + 12, + context, + ); + }); + + // --- 14.14 `configuration-error`: delivered by every configuration- + // loading command as the exit-2 usage error; its JSON report form is + // the error document, whose one finding carries the stable code + // (SPEC 14.14, 12.0, 12.7). + await withWorkspace(BOGUS_KEY_DECL, async (workspace) => { + const context = + "T14-6 (14.14) `build --json` under the unknown-key configuration"; + const result = await expectExit( + product, + workspace, + ["build", "--json"], + 2, + `${context} — a configuration error is an exit-2 usage error ` + + `(SPEC 14.14, 12.0)`, + ); + assertExactCodeToken( + [expectErrorDocument(result, context)], + 14, + `${context} — the error document's finding`, + ); + }); + + // --- 14.21 `corrupt-session`: `check` (a stated reporter beside the + // `review` subcommands naming the session and `review list`, SPEC + // 14.21), on the freshly built workspace plus the garbage session. + await withWorkspace(VALID_SPECS_DECL, async (workspace) => { + await buildOk( + product, + workspace, + "T14-6 (14.21) staging `build` (SPEC 12.1)", + ); + await workspace.file(GARBAGE_SESSION_PATH, GARBAGE_SESSION_CONTENT); + const context = "T14-6 (14.21) `check --json` beside the corrupt session"; + assertExactCodeToken( + await checkFindings(product, workspace, context), + 21, + context, + ); + }); + + // --- 14.23 `unreadable-record`: `inventory` (a stated reporter beside + // the `rename`/`move` previews, SPEC 14.23) — the finding accompanies + // the answer with its stable code, exit 1; the corruption applies to a + // record the product itself wrote (H-3). + await withWorkspace(VALID_SPECS_DECL, async (workspace) => { + await buildOk( + product, + workspace, + "T14-6 (14.23) staging `build` (SPEC 12.1, 13.3; H-3)", + ); + await corruptGraphDataShapeBlind(workspace.root, "T14-6 (14.23)"); + const context = "T14-6 (14.23) `inventory`"; + assertExactCodeToken( + decodeInventoryFindings( + await runJsonExpecting( + product, + workspace, + ["inventory"], + 1, + `${context} — the condition-23 finding accompanies the answer ` + + `with its stable code and the invocation exits 1 (SPEC 14.23, ` + + `11.6)`, + ), + context, + ), + 23, + context, + ); + }); + + // --- `code` null: a plain usage error (T12.7-3's staging — an unknown + // command, the error determined by the invocation's syntax alone) + // describes the invocation the consuming tool composed and carries no + // stable code (SPEC 14, 12.0). + await withWorkspace(VALID_SPECS_DECL, async (workspace) => { + const context = + "T14-6 (plain usage error) `definitely-not-a-command --json`"; + const result = await expectExit( + product, + workspace, + ["definitely-not-a-command", "--json"], + 2, + `${context} — an unknown command is a plain usage error (SPEC 12.0)`, + ); + assertCodeNull( + expectErrorDocument(result, context), + "a plain usage error", + context, + ); + }); + + // --- `code` null: a review-operation refusal (T12.7-1's staging — + // `create` with an existing session's exact name, refused per SPEC + // 10.1/10.7; the audit strategy needs no git). + await withWorkspace(VALID_SPECS_DECL, async (workspace) => { + await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", "s"], + 0, + "T14-6 (review refusal) staging `review create --strategy audit " + + "--name s` — the first creation succeeds on the valid workspace " + + "(SPEC 10.1, 10.6)", + ); + const context = + "T14-6 (review refusal) `review create --strategy audit --name s " + + "--json` again"; + const result = await expectExit( + product, + workspace, + ["review", "create", "--strategy", "audit", "--name", "s", "--json"], + 1, + `${context} — \`create\` with an existing session's exact name is ` + + `refused: exit 1, a refused review operation (SPEC 10.1, 10.7, ` + + `12.0)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout( + result, + `${context} — a refused operation's report is the findings-only ` + + `document {"findings": […]} (SPEC 12.7)`, + ), + context, + ).findings; + if (findings.length === 0) { + fail( + `${context}: the refusal must be reported as at least one ` + + `finding — an exit-1 refusal with an empty findings array ` + + `reports nothing (SPEC 10.7, 12.7, 14)`, + ); + } + for (const finding of findings) { + assertCodeNull(finding, "a review-operation refusal", context); + } + }); + }, +}); + +/** TEST-SPEC §14 T14-1…T14-6, in canonical ID order (SUITE-49). */ export const section14ValidationTests: readonly ProductTestEntry[] = [ T14_1, T14_2, T14_3, T14_4, T14_5, + T14_6, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 2b26db1..d180baf 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -411,6 +411,10 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T14-3": ["14"], "T14-4": ["14"], "T14-5": ["14"], + // T14-6: 12.7 (the JSON report form pinning `code`) and 12.0 (the exit-2 + // error document carriage) are context with home coverage at + // T12.7-*/T12.0-*. + "T14-6": ["14"], "T15-1": ["15"], "P-1": ["1.4", "2.6"], "P-2": ["3"], From 6a8b3c859dffc9404de5f072b29f5a3b236ecc50 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 16 Aug 2026 23:47:59 +0000 Subject: [PATCH 125/226] sdg(phase-9): implement T14-7, the refusal-reason contract (FP-080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-reason sweep stages the refusal reasons via the home fixtures (TEST-SPEC 14: staged at T6.4-3/T6.5-4/T6.5-6/T6.6-3) and asserts the reporting contract alone — exit 1, the form-exact 12.7 findings-only report, the exact finding multiset with each reason's concerned file/range/identity. T14-7's own stagings add the destination-path directory-component case (refused-invalid-destination, never 14.22), the both-collide-and-cycle section move (every applicable reason together, never only the first found), and the invalid-workspace refusal with the rename staged to also collide (validation findings alone). Red-as-diagnosed at the known form-exact product gap; test:self unchanged (2 planned FP-091 reds). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 47 ++- test/suite/registry/section-14.ts | 477 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 5 + 3 files changed, 524 insertions(+), 5 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 3b68409..fd80c43 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3563,10 +3563,55 @@ certify against FP-091's fixtures once those land. `npm run test:self` 268 passed, unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091); S-1/S-5/S-7/certification green (S-7 sweeps T14-6 against the stub, diagnosed).] -- [ ] FP-080 — Implement T14-7: refusal reasons — each stable refusal code +- [x] FP-080 — Implement T14-7: refusal reasons — each stable refusal code with concerned file/range/identity; all-applicable-reasons-together; the invalid-workspace refusal reporting numbered findings alone. [R2 #12; TEST-SPEC §14] + [Done 2026-08-16: T14-7 registered in section-14.ts. The per-reason sweep + stages the reasons via the home fixtures (TEST-SPEC 14 preamble: staged at + T6.4-3/T6.5-4/T6.5-6/T6.6-3) — section-6.4's RENAME_REFUSAL_* and + section-6.5's MOVE_REFUSAL_*/stageMoveRefusalOccupants/MOVE_DERIVED_PATH_* + exports, the T6.6-3 reuse precedent — through a new reporting-contract + helper (`assertRefusalReport`): exit 1, form-exact 12.7 findings-only + report, exact finding multiset (one finding per applicable reason, none + beside — the exact counts also realize "identity-unchanged alone", the + intrinsic-only "never both", and "never 14.22"), plus each reason's + concerned file/range/identity via the support.ts SOME-quantified helpers; + modifies-nothing compares, journal discipline, and preview equivalence + stay the home tests' subject. T14-7's own stagings add what no home table + stages: (i) a plain file as a directory component of the destination path + itself (`specs/blocked`; matches no glob, under no write path, premise + `build` passes) → refused-invalid-destination concerning + specs/blocked/Out.mdx, never 14.22; (ii) the both-collide-and-cycle + section move (occupant child `keep.mv` remains after the removal while + `mv` `d={"keep"}` would become `keep`'s child, SPEC 5.3) → exactly + {refused-id-collision, refused-cycle}, never only the first, the collision + locating the occupant construct, the cycle the participating `d` spelling; + (iii) the invalid-workspace refusal with the rename staged to ALSO collide + — a control on the valid twin pins the premise (exactly + refused-id-collision locating the remaining `a.sib` bearer), then Bad.mdx + broken (14.5) → exactly the one located 14.5 finding, no refusal reason + evaluated or reported beside it. refused-unresolvable-reference: no arm + (admits no fixture; the always-passing side of successful operations); + the exact self-move's identity-unchanged stays at its home T6.5-6. + Traceability "T14-7": ["14"] (6.4/6.5/5.3/12.7/12.0 carriage context, the + T14-6 precedent); no certification scope. Verified: typecheck/format + clean; T14-7 red-as-diagnosed at the first rename case's form-exact + decode ("expected no member \"refused\"" — the FP-001/FP-007-class + product gap), section-14 6→7 failed with T14-1..T14-6 unchanged; direct + CLI probes against the built product prove the new stagings — the + component arm's control twin succeeds without the occupant (creates + specs/blocked/Out.mdx, pinning component occupancy as the sole defect) + while with it the product internal-errors exit 70 (the + vets-only-own-components gap class FP-018 diagnosed); the multi staging's + reasons each fire individually (collision-alone twin refuses on the + collision; cycle-alone twin reports the would-be keep→keep.mv→keep cycle) + while combined the product reports only the collision — the + only-the-first-found gap the arm rejects; the invalid-workspace probes + behave as specced in the old shape (control: collision refusal; broken: + the one 14.5 finding alone). `npm run test:self` unchanged 2 planned + mid-loop reds (certification-document ×2 → FP-091); S-1/S-5/S-7/ + certification green (S-7 sweeps T14-7 against the stub, diagnosed).] - [ ] FP-081 — Implement T14-8: location cardinality — one finding locating every participant (triple-duplicate ID, import collision, cycle full path, embedding container span); within-finding location order. [R2 #13; diff --git a/test/suite/registry/section-14.ts b/test/suite/registry/section-14.ts index 39c4a95..9944be4 100644 --- a/test/suite/registry/section-14.ts +++ b/test/suite/registry/section-14.ts @@ -1,5 +1,5 @@ // TEST-SPEC §14 (validation errors: the reporting contract) — SUITE-49: -// T14-1 … T14-6. +// T14-1 … T14-7. // // Sections 1–13 exercise each numbered condition in its home context; these // are the reporting-contract tests: multi-error completeness with @@ -8,9 +8,12 @@ // masking by unparseable files and by configuration errors (T14-3), the // reporter matrix — which of `build`/`check`/`review`/the machine-interface // surfaces reports which condition (T14-4) — grammar selection by file -// name (T14-5), and the stable-code contract — each of the 23 conditions' +// name (T14-5), the stable-code contract — each of the 23 conditions' // exact token as the finding's `code`, `null` where 14 assigns none -// (T14-6). +// (T14-6) — and the refusal-reason contract: each stable refusal code with +// its concerned file, range, or identity, every applicable reason together, +// and the invalid-workspace refusal reporting numbered findings alone +// (T14-7). // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -105,6 +108,37 @@ // reporter breadth stay T14-4's and the home tests' subject; the // `code`-null arms mirror T12.7-3's plain-usage-error and T12.7-1's // review-refusal stagings, per T14-6's own citations. +// - T14-7 stages the refusal reasons via the home fixtures — T6.4-3's and +// T6.5-4's exported staging and case tables (TEST-SPEC §14 preamble: the +// refusal reasons are staged at T6.4-3, T6.5-4, T6.5-6, T6.6-3) — and +// asserts the reporting contract alone: exit 1, the form-exact 12.7 +// findings-only report, the exact finding multiset (one finding per +// applicable reason, none beside), and each finding's stable code with +// its concerned file/range/identity. The modifies-nothing compares, +// journal discipline, and preview equivalence stay the home tests' +// subject (T6.4-3, T6.5-4, T6.6-3). Location assertions are +// SOME-quantified per the home operationalization (support.ts +// assertFindingMentionsLocation): every-participant location cardinality +// is T14-8's subject, so "locating every colliding bearer" and "the +// would-be cycle's full path" are asserted as the staged fixtures' one +// assertable participant each — the remaining colliding bearer's +// construct; the dependency cycle's participating `d` spelling (the +// would-be spec import cycle's participating import declarations exist +// in no pre-operation source, so that arm pins code and form alone, the +// home note). `refused-unresolvable-reference` admits no fixture +// (TEST-SPEC T6.4-3, T6.5-6) and is asserted only as the always-passing +// side of successful operations (T6.4-1, T6.5-1/2/3): no arm here. The +// exact self-move's refused-identity-unchanged is staged at its home +// (T6.5-6); T14-7's identity-unchanged arm is the rename, per its entry. +// T14-7's own stagings add what no home table stages: the plain file as +// a directory component of the destination path itself (the other +// destination-side directory-component case of 6.5 beside T6.5-4's +// derived-path arm — refused-invalid-destination, never 14.22); the +// both-collide-and-cycle section move (every applicable reason together, +// never only the first found); and the invalid-workspace refusal with +// the rename staged to ALSO collide — the control arm on the valid twin +// pins the staged-to-collide premise (exactly the collision refusal), +// then the broken workspace reports the validation findings alone. import { Buffer } from "node:buffer"; import type { Finding, GraphEdge } from "../../helpers/adapters/index.js"; @@ -134,10 +168,28 @@ import { } from "../../helpers/tooling.js"; import type { WorkspaceDecl } from "../../helpers/workspace.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import { + RENAME_REFUSAL_CASES, + RENAME_REFUSAL_CONFIG, + RENAME_REFUSAL_FILES, +} from "./section-6.4.js"; +import type { RefusalExpectation } from "./section-6.5.js"; +import { + MOVE_DERIVED_PATH_CASE, + MOVE_DERIVED_PATH_CONFIG, + MOVE_DERIVED_PATH_FILES, + MOVE_REFUSAL_CASES, + MOVE_REFUSAL_CONFIG, + MOVE_REFUSAL_FILES, + stageMoveRefusalOccupants, +} from "./section-6.5.js"; import { assertConditionCounts, assertEdgeSetEqual, + assertFindingConcernsPath, assertFindingLocated, + assertFindingMentionsLocation, + assertFindingNamesIdentity, assertSameJson, buildFindings, buildOk, @@ -2046,7 +2098,423 @@ const T14_6 = defineProductTest({ }, }); -/** TEST-SPEC §14 T14-1…T14-6, in canonical ID order (SUITE-49). */ +// --------------------------------------------------------------------------- +// T14-7 — refusal reasons +// --------------------------------------------------------------------------- + +/** + * The T14-7 reporting contract over one refused invocation (SPEC 14, 12.7): + * run with `--json`, assert exit 1 exactly (refusals are findings in the + * exit-code partition, SPEC 12.0; H-5), decode stdout as the form-exact 12.7 + * findings-only report (H-3), assert the exact finding multiset — one + * finding per applicable reason (or per staged numbered condition, for the + * invalid-workspace refusal), never only the first found, none beside — and + * assert each expected finding's concerned file/range/identity (the + * SOME-quantified location of the home operationalization; module header). + * The modifies-nothing compares are the home tests' subject (T6.4-3, + * T6.5-4). Per-reason concern lookup is by counting key, total because a + * refusal report never carries two findings of one reason (SPEC 14: one + * finding per reason). + */ +async function assertRefusalReport( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], + expected: RefusalExpectation | readonly RefusalExpectation[], + context: string, +): Promise<void> { + const expectations: readonly RefusalExpectation[] = Array.isArray(expected) + ? expected + : [expected]; + const command = argv.join(" "); + const result = await expectExit( + product, + workspace, + [...argv, "--json"], + 1, + `${context}: \`${command} --json\` — a refusal is a validation failure, ` + + `exit 1 (SPEC 6.4, 6.5, 12.0)`, + ); + const findings = decodeFindingsReport( + parseJsonStdout(result, `${context}: \`${command} --json\``), + `${context}: \`${command} --json\` — a refused operation's report is ` + + `the form-exact 12.7 findings-only report (SPEC 12.7, H-3)`, + ).findings; + const counts: Record<string, number> = {}; + for (const expectation of expectations) { + counts[expectation.finding] = (counts[expectation.finding] ?? 0) + 1; + } + assertConditionCounts( + findings, + counts, + `${context}: every applicable reason reports together, one finding per ` + + `reason — never only the first found — and none beside the staged ` + + `one(s), each carrying its exact stable code (SPEC 14, 12.7)`, + ); + for (const expectation of expectations) { + const finding = findings.find( + (candidate) => + (candidate.condition ?? candidate.code ?? "(code-less)") === + expectation.finding, + ); + if (finding === undefined) { + fail( + `${context}: no reported finding carries ` + + `${JSON.stringify(expectation.finding)} (SPEC 14, 12.7)`, + ); + } + if (expectation.locatedAt !== undefined) { + assertFindingMentionsLocation( + finding, + expectation.locatedAt, + `${context}: the ${expectation.finding} finding's concerned construct`, + ); + } + if (expectation.identity !== undefined) { + assertFindingNamesIdentity( + finding, + expectation.identity, + `${context}: the ${expectation.finding} finding's concerned identity`, + ); + } + if (expectation.path !== undefined) { + assertFindingConcernsPath( + finding, + expectation.path, + `${context}: the ${expectation.finding} finding's concerned path`, + ); + } + } +} + +// The destination-path directory-component staging (the other +// destination-side directory-component case of SPEC 6.5, beside T6.5-4's +// derived-path arm): the plain file `specs/blocked` occupies a +// workspace-relative directory component of the destination path +// `specs/blocked/Out.mdx`. The occupant matches no configured glob (no +// `.mdx`) and lies under no current source's write path, so the premise +// `build` passes and the refusal is the move's own — +// refused-invalid-destination concerning the destination path, never 14.22 +// (SPEC 6.5, 14.22, 14). Soundness: without the occupant the identical move +// succeeds and creates `specs/blocked/Out.mdx` (writes create missing +// directories, 13.4) — component occupancy is the arm's sole defect. +const T14_7_COMPONENT_OCCUPANT = "specs/blocked"; +const T14_7_COMPONENT_DEST = "specs/blocked/Out.mdx"; +const T14_7_COMPONENT_FILES: Readonly<Record<string, string>> = { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/Src.mdx": '<S id="solo">\nSolo text.\n</S>\n', + [T14_7_COMPONENT_OCCUPANT]: "not a directory\n", +}; + +// The every-applicable-reason staging: a section move staged to BOTH collide +// and create a dependency cycle. `mv` carries `d={"keep"}` and the move +// `specs/M.mdx#mv` → `specs/M.mdx#keep.mv` would make it `keep`'s child — a +// dependency on its own ancestor, a cycle (SPEC 5.3, 6.5) — while the +// occupant child already identified `keep.mv` remains after the removal (the +// vacated set is exactly the moved subtree's IDs, here `mv` alone), so the +// prefix-replaced new ID collides (SPEC 6.5). Each reason's applicability +// reads on its own terms (SPEC 14): both findings, never only the first. +const T14_7_MULTI_FILE = "specs/M.mdx"; +const T14_7_MULTI_SOURCE = [ + '<S id="keep">', + "Keep holder text.", + "", + '<S id="keep.mv">', + "Occupant child text.", + "</S>", + "</S>", + "", + '<S id="mv" d={"keep"}>', + "Moved candidate text.", + "</S>", + "", +].join("\n"); + +// The remaining colliding bearer's whole construct (the collision locates +// every colliding bearer, SPEC 14; the fixture's one assertable participant) +// and the dependency cycle's participating reference spelling (the home +// operationalization of "locating the would-be cycle's full path"). +const T14_7_OCCUPANT_CONSTRUCT = '<S id="keep.mv">\nOccupant child text.\n</S>'; +const T14_7_OCCUPANT_WINDOW = byteWindow( + T14_7_MULTI_SOURCE.slice( + 0, + T14_7_MULTI_SOURCE.indexOf(T14_7_OCCUPANT_CONSTRUCT), + ), + T14_7_OCCUPANT_CONSTRUCT, +); +const T14_7_CYCLE_SPELLING = 'd={"keep"}'; +const T14_7_CYCLE_WINDOW = byteWindow( + T14_7_MULTI_SOURCE.slice(0, T14_7_MULTI_SOURCE.indexOf(T14_7_CYCLE_SPELLING)), + T14_7_CYCLE_SPELLING, +); + +// The invalid-workspace staging: rename `a.mid` → `a.sib` is staged to +// collide with the remaining `a.sib` bearer (the control arm pins that +// premise on the valid twin), and `specs/Bad.mdx` is then broken with an +// unresolved `d` reference (14.5) — the workspace failing `build`'s +// validations through a file the rename's arguments never touch, while the +// usage-error argument checks still pass (the origin file exists and spells +// `a.mid`, SPEC 6.4, 12.0). +const T14_7_RENAME_FILE = "specs/R.mdx"; +const T14_7_RENAME_SOURCE = [ + '<S id="a">', + "Holder text.", + "", + '<S id="a.mid">', + "Mid text.", + "</S>", + "", + '<S id="a.sib">', + "Sib text.", + "</S>", + "</S>", + "", +].join("\n"); +const T14_7_SIB_CONSTRUCT = '<S id="a.sib">\nSib text.\n</S>'; +const T14_7_SIB_WINDOW = byteWindow( + T14_7_RENAME_SOURCE.slice( + 0, + T14_7_RENAME_SOURCE.indexOf(T14_7_SIB_CONSTRUCT), + ), + T14_7_SIB_CONSTRUCT, +); +const T14_7_BAD_FILE = "specs/Bad.mdx"; +const T14_7_BAD_VALID = + '<S id="bad">\nBad-file text, valid for the control arm.\n</S>\n'; +const T14_7_BAD_INVALID = + '<S id="bad" d={"nope"}>\nUnresolved dependency target.\n</S>\n'; + +const T14_7 = defineProductTest({ + id: "T14-7", + title: + "refusal reasons: staged refusals asserting each stable code with its concerned file, range, or identity — refused-invalid-id concerning the invalid identity (intrinsic form only: a structurally misplaced but intrinsically valid new ID reports refused-structural-parent alone, never both); refused-identity-unchanged reported alone by an identity-unchanged rename, no collision reason beside it; refused-id-collision locating the colliding bearer; refused-structural-parent concerning the violated identity; refused-cycle locating the would-be cycle's participating spelling; refused-destination-exists concerning the occupied path, the section form's non-spec-source occupant included; refused-missing-target-parent concerning the target-parent identity; refused-invalid-destination concerning the destination path — the destination-side directory-component cases reporting this code, never 14.22: a plain file staged as a directory component of the destination path and, in the derived-path arm, of the destination's `outDir` emit destination; refused-unresolvable-reference admits no fixture and is asserted only as the always-passing side of successful operations; every applicable reason reports together, one finding per reason — a section move staged to both collide and create a dependency cycle reports both findings, never only the first; the invalid-workspace refusal reports the workspace's numbered findings alone — a rename staged to also collide on a workspace failing validation reports the validation findings only, exit 1, no refusal reason evaluated or reported beside them (SPEC 14, 6.4, 6.5, 5.3, 12.0, 12.7)", + timeoutMs: 300_000, + run: async (product) => { + // --- The rename reasons, staged via T6.4-3's exported fixture: the + // 1.4-invalid new IDs (refused-invalid-id concerning the invalid + // identity), the identity-unchanged rename (alone — the exact one-entry + // multiset holds no collision reason beside it, SPEC 6.4), the + // collision (locating the remaining bearer), and the structurally + // misplaced but intrinsically valid new IDs (refused-structural-parent + // alone, never refused-invalid-id beside it — the same exact-multiset + // teeth; SPEC 14 "intrinsic form only"). + await withWorkspace( + { + files: { + "xspec.config.ts": RENAME_REFUSAL_CONFIG, + ...RENAME_REFUSAL_FILES, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T14-7 rename-reason staging `build` (the T6.4-3 protocol)", + ); + for (const { argv, expected, reason } of RENAME_REFUSAL_CASES) { + await assertRefusalReport( + product, + workspace, + argv, + expected, + `T14-7 rename (${reason})`, + ); + } + }, + ); + + // --- The move reasons, staged via T6.5-4's exported fixture: the two + // cycle arms (the dependency arm locating the participating `d` + // spelling), the destination occupants — the section form's + // non-spec-source occupants included, the out-of-group `.mdx` occupant + // refusing under both applicable reasons — the 1.4-invalid new IDs, the + // cross-file collision, the missing and within-subtree target parents, + // and the invalid destinations (SPEC 6.5, 14). + await withWorkspace( + { + files: { + "xspec.config.ts": MOVE_REFUSAL_CONFIG, + ...MOVE_REFUSAL_FILES, + }, + }, + async (workspace) => { + // Occupants before the premise `build`, which must still pass + // (T6.5-4's staging note). + await stageMoveRefusalOccupants(workspace); + await buildOk( + product, + workspace, + "T14-7 move-reason staging `build` (occupants staged before it; " + + "the T6.5-4 protocol)", + ); + for (const { argv, expected, reason } of MOVE_REFUSAL_CASES) { + await assertRefusalReport( + product, + workspace, + argv, + expected, + `T14-7 move (${reason})`, + ); + } + }, + ); + + // --- refused-invalid-destination, the derived-path directory-component + // case, staged via T6.5-4's exported derived-path fixture: the + // otherwise-valid destination's `outDir` emit destination has its + // directory component occupied by a plain file lying under no current + // source's write path — refused concerning the destination path, never + // 14.22 (SPEC 6.5, 7.3, 13.1, 13.2, 14). + await withWorkspace( + { + files: { + "xspec.config.ts": MOVE_DERIVED_PATH_CONFIG, + ...MOVE_DERIVED_PATH_FILES, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T14-7 derived-path staging `build` — the occupant lies under no " + + "current source's write path (T6.5-4's derived-path arm), so " + + "the refusal below is the move's own", + ); + await assertRefusalReport( + product, + workspace, + MOVE_DERIVED_PATH_CASE.argv, + MOVE_DERIVED_PATH_CASE.expected, + `T14-7 move (${MOVE_DERIVED_PATH_CASE.reason})`, + ); + }, + ); + + // --- refused-invalid-destination, the destination-path + // directory-component case (T14-7's own staging; the fixture note): a + // plain file occupies a directory component of the destination path + // itself — refused concerning the destination path, never 14.22 (the + // exact one-entry multiset excludes a condition-22 finding beside it; + // SPEC 6.5, 14.22, 14). + await withWorkspace({ files: T14_7_COMPONENT_FILES }, async (workspace) => { + await buildOk( + product, + workspace, + "T14-7 destination-component staging `build` — the plain-file " + + "occupant matches no glob and lies under no current source's " + + "write path, so the workspace passes `build`'s validations", + ); + await assertRefusalReport( + product, + workspace, + ["move", "specs/Src.mdx", T14_7_COMPONENT_DEST], + { + finding: "refused-invalid-destination", + path: T14_7_COMPONENT_DEST, + }, + "T14-7 move (destination-path directory component occupied by a " + + "plain file — refused-invalid-destination concerning the " + + "destination path, never 14.22)", + ); + }); + + // --- Every applicable reason together: the both-collide-and-cycle + // section move (the fixture note) reports both findings, never only the + // first found — the exact two-entry multiset with each reason's + // concerned participant (SPEC 14, 6.5, 5.3). + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [T14_7_MULTI_FILE]: T14_7_MULTI_SOURCE, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T14-7 multi-reason staging `build` over the valid workspace", + ); + await assertRefusalReport( + product, + workspace, + ["move", `${T14_7_MULTI_FILE}#mv`, `${T14_7_MULTI_FILE}#keep.mv`], + [ + { + finding: "refused-id-collision", + locatedAt: { + file: T14_7_MULTI_FILE, + window: T14_7_OCCUPANT_WINDOW, + }, + }, + { + finding: "refused-cycle", + locatedAt: { + file: T14_7_MULTI_FILE, + window: T14_7_CYCLE_WINDOW, + }, + }, + ], + "T14-7 move (staged to both collide — `keep.mv` present in the " + + "target file, remaining after the removal — and create a " + + "dependency cycle — the moved node depends on `keep`, its " + + "would-be ancestor: both findings, never only the first)", + ); + }, + ); + + // --- The invalid-workspace refusal: the control arm on the valid twin + // pins the staged-to-collide premise (exactly the collision refusal), + // then the broken workspace — an unresolved `d` in a file the rename + // never touches — reports the workspace's numbered findings alone: the + // one located 14.5 finding, exit 1, no refusal reason evaluated or + // reported beside it (SPEC 6.4, 14). + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [T14_7_RENAME_FILE]: T14_7_RENAME_SOURCE, + [T14_7_BAD_FILE]: T14_7_BAD_VALID, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T14-7 invalid-workspace staging `build` over the valid twin", + ); + await assertRefusalReport( + product, + workspace, + ["rename", T14_7_RENAME_FILE, "a.mid", "a.sib"], + { + finding: "refused-id-collision", + locatedAt: { file: T14_7_RENAME_FILE, window: T14_7_SIB_WINDOW }, + }, + "T14-7 rename control (the valid twin: the rename is staged to " + + "collide with the remaining `a.sib` bearer — the premise the " + + "invalid-workspace arm rides)", + ); + await workspace.file(T14_7_BAD_FILE, T14_7_BAD_INVALID); + await assertRefusalReport( + product, + workspace, + ["rename", T14_7_RENAME_FILE, "a.mid", "a.sib"], + { + finding: "14.5", + locatedAt: { file: T14_7_BAD_FILE }, + }, + "T14-7 rename (invalid workspace: the same rename, still staged " + + "to collide, reports the workspace's numbered findings alone — " + + "the one 14.5 finding located in specs/Bad.mdx, no refusal " + + "reason evaluated or reported beside it)", + ); + }, + ); + }, +}); + +/** TEST-SPEC §14 T14-1…T14-7, in canonical ID order (SUITE-49). */ export const section14ValidationTests: readonly ProductTestEntry[] = [ T14_1, T14_2, @@ -2054,4 +2522,5 @@ export const section14ValidationTests: readonly ProductTestEntry[] = [ T14_4, T14_5, T14_6, + T14_7, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index d180baf..d265a87 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -415,6 +415,11 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { // error document carriage) are context with home coverage at // T12.7-*/T12.0-*. "T14-6": ["14"], + // T14-7: 6.4/6.5/5.3 (the staged operations and the cycle rule) and + // 12.7/12.0 (report carriage) are context with home coverage at + // T6.4-*/T6.5-*/T5.3-1/T12.7-*/T12.0-*; the home passage "14" also + // carries the invalid-workspace arm's asserted numbered condition (14.5). + "T14-7": ["14"], "T15-1": ["15"], "P-1": ["1.4", "2.6"], "P-2": ["3"], From aa68a6bdbd58e1cf9ad10cc51a0bb98bc70c0b38 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 17 Aug 2026 00:05:28 +0000 Subject: [PATCH 126/226] sdg(phase-9): implement T14-8, the location-cardinality contract (FP-081) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 48 ++- test/suite/registry/section-14.ts | 506 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 7 + 3 files changed, 556 insertions(+), 5 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index fd80c43..55489d9 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3612,10 +3612,56 @@ certify against FP-091's fixtures once those land. the one 14.5 finding alone). `npm run test:self` unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091); S-1/S-5/S-7/ certification green (S-7 sweeps T14-7 against the stub, diagnosed).] -- [ ] FP-081 — Implement T14-8: location cardinality — one finding locating +- [x] FP-081 — Implement T14-8: location cardinality — one finding locating every participant (triple-duplicate ID, import collision, cycle full path, embedding container span); within-finding location order. [R2 #13; TEST-SPEC §14] + [Done 2026-08-17: T14-8 registered in section-14.ts — six own stagings, + each arm's condition multiset exact and its participants asserted + index-wise via new every-participant helpers + (`assertFindingLocatesParticipants`: exact location count, per-participant + byte window, `path` null; `locationsMatchParticipants` classifies the + two-cycle report), the every-participant strictness the home tests + SOME-quantify (T1.3-5/T2.1-5/T5.3-1/T14-7 notes): (i) triple-duplicated + ID → exactly one 14.3 finding with three locations, one per bearer + window; (ii) two imports binding `A` → exactly one 14.15 finding locating + both declarations, the first included; (iii) cross-file `depends` cycle + a→b→a with its unavoidable mutual-import spec import cycle (the T5.3-1 + rationale) → exactly two 14.9 findings, told apart by disjoint windows — + one locating every participating reference spelling (the `d`-bearing + elements, one per file), one every participating import declaration; + (iv) pure mutual-import cycle with unused bindings (SPEC 2.1: valid + individually, recording no edges) → exactly one 14.9 finding locating + both declarations; (v) no-occurrence embedding `{text("emb.nope")}` → + one 14.6 finding whose range is the full braced container asserted + BYTE-EXACT [42,60) — no widening, the T14-8 pin keeping T11.4-6's byte + classification exact; (vi) forbidden-rule policy violation via + build-then-`check --json` → exactly one 14.12 finding, locations [], + path null, identities exactly [rule, source, kind token, target]. + Participant sequences are declared in the 12.7 within-finding order + (document order in-file, file bytes across files), so the index-wise + assertions pin the order value-wise beside the decoder's global + enforcement (forms.ts, S-5-guarded; the end tiebreak admits no natural + fixture — no staged pair shares file and start — and stays + decoder-enforced). Traceability "T14-8": ["14"] (5.7/11.4/12.7/2.1/5.3 + carriage context, the T14-6/-7 precedent); no certification scope + (CERTIFICATIONS.md Exclusions name T14-8 explicitly). Verified: + typecheck/format clean; T14-8 red-as-diagnosed at the first arm's + form-exact decode ("expected no member \"condition\"" — the FP-001-class + product gap), section-14 7→8 failed with T14-1..T14-7 unchanged; direct + CLI probes against the built product prove every staging fires exactly + the diagnosed multiset and the cardinality teeth are real — dup: two + old-shape per-occurrence findings locating only the later bearers; + collision: one finding locating only the second declaration; the + two-cycle staging: exactly the two 14.9 findings (import cycle at the + CycA import [0,28), dependency cycle at the CycA element [30,76)) each + locating only its CycA participant; pure import cycle: one finding, ImpA + declaration only; emb: 14.6 located at the argument [48,58) where the + container is [42,60); policy: build 0 then check 1 with exactly the one + old-shape 14.12 naming the staged edge. `npm run test:self` unchanged 2 + planned mid-loop reds (certification-document ×2 → FP-091), 268 passed; + S-1/S-5/S-7/certification green (S-7 sweeps T14-8 against the stub, + diagnosed).] ## Stage H — property layer (§16) and oracles (S-6) diff --git a/test/suite/registry/section-14.ts b/test/suite/registry/section-14.ts index 9944be4..98e91dc 100644 --- a/test/suite/registry/section-14.ts +++ b/test/suite/registry/section-14.ts @@ -1,5 +1,5 @@ // TEST-SPEC §14 (validation errors: the reporting contract) — SUITE-49: -// T14-1 … T14-7. +// T14-1 … T14-8. // // Sections 1–13 exercise each numbered condition in its home context; these // are the reporting-contract tests: multi-error completeness with @@ -10,10 +10,13 @@ // surfaces reports which condition (T14-4) — grammar selection by file // name (T14-5), the stable-code contract — each of the 23 conditions' // exact token as the finding's `code`, `null` where 14 assigns none -// (T14-6) — and the refusal-reason contract: each stable refusal code with +// (T14-6) — the refusal-reason contract: each stable refusal code with // its concerned file, range, or identity, every applicable reason together, // and the invalid-workspace refusal reporting numbered findings alone -// (T14-7). +// (T14-7) — and the location-cardinality contract: a condition several +// constructs jointly violate is one finding locating every participant, +// each in its containing file, in the pinned within-finding location order +// (T14-8). // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -139,6 +142,30 @@ // the rename staged to ALSO collide — the control arm on the valid twin // pins the staged-to-collide premise (exactly the collision refusal), // then the broken workspace reports the validation findings alone. +// - T14-8 owns the every-participant strictness the home tests SOME-quantify +// (T1.3-5's and T2.1-5's per-file tolerance, T5.3-1's file-dimension +// binding, T14-7's mentions-location): exact finding counts and an +// index-wise per-participant assertion — exactly one location per +// participating construct, each within its construct's byte window (the +// module-header window convention). Participant sequences are declared in +// the 12.7 within-finding order — document order within one file, +// file-path-byte order across files — so the index-wise assertion also +// pins "file bytes, then start, then end" value-wise, beside the +// form-exact decoder's enforcement of that order on every decoded finding +// (forms.ts, S-5-guarded); no staged pair of participants shares file and +// start, so the end tiebreak stays decoder-enforced. The no-occurrence +// embedding spelling's container range is byte-EXACT, no end-widening: +// SPEC 14 pins the full braced container, opening brace through closing +// brace — the span its occurrence would occupy (5.7) — keeping T11.4-6's +// byte classification exact. The cross-file dependency cycle necessarily +// co-stages the mutual-import spec import cycle (the T5.3-1 rationale: a +// cross-file `depends` edge needs an external reference, external +// references need imports, so A→B→A needs mutual imports); its report is +// exactly two 14.9 findings, told apart by their located participants — +// the reference spellings (element windows) vs the import declarations +// (import windows), disjoint by construction — while the pure +// mutual-import staging (bindings unused, so no dependency edge exists, +// SPEC 2.1) isolates the import cycle as exactly one 14.9 finding. import { Buffer } from "node:buffer"; import type { Finding, GraphEdge } from "../../helpers/adapters/index.js"; @@ -2514,7 +2541,477 @@ const T14_7 = defineProductTest({ }, }); -/** TEST-SPEC §14 T14-1…T14-7, in canonical ID order (SUITE-49). */ +// --------------------------------------------------------------------------- +// T14-8 — location cardinality +// --------------------------------------------------------------------------- + +/** + * One expected participant of a jointly violated condition: its containing + * file and its construct's byte window (the module-header window + * convention). Participant sequences are declared in the 12.7 + * within-finding location order — document order within one file, + * file-path-byte order across files — so the index-wise assertions below + * also pin that order value-wise. + */ +interface ParticipantExpectation { + readonly file: string; + readonly window: { readonly start: number; readonly end: number }; +} + +/** + * Whether a finding's locations match a participant sequence index-wise: + * exactly one location per participant, each in the participant's file + * within its window. Boolean — the W1 cycle arm classifies its two 14.9 + * findings with it; `assertFindingLocatesParticipants` is the diagnosed + * form. + */ +function locationsMatchParticipants( + finding: Finding, + participants: readonly ParticipantExpectation[], +): boolean { + return ( + finding.locations.length === participants.length && + finding.locations.every((location, index) => { + const expected = participants[index]!; + return ( + location.file === expected.file && + location.range.start >= expected.window.start && + location.range.end <= expected.window.end + ); + }) + ); +} + +/** + * Assert one finding locates EVERY participant and nothing else (SPEC 14's + * location-cardinality rule — the every-participant strictness T14-8 owns; + * no SOME-quantified tolerance): exactly one location per participating + * construct, index-wise in the declared order, each in its containing file + * within its construct's byte window; and, locating in source, the finding + * concerns no path (12.7: `path` null for located conditions). + */ +function assertFindingLocatesParticipants( + finding: Finding, + participants: readonly ParticipantExpectation[], + context: string, +): void { + if (finding.locations.length !== participants.length) { + fail( + `${context}: one finding carries a location for every participating ` + + `construct — no representative chosen, none beside (SPEC 14, 12.7); ` + + `expected exactly ${String(participants.length)} location(s), got ` + + `${String(finding.locations.length)}: ` + + `${JSON.stringify(finding.locations)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + participants.forEach((expected, index) => { + const location = finding.locations[index]!; + if ( + location.file !== expected.file || + location.range.start < expected.window.start || + location.range.end > expected.window.end + ) { + fail( + `${context}: location[${String(index)}] must locate its participant ` + + `in ${JSON.stringify(expected.file)} within the construct's byte ` + + `window [${String(expected.window.start)}, ` + + `${String(expected.window.end)}] (SPEC 14: each participant located ` + + `in the file containing it; 12.7 orders locations by file bytes, ` + + `then start, then end — the declared participant order); got ` + + `${JSON.stringify(finding.locations)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + }); + if (finding.path !== null) { + fail( + `${context}: a located condition's finding concerns no path — ` + + `\`path\` null (SPEC 12.7, 14); got ${JSON.stringify(finding.path)} ` + + `(message: ${JSON.stringify(finding.message)})`, + ); + } +} + +// Triple-duplicated ID (SPEC 14.3, 14): three bearers of `dup`, each a +// structurally valid top-level section (one segment against the empty +// prefix), so the duplication is the workspace's only condition — one +// condition-3 finding with three locations, one per bearer, no +// representative chosen (a product reporting only the later bearers, or one +// finding per occurrence, fails the exact cardinality). +const T14_8_DUP_FILE = "specs/Dup.mdx"; +const T14_8_DUP_BEARERS: readonly string[] = [ + '<S id="dup">\nFirst bearer text.\n</S>', + '<S id="dup">\nSecond bearer text.\n</S>', + '<S id="dup">\nThird bearer text.\n</S>', +]; +const T14_8_DUP_SOURCE = `${T14_8_DUP_BEARERS.join("\n\n")}\n`; +const T14_8_DUP_PARTICIPANTS: readonly ParticipantExpectation[] = + T14_8_DUP_BEARERS.map((construct, index) => ({ + file: T14_8_DUP_FILE, + window: byteWindow( + T14_8_DUP_BEARERS.slice(0, index) + .map((bearer) => `${bearer}\n\n`) + .join(""), + construct, + ), + })); + +// Import-binding collision (SPEC 2.1, 14.15): two imports binding `A`, each +// individually valid (single default binding designating a discovered spec +// source; an unused binding is valid and records no edges), so the +// collision is the file's only condition — one condition-15 finding locating +// every colliding declaration, the first included. +const T14_8_COL_FILE = "specs/Col.mdx"; +const T14_8_COL_IMPORTS: readonly string[] = [ + 'import A from "./One.xspec"', + 'import A from "./Two.xspec"', +]; +const T14_8_COL_SOURCE = [ + ...T14_8_COL_IMPORTS, + "", + '<S id="col">', + "Collision-file body text.", + "</S>", + "", +].join("\n"); +const T14_8_COL_PARTICIPANTS: readonly ParticipantExpectation[] = + T14_8_COL_IMPORTS.map((declaration, index) => ({ + file: T14_8_COL_FILE, + window: byteWindow( + T14_8_COL_IMPORTS.slice(0, index) + .map((line) => `${line}\n`) + .join(""), + declaration, + ), + })); + +// Cross-file dependency cycle a→b→a with its unavoidable mutual-import spec +// import cycle (the module-header note): exactly two 14.9 findings — the +// dependency cycle's full path rendered as every participating reference +// spelling's location (the `d`-bearing elements, one per file), the import +// cycle's as every participating import declaration's — told apart by which +// disjoint windows their locations fall in. +const T14_8_CYC_A_FILE = "specs/CycA.mdx"; +const T14_8_CYC_B_FILE = "specs/CycB.mdx"; +const T14_8_CYC_A_IMPORT = 'import B from "./CycB.xspec"'; +const T14_8_CYC_A_ELEMENT = '<S id="a" d={B.b}>\nCycle A behavior text.\n</S>'; +const T14_8_CYC_B_IMPORT = 'import A from "./CycA.xspec"'; +const T14_8_CYC_B_ELEMENT = '<S id="b" d={A.a}>\nCycle B behavior text.\n</S>'; +const T14_8_CYC_FILES: Readonly<Record<string, string>> = { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [T14_8_CYC_A_FILE]: `${T14_8_CYC_A_IMPORT}\n\n${T14_8_CYC_A_ELEMENT}\n`, + [T14_8_CYC_B_FILE]: `${T14_8_CYC_B_IMPORT}\n\n${T14_8_CYC_B_ELEMENT}\n`, +}; +const T14_8_CYC_SPELLING_PARTICIPANTS: readonly ParticipantExpectation[] = [ + { + file: T14_8_CYC_A_FILE, + window: byteWindow(`${T14_8_CYC_A_IMPORT}\n\n`, T14_8_CYC_A_ELEMENT), + }, + { + file: T14_8_CYC_B_FILE, + window: byteWindow(`${T14_8_CYC_B_IMPORT}\n\n`, T14_8_CYC_B_ELEMENT), + }, +]; +const T14_8_CYC_IMPORT_PARTICIPANTS: readonly ParticipantExpectation[] = [ + { file: T14_8_CYC_A_FILE, window: byteWindow("", T14_8_CYC_A_IMPORT) }, + { file: T14_8_CYC_B_FILE, window: byteWindow("", T14_8_CYC_B_IMPORT) }, +]; + +// Pure spec import cycle (SPEC 2.1: invalid even when no requirement-level +// dependency cycle exists): mutual imports whose bindings are never used — +// valid individually, recording no edges — so the import cycle is the +// workspace's only condition, one condition-9 finding locating every +// participating import declaration. +const T14_8_IMP_A_FILE = "specs/ImpA.mdx"; +const T14_8_IMP_B_FILE = "specs/ImpB.mdx"; +const T14_8_IMP_A_IMPORT = 'import B from "./ImpB.xspec"'; +const T14_8_IMP_B_IMPORT = 'import A from "./ImpA.xspec"'; +const T14_8_IMP_FILES: Readonly<Record<string, string>> = { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [T14_8_IMP_A_FILE]: `${T14_8_IMP_A_IMPORT}\n\n<S id="ia">\nImport-cycle A text, binding unused.\n</S>\n`, + [T14_8_IMP_B_FILE]: `${T14_8_IMP_B_IMPORT}\n\n<S id="ib">\nImport-cycle B text, binding unused.\n</S>\n`, +}; +const T14_8_IMP_PARTICIPANTS: readonly ParticipantExpectation[] = [ + { file: T14_8_IMP_A_FILE, window: byteWindow("", T14_8_IMP_A_IMPORT) }, + { file: T14_8_IMP_B_FILE, window: byteWindow("", T14_8_IMP_B_IMPORT) }, +]; + +// No-occurrence MDX embedding spelling (SPEC 14, 14.6, 5.7): a local +// `text(...)` embedding whose target resolves to nothing records no +// occurrence, so its condition-6 finding's range is the FULL braced +// container, opening brace through closing brace — the span its occurrence +// would occupy — byte-exact (prose on both sides keeps the container off +// the file's ends, so an end-widened or line-granular range fails). +const T14_8_EMB_FILE = "specs/Emb.mdx"; +const T14_8_EMB_PREFIX = '<S id="emb">\nProse before the embedding.\n\n'; +const T14_8_EMB_CONTAINER = '{text("emb.nope")}'; +const T14_8_EMB_SOURCE = `${T14_8_EMB_PREFIX}${T14_8_EMB_CONTAINER}\n\nProse after keeps the container off the file end.\n</S>\n`; +const T14_8_EMB_RANGE = { + start: Buffer.byteLength(T14_8_EMB_PREFIX, "utf8"), + end: + Buffer.byteLength(T14_8_EMB_PREFIX, "utf8") + + Buffer.byteLength(T14_8_EMB_CONTAINER, "utf8"), +}; + +// Policy finding (SPEC 7.5, 14.12, 12.7): one forbidden rule over the spec +// group and one `depends` edge between its nodes — `build` never evaluates +// policy, so the premise build passes and `check` reports exactly the one +// violation, locations `[]`, path `null`, its context identities alone in +// 14.12's contractual order. +const T14_8_POLICY_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + policy: [ + { + name: "no-spec-deps", + type: "forbidden", + from: { group: "main" }, + to: { group: "main" } + } + ] +}) +`; +const T14_8_POL_FILE = "specs/Pol.mdx"; +const T14_8_POL_SOURCE = [ + '<S id="a">', + "Policy target text.", + "</S>", + "", + '<S id="p" d={"a"}>', + "Policy source text.", + "</S>", + "", +].join("\n"); + +const T14_8 = defineProductTest({ + id: "T14-8", + title: + "location cardinality: a condition several constructs jointly violate is one finding locating every participant, each in its containing file — a triple-duplicated ID is one condition-3 finding with three locations, one per bearer, no representative chosen; an import-binding collision is one condition-15 finding locating every colliding declaration; a cross-file dependency cycle is one condition-9 finding locating its full path — every participating reference spelling — beside exactly one further condition-9 finding locating the co-staged spec import cycle's every participating import declaration, a pure mutual-import cycle with unused bindings reporting exactly that one finding; a no-occurrence MDX embedding spelling's condition-6 finding has the full braced container as its byte-exact range, the span its occurrence would occupy, keeping T11.4-6's byte classification exact; a policy finding carries locations [], path null, its context identities alone; location order within a finding is file bytes, then start, then end (SPEC 14, 12.7, 5.7, 5.3, 2.1, 14.12)", + timeoutMs: 180_000, + run: async (product) => { + // --- Triple-duplicated ID → one 14.3 finding with three locations. + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [T14_8_DUP_FILE]: T14_8_DUP_SOURCE, + }, + }, + async (workspace) => { + const context = "T14-8 `build --json` over a triple-duplicated ID"; + const findings = await buildFindings(product, workspace, context); + assertConditionCounts( + findings, + { "14.3": 1 }, + `${context} — the duplication is ONE finding (one condition the ` + + `three bearers jointly violate), never one per occurrence, and ` + + `the workspace's only condition (SPEC 14, 14.3)`, + ); + assertFindingLocatesParticipants( + findingOf(findings, "14.3", context), + T14_8_DUP_PARTICIPANTS, + `${context}: the condition-3 finding locates every bearer`, + ); + }, + ); + + // --- Import-binding collision → one 14.15 finding locating every + // colliding declaration. + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [T14_8_COL_FILE]: T14_8_COL_SOURCE, + "specs/One.mdx": '<S id="one">\nTarget one text.\n</S>\n', + "specs/Two.mdx": '<S id="two">\nTarget two text.\n</S>\n', + }, + }, + async (workspace) => { + const context = "T14-8 `build --json` over an import-binding collision"; + const findings = await buildFindings(product, workspace, context); + assertConditionCounts( + findings, + { "14.15": 1 }, + `${context} — the collision is ONE finding (one condition the two ` + + `declarations jointly violate) and the workspace's only ` + + `condition: each import is individually valid, unused bindings ` + + `included (SPEC 2.1, 14, 14.15)`, + ); + assertFindingLocatesParticipants( + findingOf(findings, "14.15", context), + T14_8_COL_PARTICIPANTS, + `${context}: the condition-15 finding locates every colliding ` + + `declaration — the first included`, + ); + }, + ); + + // --- Cross-file dependency cycle → one 14.9 finding locating every + // participating reference spelling, beside the one 14.9 finding locating + // the unavoidable import cycle's every participating import declaration. + await withWorkspace({ files: T14_8_CYC_FILES }, async (workspace) => { + const context = + "T14-8 `build --json` over a cross-file dependency cycle (with its " + + "unavoidable mutual-import spec import cycle)"; + const findings = await buildFindings(product, workspace, context); + assertConditionCounts( + findings, + { "14.9": 2 }, + `${context} — two distinct condition-9 violations are present (the ` + + `dependency cycle; the spec import cycle), each ONE finding — ` + + `never merged, never split per file or per rotation (SPEC 5.3, ` + + `2.1, 14, 14.9)`, + ); + const dependencyMatches = findings.filter((finding) => + locationsMatchParticipants(finding, T14_8_CYC_SPELLING_PARTICIPANTS), + ); + const importMatches = findings.filter((finding) => + locationsMatchParticipants(finding, T14_8_CYC_IMPORT_PARTICIPANTS), + ); + if (dependencyMatches.length !== 1 || importMatches.length !== 1) { + fail( + `${context}: of the two 14.9 findings, exactly one must locate ` + + `the dependency cycle's full path — every participating ` + + `reference spelling, one location per \`d\`-bearing element in ` + + `its containing file — and exactly one must locate every ` + + `participating import declaration (SPEC 14, 5.3, 2.1, 12.7; the ` + + `windows are disjoint by construction); got ` + + `${String(dependencyMatches.length)} spelling-located and ` + + `${String(importMatches.length)} import-located among ` + + `${JSON.stringify(findings)}`, + ); + } + assertFindingLocatesParticipants( + dependencyMatches[0]!, + T14_8_CYC_SPELLING_PARTICIPANTS, + `${context}: the dependency-cycle finding`, + ); + assertFindingLocatesParticipants( + importMatches[0]!, + T14_8_CYC_IMPORT_PARTICIPANTS, + `${context}: the import-cycle finding`, + ); + }); + + // --- Pure spec import cycle → exactly one 14.9 finding locating every + // participating import declaration. + await withWorkspace({ files: T14_8_IMP_FILES }, async (workspace) => { + const context = + "T14-8 `build --json` over a pure mutual-import spec import cycle " + + "(bindings unused, so no dependency edge exists)"; + const findings = await buildFindings(product, workspace, context); + assertConditionCounts( + findings, + { "14.9": 1 }, + `${context} — the import cycle is the workspace's only condition ` + + `and ONE finding (SPEC 2.1, 14, 14.9)`, + ); + assertFindingLocatesParticipants( + findingOf(findings, "14.9", context), + T14_8_IMP_PARTICIPANTS, + `${context}: the condition-9 finding locates every participating ` + + `import declaration`, + ); + }); + + // --- No-occurrence MDX embedding spelling → the 14.6 finding's range is + // the full braced container, byte-exact. + await withWorkspace( + { + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [T14_8_EMB_FILE]: T14_8_EMB_SOURCE, + }, + }, + async (workspace) => { + const context = + "T14-8 `build --json` over a no-occurrence MDX embedding spelling"; + const findings = await buildFindings(product, workspace, context); + assertConditionCounts( + findings, + { "14.6": 1 }, + `${context} — the unresolving local \`text(...)\` target is the ` + + `workspace's only condition (SPEC 14.6)`, + ); + const finding = findingOf(findings, "14.6", context); + assertSameJson( + finding.locations, + [{ file: T14_8_EMB_FILE, range: T14_8_EMB_RANGE }], + `${context}: the condition-6 finding's one location is the FULL ` + + `braced container, opening brace through closing brace — the ` + + `span its occurrence would occupy — byte-exact (SPEC 14, 5.7; ` + + `keeping T11.4-6's byte classification exact)`, + ); + if (finding.path !== null) { + fail( + `${context}: a located condition's finding concerns no path — ` + + `\`path\` null (SPEC 12.7, 14); got ` + + `${JSON.stringify(finding.path)}`, + ); + } + }, + ); + + // --- Policy finding → locations [], path null, context identities alone. + await withWorkspace( + { + files: { + "xspec.config.ts": T14_8_POLICY_CONFIG, + [T14_8_POL_FILE]: T14_8_POL_SOURCE, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T14-8 policy staging `build` — build never evaluates policy " + + "(SPEC 7.5, 12.1), so the premise build passes", + ); + const context = + "T14-8 `check --json` over the one forbidden `depends` edge"; + const findings = await checkFindings(product, workspace, context); + assertConditionCounts( + findings, + { "14.12": 1 }, + `${context} — the forbidden rule's one violation (the sole ` + + `depends/embeds/references edge between "main" nodes) is the ` + + `freshly built workspace's only finding (SPEC 7.5, 14.12)`, + ); + assertSameJson( + findings.map((finding) => ({ + locations: finding.locations, + path: finding.path, + identities: finding.identities, + })), + [ + { + locations: [], + path: null, + identities: [ + "no-spec-deps", + `${T14_8_POL_FILE}#p`, + "depends", + `${T14_8_POL_FILE}#a`, + ], + }, + ], + `${context}: a policy finding, constraining an edge rather than ` + + `any file's content, carries no in-source locations and ` + + `concerns no path — \`locations\` [], \`path\` null — its ` + + `context identities alone, in order the violated rule's name ` + + `and the edge's source identity, kind token, and target ` + + `identity (SPEC 14.12, 12.7)`, + ); + }, + ); + }, +}); + +/** TEST-SPEC §14 T14-1…T14-8, in canonical ID order (SUITE-49). */ export const section14ValidationTests: readonly ProductTestEntry[] = [ T14_1, T14_2, @@ -2523,4 +3020,5 @@ export const section14ValidationTests: readonly ProductTestEntry[] = [ T14_5, T14_6, T14_7, + T14_8, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index d265a87..3850709 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -420,6 +420,13 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { // T6.4-*/T6.5-*/T5.3-1/T12.7-*/T12.0-*; the home passage "14" also // carries the invalid-workspace arm's asserted numbered condition (14.5). "T14-7": ["14"], + // T14-8: 5.7/11.4 (the embedding container span and the byte + // classification it keeps exact) and 12.7 (the finding form's location + // order) are context with home coverage at T5.7-2/T11.4-6/T12.7-*; 2.1 + // and 5.3 (the staged cycles) have home coverage at T2.1-5/T5.3-*. The + // home passage "14" carries the asserted numbered conditions (14.3, + // 14.15, 14.9, 14.6, 14.12). + "T14-8": ["14"], "T15-1": ["15"], "P-1": ["1.4", "2.6"], "P-2": ["3"], From 377a05744975f4765f49cc6490b209c467f40980 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 17 Aug 2026 00:29:08 +0000 Subject: [PATCH 127/226] sdg(phase-9): implement FP-082, the P-2 fence/code-span staging Generate fenced code blocks and inline code spans spelling tag-, import-, and expression-like bytes in the P-2/P-3 document generator (T3-1's grammar boundary; TEST-SPEC 16 P-2's named inclusion), every fence/span byte a content entry the oracle treats as content. Backticks and tildes stay out of free prose; fences are column-0 3-4 runs with backtick-free info strings and equal-run closers at every nesting depth, spans equal 1-2 backtick runs around non-empty backtick-free interiors, staged inline in prose lines and as own-line comments' sole residues. S-6 markdown-oracle vectors extended 31->35 for the grammar-boundary treatment; oracle module header documents the boundary (no functional change); conf-md's stale LF-only-staging comment corrected. Verified: per-seed dry-runs show the CONF-MD conformer passing P-2 and both violators falsified under every fixed seed; suite P-2/P-3 stay green against the built product; test:self unchanged 2 planned mid-loop reds (certification-document x2 -> FP-091), 272 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 38 +++++- test/fixtures/conf-md/product.mjs | 7 +- test/helpers/oracles/markdown.ts | 9 ++ test/self/s6-markdown-oracle.test.ts | 61 +++++++++ test/suite/registry/section-16-p2-p3.ts | 173 ++++++++++++++++++++---- 5 files changed, 259 insertions(+), 29 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 55489d9..3c14072 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3665,13 +3665,49 @@ certify against FP-091's fixtures once those land. ## Stage H — property layer (§16) and oracles (S-6) -- [ ] FP-082 — P-2 generator: include backticks/`~` so fenced code blocks +- [x] FP-082 — P-2 generator: include backticks/`~` so fenced code blocks and inline code spans spelling construct-like bytes are generated, with the oracle treating them as content. [R2 #36; TEST-SPEC §16 P-2] Generator/oracle: `test/helpers/oracles/markdown.ts` + `test/suite/registry/section-16-p2-p3.ts`. Keep the S-6 markdown-oracle vetted suite green (`test/self/s6-markdown-oracle.test.ts`) — extend its vectors for the new grammar-boundary treatment. + [Done 2026-08-17: backticks/`~` enter the generator only as deliberately + staged constructs — free prose keeps excluding them (a stray backtick + would open a span across a real construct and desynchronize the product's + parse from the generator's model): new `genFenceBlock` block shape + (column-0 fences, 3–4-run backtick or tilde markers, optional + backtick-free info string, 0–3 interior lines from T3-1's construct-like + set / free prose / empty, bare equal-run closer — always closed, staged + at every nesting depth so fences land inside block sections and in + embeddable targets' subtree text) and `codeSpan` generator (equal + 1–2-backtick runs around a non-empty backtick-free construct-like + interior), staged as a prose-line inline element and as an own-line + comment's sole residue (the span keeps its removal-affected line). Every + fence/span byte is a `content` entry, so the oracle — which never scans + content for construct-like patterns — needed no functional change + (module header now documents the T3-1/P-2 grammar boundary), and the + untouched-lines byte-preservation assertion covers them directly. S-6 + vectors 31→35: construct-like fence interior preserved amid real + removals; tilde fence's blank/whitespace-only interior lines kept + (CRLF); span as a removal-affected line's sole survivor; fence bytes + riding an expansion. Stale LF-only-staging justification in conf-md + `markdownLiteralRegions`' docstring corrected (comment only; region + scanning stays plain-line-model across the fixture family — the + deviation is compile-model-only per CERTIFICATIONS.md). Verified: + typecheck/format clean; S-6 35/35; scratch per-seed dry-runs (deleted + before commit) prove fixed-seed reach — 43 fences (21 backtick/22 tilde; + 23 inside sections; 12 empty interior lines), 8 span lines (4 beside + comments), 4 embedded fence-bearing targets across the 36 P-2 trials — + and per seed the CONF-MD conformer passes P-2 while VIOL-MD-CLASS and + VIOL-MD-CR are each falsified (flip-class reach re-verified against the + violator executables themselves; module comment records it); suite + section-16-p2-p3 stays 2 passed against the built product — a genuine + pass; direct probes confirmed it parses tilde/4-run/CR-terminated + fences, blank fence interiors, 2-run spans, and fence bytes in an + embedded target's subtree text exactly as staged; `npm run test:self` + unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091), + 272 passed.] - [ ] FP-083 — Implement the P-5 section-move category oracle + its S-6 vetted fixed-vector suite. [R2 #42; TEST-SPEC §16 P-5, §17 S-6; SPEC 6.2, diff --git a/test/fixtures/conf-md/product.mjs b/test/fixtures/conf-md/product.mjs index 8879a3e..333ef8d 100644 --- a/test/fixtures/conf-md/product.mjs +++ b/test/fixtures/conf-md/product.mjs @@ -695,8 +695,11 @@ const EMBED_OPEN_RE = /^\{[ \t]*text[ \t]*\(/; * * Line structure here is the plain Markdown one (LF, CRLF, lone CR) — never * the deviation-switchable compile line model: each violator's single - * deviation lives in the compile hooks alone, and the committed grammar- - * boundary staging is LF-only, where all models agree. + * deviation is defined on the compile's line model alone (CERTIFICATIONS.md), + * so construct recognition — fence and span regions included, the P-2 + * generator staging fences over mixed terminators — is identical across the + * whole fixture family, and only compiled bytes (with own/subtree text + * through SPEC 1.6) diverge under a deviation. */ function markdownLiteralRegions(text) { /** @type {{ start: number, end: number }[]} */ diff --git a/test/helpers/oracles/markdown.ts b/test/helpers/oracles/markdown.ts index 3a269b7..9d3ccd8 100644 --- a/test/helpers/oracles/markdown.ts +++ b/test/helpers/oracles/markdown.ts @@ -16,6 +16,15 @@ // it independent of the product (P-2: "an independent oracle ... the oracle // lives in the harness"). // +// Grammar boundary (TEST-SPEC T3-1, §16 P-2): constructs exist only where +// the MDX parse yields them — fenced code blocks and inline code spans are +// literal text, so construct-like bytes inside them (`<S id="x">`, `<div>`, +// `import X from "./X.xspec"`, `{text("a")}`) are plain content. Callers +// express that by passing every fence and span byte as a `content` piece; +// the oracle treats content uniformly — preserved verbatim, subject only to +// the drop rule under the 1.4 classes — and never scans content for +// construct-like patterns (the S-6 grammar-boundary vectors pin this). +// // SPEC.md 3, as implemented here: // // * Compilation removes spec module imports, `<S>`/`<Spec>` tags together diff --git a/test/self/s6-markdown-oracle.test.ts b/test/self/s6-markdown-oracle.test.ts index 811aac6..b9c4501 100644 --- a/test/self/s6-markdown-oracle.test.ts +++ b/test/self/s6-markdown-oracle.test.ts @@ -10,6 +10,10 @@ // in parentheses): // * removals of imports, tags with all their props, and comments; byte // preservation of everything else (T3-1); +// * the grammar boundary: fenced-code-block and inline-code-span bytes are +// content — callers pass them as content pieces, and the oracle +// preserves them verbatim, construct-like spellings included (T3-1's +// boundary, the P-2 generator's fence/span staging); // * text(...) replacement, fully expanded through chains, expansions // inserted verbatim (T3-2); // * the line-drop rule with all counter-cases, the 1.4 class boundaries — @@ -104,6 +108,63 @@ test("S-6 (T3-1): a document without constructs compiles to itself, final termin expectCompiled([content("plain\ntext \n\nend ")], "plain\ntext \n\nend "); }); +// --- grammar boundary: fence/span bytes are content (T3-1, P-2) -------------- + +test("S-6 (T3-1/P-2): fenced-code-block bytes spelling construct-like forms are content — preserved verbatim while real constructs are removed", () => { + // The P-2 generator stages fences as content pieces (constructs exist only + // where the MDX parse yields them); the oracle must preserve every fence + // byte and never scan content for construct-like patterns. + const fence = + '```md\n<S id="x">\nimport X from "./X.xspec"\n{text("a")}\n```'; + expectCompiled( + [ + removal('import BASE from "./BASE.xspec"'), + content(`\n${fence}\n`), + removal("{/* own-line comment */}"), + content("\ntail\n"), + ], + `${fence}\ntail\n`, + ); +}); + +test("S-6 (T3-1/P-2): a tilde fence's blank and whitespace-only interior lines are untouched content and are kept", () => { + // No construct touches the fence's interior lines, so the drop rule never + // fires for them — an empty and a whitespace-only interior line survive + // exactly, CRLF terminators included. + const fence = "~~~ts\r\n\r\n \t\r\n<div>\r\n~~~"; + expectCompiled( + [ + removal('<S id="a">'), + content(`\r\n${fence}\r\n`), + removal("</S>"), + content("\r\n"), + ], + `${fence}\r\n`, + ); +}); + +test("S-6 (T3-1/P-2): inline-code-span bytes are non-whitespace content — a removal-affected line holding only the span is kept", () => { + expectCompiled( + [ + content("a\n"), + removal("{/* c */}"), + content('`<S id="x">{text("a")}`\nb\n'), + ], + 'a\n`<S id="x">{text("a")}`\nb\n', + ); +}); + +test("S-6 (T3-2/P-2): an expansion carrying fence bytes is inserted verbatim — an embedded target's fences ride the replacement", () => { + expectCompiled( + [ + content("pre\n"), + embedding('{text("t")}', "```\n<div>\n```\n"), + content("\npost\n"), + ], + "pre\n```\n<div>\n```\n\npost\n", + ); +}); + // --- replacement (T3-2) ------------------------------------------------------ test("S-6 (T3-2): a text(...) expression is replaced by its expansion at its position", () => { diff --git a/test/suite/registry/section-16-p2-p3.ts b/test/suite/registry/section-16-p2-p3.ts index 16ab0e0..239fa71 100644 --- a/test/suite/registry/section-16-p2-p3.ts +++ b/test/suite/registry/section-16-p2-p3.ts @@ -3,11 +3,14 @@ // Two registered product-facing property tests (C-2 "one code path") sharing // one seeded random-document generator (helpers/property.ts, H-10; fixed seed // set in CI, E-5). Each trial generates a workspace of 1–3 `.mdx` spec -// sources composed of prose blocks, nested sections, imports, single- and -// multi-line MDX comments, and same-file and cross-file `{text(...)}` -// embeddings, over mixed line terminators (LF, CRLF, lone CR), with content -// weighted toward the whitespace/non-whitespace boundary code points of -// SPEC 1.4 (U+00A0, U+0085, U+2028 included) — exactly the P-2 input space. +// sources composed of prose blocks — fenced code blocks and inline code +// spans spelling tag-, import-, and expression-like bytes included (T3-1's +// grammar boundary: such bytes are content) — nested sections, imports, +// single- and multi-line MDX comments, and same-file and cross-file +// `{text(...)}` embeddings, over mixed line terminators (LF, CRLF, lone CR), +// with content weighted toward the whitespace/non-whitespace boundary code +// points of SPEC 1.4 (U+00A0, U+0085, U+2028 included) — exactly the P-2 +// input space. // // * P-2 — for every file, `build` under `markdown: { emit: true }` emits // Markdown byte-equal to the independent harness oracle @@ -38,7 +41,9 @@ // 1.4/3, dropped under the CLASS deviation), and lone-CR terminators on and // around removal-affected lines (line extents, and therefore drops and kept // bytes, diverge under the CR deviation) — verified by a per-seed dry-run -// against deviation-simulating oracles at implementation time. P-3 asserts +// against deviation-simulating oracles at implementation time, and +// re-verified per seed against the violator executables themselves when the +// fence/code-span staging landed (the choice streams shifted). P-3 asserts // only product-internal consistency, which both violators preserve // ("consistently in Markdown output and, through 1.6, in own and subtree // text"), so P-3 passes against every CONF-MD fixture while P-2 fails @@ -49,13 +54,32 @@ // // Staging discipline (byte-exact per HARNESS-01; the generator, not the // oracle, owns these choices): -// * Generated prose draws from an alphabet that excludes MDX-structural -// characters — `<`, `{`, `}`, backtick, `~`, `>`, `&`, `\` — so a prose -// byte can never open a fence, JSX tag, expression container, blockquote -// lazy-continuation, or character reference that would make the -// product's construct parse diverge from the generator's structure. -// Everything else (Markdown punctuation included) is plain content to -// SPEC 3, which never interprets Markdown semantics. +// * Generated free prose draws from an alphabet that excludes +// MDX-structural characters — `<`, `{`, `}`, backtick, `~`, `>`, `&`, +// `\` — so a prose byte can never open a fence, JSX tag, expression +// container, blockquote lazy-continuation, or character reference that +// would make the product's construct parse diverge from the generator's +// structure. Everything else (Markdown punctuation included) is plain +// content to SPEC 3, which never interprets Markdown semantics. +// * Backticks and `~` appear only inside deliberately staged fenced code +// blocks and inline code spans (T3-1's grammar boundary, the P-2 entry's +// named inclusion) — complete by construction and within the grammar +// subset every certified model shares: fences open at column 0 with a +// run of 3–4 backticks or tildes plus an optional backtick-free +// identifier info string, close with a bare run of the same character +// and length, and hold interior lines that never spell a fence marker +// (the interior alphabet has no backtick or `~`); code spans are +// single-line, open and close with equal-length runs of 1–2 backticks, +// and hold a non-empty backtick-free interior (an empty interior would +// merge the two runs into one). Interior bytes spell the construct-like +// forms T3-1 fixes — `<S id="x">`, `<div>`, +// `import X from "./X.xspec"`, `{text("a")}` — plus free prose. Every +// fence and span byte is a `content` entry: constructs exist only where +// the MDX parse yields them, so the oracle treats these bytes as +// content (preserved verbatim; their lines carry the marker or span +// runs as non-whitespace, and interior blank or whitespace-only lines +// are untouched lines, kept), and the direct byte-preservation +// assertion sees them as ordinary untouched lines. // * Section tags, imports, and embeddings are single-line and ASCII; the // exotic bytes live in content, where P-2 aims them. Multi-line comments // carry 1–2 internal terminators and no internal blank line (MDX @@ -128,6 +152,10 @@ const CRLF = CR + LF; const NBSP = cp(0x00a0); const NEL = cp(0x0085); const LS = cp(0x2028); +// Fence and code-span marker characters — staged only inside deliberately +// constructed fences and spans, never drawn into free prose (module header). +const BACKTICK = cp(0x0060); +const TILDE = cp(0x007e); // --------------------------------------------------------------------------- // Document IR @@ -369,6 +397,44 @@ const terminator: Gen<string> = (choices) => [3, CR], ]); +// Construct-like literal bytes (T3-1's grammar-boundary set, the P-2 entry's +// named inclusion): spelled inside fenced code blocks and inline code spans, +// where the MDX parse makes them plain content. A product recognizing +// constructs by textual pattern instead of by parse turns them into phantom +// constructs — a finding failing `build` exit 0, or bytes missing from the +// compiled output failing the oracle and byte-preservation arms. Backtick- +// and tilde-free, so none can close a span or spell a fence marker. +const CONSTRUCT_LIKE_LINES = [ + '<S id="x">', + "<div>", + 'import X from "./X.xspec"', + '{text("a")}', + "</S>", + "{/* not a comment */}", +] as const; + +/** Single-line code-span interiors: non-empty, backtick-free (module header). */ +const CONSTRUCT_LIKE_SPAN_INTERIORS = [ + '<S id="x">', + '{text("a")}', + '<S id="x">{text("a")}', + 'import X from "./X.xspec"', + "<div>", +] as const; + +/** + * A complete inline code span on one line: equal-length runs of 1–2 + * backticks around a non-empty backtick-free construct-like interior — the + * exact shape both the CommonMark/MDX grammar and CONF-MD's modeled subset + * close where the generator says (an empty interior would merge the two runs + * into one). Always emitted as a `content` entry: span bytes are literal + * text (T3-1). + */ +const codeSpan: Gen<string> = (choices) => { + const marker = BACKTICK.repeat(choices.intInclusive(1, 2)); + return `${marker}${choices.pick(CONSTRUCT_LIKE_SPAN_INTERIORS)}${marker}`; +}; + // --------------------------------------------------------------------------- // Per-file generation @@ -497,6 +563,7 @@ function genBlock( | "comment" | "multiComment" | "embedLine" + | "fence" | "section" | "selfClosing" >([ @@ -506,6 +573,7 @@ function genBlock( [3, "comment"], [2, "multiComment"], [3, "embedLine"], + [2, "fence"], [5, "section"], [2, "selfClosing"], ]); @@ -526,6 +594,9 @@ function genBlock( case "multiComment": genMultiLineComment(choices, ctx, out); return; + case "fence": + genFenceBlock(choices, ctx, out); + return; case "embedLine": { const ref = pickRef(choices, ctx); if (ref === null) { @@ -571,12 +642,13 @@ function registerSection( } /** - * A prose line: free content, optionally hosting one inline construct — an - * inline comment, an inline embedding, a one-line section, or a self-closing - * section — with content around it. A line hosting an inline section always - * carries a guaranteed-kept plain prose anchor, so the line is kept under - * SPEC 3 and the section's contribution is exactly its interior bytes - * (module header). + * A prose line: free content, optionally hosting one inline element — an + * inline comment, an inline embedding, an inline code span whose + * construct-like bytes are literal content (T3-1), a one-line section, or a + * self-closing section — with content around it. A line hosting an inline + * section always carries a guaranteed-kept plain prose anchor, so the line + * is kept under SPEC 3 and the section's contribution is exactly its + * interior bytes (module header). */ function genProseLine( choices: Choices, @@ -591,10 +663,11 @@ function genProseLine( return; } const inline = choices.weightedPick< - "comment" | "embed" | "inlineSection" | "inlineSelfClosing" + "comment" | "embed" | "codeSpan" | "inlineSection" | "inlineSelfClosing" >([ [3, "comment"], [3, "embed"], + [2, "codeSpan"], [3, "inlineSection"], [1, "inlineSelfClosing"], ]); @@ -603,6 +676,10 @@ function genProseLine( case "comment": pieces.push({ kind: "removal", text: `{/*${commentProse(choices)}*/}` }); break; + case "codeSpan": + // Literal span bytes amid prose — content, never a construct (T3-1). + pieces.push({ kind: "content", text: codeSpan(choices) }); + break; case "embed": { const ref = pickRef(choices, ctx); if (ref !== null) { @@ -649,7 +726,9 @@ function genProseLine( * A single-line own-line comment, optionally with a residue on the line — * weighted toward the T3-3 arms: a boundary-code-point-only residue (kept * under SPEC 1.4, the §VIOL-MD-CLASS flip), a 1.4-whitespace residue (the - * line still drops), mixes, and plain kept residues. + * line still drops), mixes, plain kept residues, and an inline code span as + * the line's sole other survivor (non-whitespace literal content, T3-1: the + * removal-affected line is kept holding exactly the span bytes). */ function genCommentLine( choices: Choices, @@ -681,6 +760,7 @@ function genCommentLine( )(c), ], [2, run(plainChar, 1, 3)], + [2, codeSpan], ])(choices); const residueFirst = choices.boolean(0.3); if (residue !== "" && residueFirst) { @@ -718,6 +798,46 @@ function genMultiLineComment( endLine(choices, ctx, out, false); } +/** + * A fenced code block (T3-1's grammar boundary; module header): an opening + * fence line — column 0, a run of 3–4 backticks or tildes, an optional + * backtick-free identifier info string — 0–3 interior lines spelling + * construct-like bytes, free prose, or nothing, and a bare closing fence of + * the same character and length. Every byte is a `content` entry: fences are + * literal text under the MDX grammar, so the oracle and a conforming product + * alike treat the interior's construct-like spellings as plain content, and + * the fence's lines are ordinary logical lines (marker lines carry + * non-whitespace; interior blank or whitespace-only lines are untouched and + * kept). Interior alphabets contain no backtick or `~`, so no interior line + * can spell a fence marker and the fence closes exactly where the generator + * says it does — fenced code blocks interrupt paragraphs in CommonMark, so + * no blank-line separation is needed around the block. + */ +function genFenceBlock( + choices: Choices, + ctx: FileContext, + out: DocEntry[], +): void { + const marker = choices + .pick([BACKTICK, TILDE] as const) + .repeat(choices.intInclusive(3, 4)); + const info = choices.pick(["", "ts", "md"] as const); + out.push({ kind: "content", text: `${marker}${info}` }); + endLine(choices, ctx, out, false); + const interiorLines = choices.intInclusive(0, 3); + for (let index = 0; index < interiorLines; index += 1) { + const line = choices.weightedPick<Gen<string>>([ + [4, (c: Choices) => c.pick(CONSTRUCT_LIKE_LINES)], + [2, prose], + [1, () => ""], + ])(choices); + if (line !== "") out.push({ kind: "content", text: line }); + endLine(choices, ctx, out, line === ""); + } + out.push({ kind: "content", text: marker }); + endLine(choices, ctx, out, false); +} + /** * A block section: opening tag alone on its line, interior blocks one level * deeper, closing tag alone on its line; registered as an embeddable target @@ -1160,11 +1280,12 @@ async function runP3Trial( const P_2 = defineProductTest({ id: "P-2", title: - "property: random documents (prose, nested sections, imports, single- and multi-line " + - "comments, embeddings, mixed line terminators, boundary-code-point-weighted content) " + - "compile to Markdown byte-equal to the harness's SPEC 3 oracle, deterministically " + - "across directories, preserving content bytes outside removed constructs " + - "(SPEC 3, 1.4, 1.6, 7.3; TEST-SPEC §16 P-2)", + "property: random documents (prose, fenced code blocks and inline code spans spelling " + + "tag-, import-, and expression-like bytes as literal content, nested sections, imports, " + + "single- and multi-line comments, embeddings, mixed line terminators, " + + "boundary-code-point-weighted content) compile to Markdown byte-equal to the harness's " + + "SPEC 3 oracle, deterministically across directories, preserving content bytes outside " + + "removed constructs (SPEC 3, 1.4, 1.6, 7.3; TEST-SPEC §16 P-2)", // Wall-clock hang guard only (H-10): three fixed seeds (E-5), two // workspaces and two builds per trial, plus the shrink budget. timeoutMs: 300_000, From 8a403ee78f084d8866145791ef438e074259df6c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 17 Aug 2026 01:21:42 +0000 Subject: [PATCH 128/226] sdg(phase-9): implement FP-083, the P-5 section-move category oracle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test/helpers/oracles/section-move.ts: predictSectionMoveImpact — the SPEC 6.2/5.6 prediction for the section form of `xspec move`, from piece-tree before-documents plus the move, after side derived per 6.5's edit rules, every line's keep/drop decision delegated to P-2's compileMarkdown; per-node category predictions with attributions and the T6.2-3 two-sided tolerance, changed/added sets, identity map, own-content sequences; the P-5 exactly-three-groups changed-set pin guarded as oracle misuse. test/self/s6-section-move-oracle.test.ts: the S-6 vetted fixed-vector suite (17 vectors) — SPEC 6.2's worked straddling-line case, T6.2-3's clean boundary, T6.2-4's pure final position and its non-final contrast, the created-target added-root rule, self-closing arms (T6.5-2's rewrite), insertion terminators, expansion-keeps-line delegation, misuse guards. Teeth proven by five scratch mutation probes, all caught. npm run test:self: unchanged 2 planned mid-loop reds (certification-document x2, cleared by FP-091), 289 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 48 +- test/helpers/oracles/section-move.ts | 1290 ++++++++++++++++++++++ test/self/s6-section-move-oracle.test.ts | 811 ++++++++++++++ 3 files changed, 2148 insertions(+), 1 deletion(-) create mode 100644 test/helpers/oracles/section-move.ts create mode 100644 test/self/s6-section-move-oracle.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 3c14072..33dc57e 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3709,12 +3709,58 @@ certify against FP-091's fixtures once those land. unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091), 272 passed.] -- [ ] FP-083 — Implement the P-5 section-move category oracle + its S-6 +- [x] FP-083 — Implement the P-5 section-move category oracle + its S-6 vetted fixed-vector suite. [R2 #42; TEST-SPEC §16 P-5, §17 S-6; SPEC 6.2, 5.6] New oracle under `test/helpers/oracles/`; vetted vectors: SPEC 6.2's worked straddling-line case plus the T6.2-3/T6.2-4 cases, in a new `test/self/s6-*-oracle.test.ts`. + [Done 2026-08-17: `test/helpers/oracles/section-move.ts` + (`predictSectionMoveImpact`) + `test/self/s6-section-move-oracle.test.ts` + (17 vectors). Interface FP-084 wires to: input is the BEFORE state in + baseline identities — origin/target piece-tree documents (content / + removal / embedding-with-expansion-and-target / nested sections carrying + `id`, tag bytes, `depends`; same object for a same-file move; + `{createdPath}` for a created target) plus movedId/newId and `otherNodes` + (untouched files' children+edgeTargets, for the cascade graph) — the + oracle derives the after side itself (6.5 at the piece level: origin + deletion enters the compile as one removal piece, insertion appends the + moved construct as the parent's last child with the U+000A rules judged + over post-deletion bytes, self-closing target parent rewritten per + T6.5-2) and reports in current identities: per-node full category + predictions ({required|tolerated-optional, attributionWithin, + attributionMustInclude} — the T6.2-3 two-sided tolerance is + required:false on cascades whose only cause is a relocated one-side-only + member; `metadata-changed` excluded by vocabulary), changed/added sets, + identityMap, and before/after own-content token sequences (runs + child/ + embed reference tokens, the P-4 shape). Every logical line's keep/drop + decision is delegated to P-2's `compileMarkdown` (the line's pieces plus + terminator; empty output = dropped), so the two oracles cannot disagree + on SPEC 3 — expansion semantics included. Guards FP-084's generator must + respect: a sequence change outside {origin parent, target parent, moved + subtree} throws (P-5's exactly-three-groups pin — no other node's bytes + on a line whose keep/drop status the move flips); single-line tags; + self-closing = empty body; complete cascade graph (dangling edge targets + throw); embedding expansions are emptiness-stable across the move + (documented staged scope — only emptiness enters the drop decision, and + the generators stage no empty subtree texts). Vectors: T6.2-3 clean + boundary (full table incl. Watch dependents' exact attributions), SPEC + 6.2's worked case (origin remainder+terminator contributed, destination + line dropped, moved node changed, parents/roots with the documented + tolerance), T6.2-4 pure final-position (empty table, sequence reproduced) + + non-final contrast (coincident parent alone changed), created-target + root changed-as-added carrying no other category over a changed moved + descendant, self-closing moved section and self-closing target parent + (T6.5-2 bytes), mid-line insertion's preceding U+000A landing in the + parent's run, non-empty-expansion-keeps-line delegation, 8 misuse guards + (sibling-flip staging first). Teeth proven by 5 scratch mutation probes + (each reverted): deletion-as-excise 2 vectors failed, dropped preceding + newline 1, added-rule removal 1, expansion stripping 1, + tolerance-made-required 3. Verified: typecheck/format clean; suite 17/17; + `npm run test:self` unchanged 2 planned mid-loop reds + (certification-document ×2 → FP-091), 289 passed. section-16-p5-p6.ts + untouched — wiring is FP-084's task (FP-085 extracts the P-6 graph-diff + oracle separately).] - [ ] FP-084 — Generalize P-5 to random section moves using the full 6.2/5.6 oracle. [R2 #37; TEST-SPEC §16 P-5] After FP-083. diff --git a/test/helpers/oracles/section-move.ts b/test/helpers/oracles/section-move.ts new file mode 100644 index 0000000..7fa11b5 --- /dev/null +++ b/test/helpers/oracles/section-move.ts @@ -0,0 +1,1290 @@ +// In-harness section-move category oracle (TEST-SPEC 16 P-5, 17 S-6): an +// independent implementation of the SPEC.md 6.2/5.6 prediction for the +// section form of `xspec move` — which nodes are `changed` and exactly which +// 5.6 cascades (`descendant-changed`, `upstream-changed`, attributions +// included) follow, relative to a baseline committed immediately before the +// move. Per S-6, the oracle passes its fixed vector suite +// (test/self/s6-section-move-oracle.test.ts) — derived from SPEC.md 6.2's +// worked straddling-line case plus the clean-boundary and final-position +// cases of TEST-SPEC T6.2-3/T6.2-4 — before any property test trusts it. +// Harness machinery only: pure functions, no product imports, no I/O, no +// test-framework dependence. +// +// The oracle does not parse MDX (the markdown oracle's independence +// discipline): its caller — the P-5 generator, the S-6 vectors — composed +// the documents, so it describes them as piece trees (`SectionMovePiece`), +// every construct located by construction, and states the move +// (`movedId` → `newId` into `target`). Everything is stated in BASELINE +// identities; the oracle derives the identity mapping (prefix replacement, +// SPEC 6.5) and reports its prediction in CURRENT identities. +// +// SPEC.md 6.2/5.6 via TEST-SPEC P-5, as implemented here: +// +// * The `changed` set is drawn from exactly the origin parent, the target +// parent, and the moved subtree's nodes — each `changed` iff its own +// content sequence (1.6) differs across the move: +// - distinct parents necessarily (one loses a child reference, one +// gains one — reference tokens enter the sequence at their positions); +// - a created target file's root, present on no baseline side, is +// instead `changed` as an added node — by addition, not comparison — +// and per 5.6 carries no other category; +// - a coincident parent iff the re-insertion fails to reproduce its +// sequence (a final child re-inserted at its own former position is +// pure in effect, 6.2); +// - a moved-subtree node iff the straddling-line drops of 6.2 change +// its runs, computed by the line-drop rules of 3 — the oracle +// delegates every logical line's keep/drop decision to P-2's markdown +// oracle (`compileMarkdown`), so the two oracles cannot disagree on 3. +// Any other node whose sequence differs (a sibling whose whitespace-only +// residue rides a straddling line whose keep/drop status the move flips, +// an ancestor's bytes on the deletion's merged line) is outside P-5's +// staged input space and throws: the generator must never stage it. +// * `metadata-changed` on no node (6.2: every moved node keeps its +// metadataHash, and canonical identities preserve every other node's) — +// the prediction's category vocabulary simply excludes it. +// * `descendant-changed` and `upstream-changed` exactly per 5.6's cascades +// from the changed nodes, attributions included, with the two-sided +// tolerance T6.2-3 documents: SPEC 5.6's baseline comparison is defined +// for nodes present on both sides, and the relocated moved subtree is a +// descendant of each parent's chain on only one side — so a cascade whose +// only cause is a relocated (one-side-only) member is predicted as +// tolerated-optional (accepted present or absent), while a both-sides +// cause makes the category required with the causing originators pinned +// into its attribution. +// +// Own-content sequences (SPEC 1.6, 5.5, as the P-4 model pins them): per +// node, its own-text runs in document order interleaved with one reference +// token per child construct and per `text(...)` embedding, each entering as +// the referenced node's identity — child and embedding references +// distinguished. Reference tokens are unconditional (a construct on a +// dropped line still divides the runs, 1.6); run bytes are exactly the +// node's surviving content bytes under the rules of 3, expansions excluded +// (an embedded target's text is no part of the embedder's own content, 5.5) +// though a non-empty expansion still keeps its line (3, delegated). All +// reference values compare as canonical identities, which the journaled +// move preserves (5.4): the baseline side is mapped through the move's +// identity mapping before comparison, and the after side reads every +// reference in current identities. +// +// The after side is derived, not supplied: the oracle performs 6.5's edits +// at the piece level — the origin deletion enters the compile as one +// removal piece (its merged straddling line dropped iff left empty or +// whitespace-only, the rule of 3, which composes with the after compile's +// own removals to the same sequences the two-stage edit yields); the +// insertion places the moved construct as the target parent's last child, +// followed by a U+000A content piece and preceded by one when the insertion +// point is not at the start of a line in the post-deletion file bytes; a +// self-closing target parent is first rewritten to paired form (T6.5-2's +// byte rule). Import additions and removals are not modeled: 6.5 pins added +// imports as lines of their own and removals as the declaration plus its +// adjunct line drop, so import edits never touch any node's surviving runs +// or reference tokens. +// +// Staged-scope contracts (guarded where checkable, documented where not): +// section tags are single-line; a self-closing section has an empty body; +// an embedding's `expansion` is emptiness-stable across the move — only +// emptiness enters the drop decision (a non-empty expansion keeps its line +// regardless of content), and the P-5 generator stages no empty subtree +// texts, so the before-side expansion decides both sides. + +import { compileMarkdown } from "./markdown.js"; +import type { MarkdownPiece } from "./markdown.js"; + +// --------------------------------------------------------------------------- +// Input model + +/** One own-content token: a text run, or a child/embedding reference. */ +export type SectionMoveOwnToken = readonly [ + kind: "run" | "child" | "embed", + value: string, +]; + +/** One piece of a document, in document order (nested for sections). */ +export type SectionMovePiece = + | { + /** Plain source content: preserved, subject only to the drop rule. */ + readonly kind: "content"; + readonly text: string; + } + | { + /** + * A non-section removed construct's own characters — a spec module + * import declaration or an MDX comment (SPEC.md 3). May contain line + * terminators (a multi-line comment merges its lines when removed). + */ + readonly kind: "removal"; + readonly text: string; + } + | { + /** + * A `text(...)` embedding: `text` is the expression's own characters + * (the braced container included), `expansion` the target's compiled + * subtree text (caller-computed, the markdown oracle's contract), and + * `target` the referenced node's identity in baseline space. + */ + readonly kind: "embedding"; + readonly text: string; + readonly expansion: string; + readonly target: string; + } + | SectionMoveSection; + +/** A requirement-section construct (SPEC 1.1) with its nested body. */ +export interface SectionMoveSection { + readonly kind: "section"; + /** The section's dotted id exactly as spelled (SPEC 1.3). */ + readonly id: string; + /** Opening tag's own characters (the whole tag when self-closing). */ + readonly open: string; + /** Closing tag's own characters; `null` = self-closing (empty body). */ + readonly close: string | null; + readonly body: readonly SectionMovePiece[]; + /** The section's `d`-declared target identities, baseline space. */ + readonly depends: readonly string[]; +} + +/** A document: its workspace-relative path plus its pieces. */ +export interface SectionMoveDocument { + readonly path: string; + readonly pieces: readonly SectionMovePiece[]; +} + +/** + * A node of a file the move does not textually touch, carried for the 5.6 + * cascade computation (dependents live anywhere). Everything in baseline + * identities; the oracle maps reference targets through the move's mapping. + */ +export interface SectionMoveGraphNode { + readonly identity: string; + /** Direct child identities in document order. */ + readonly children: readonly string[]; + /** Dependency-edge target identities (`depends` and `embeds` union). */ + readonly edgeTargets: readonly string[]; +} + +export interface SectionMoveInput { + /** The origin document, before the move; contains the moved section. */ + readonly origin: SectionMoveDocument; + /** + * The target document before the move, or `{ createdPath }` when the + * move creates the target file. A same-file move passes the identical + * document object as both `origin` and `target`. + */ + readonly target: SectionMoveDocument | { readonly createdPath: string }; + /** Dotted id of the moved section in the origin document. */ + readonly movedId: string; + /** Dotted new id (SPEC 6.5); its parent chain locates the target parent. */ + readonly newId: string; + /** Nodes of every file not textually involved in the move. */ + readonly otherNodes?: readonly SectionMoveGraphNode[]; +} + +// --------------------------------------------------------------------------- +// Output model + +export type SectionMoveCategoryName = + "changed" | "descendant-changed" | "upstream-changed"; + +/** The prediction for one category of one node. */ +export interface SectionMoveCategoryPrediction { + /** + * True: the category must be reported. False: tolerated-optional — its + * only cause is a relocated one-side-only member (the T6.2-3 tolerance), + * so it is accepted present or absent. + */ + readonly required: boolean; + /** Sorted bound: the reported attribution must be a subset. */ + readonly attributionWithin: readonly string[]; + /** Sorted; a reported category's attribution must include these. */ + readonly attributionMustInclude: readonly string[]; +} + +/** Per-node prediction: absent category name = must not be reported. */ +export interface SectionMoveNodePrediction { + readonly categories: ReadonlyMap< + SectionMoveCategoryName, + SectionMoveCategoryPrediction + >; +} + +export interface SectionMovePrediction { + /** Baseline → current identities of the moved subtree (others map to themselves). */ + readonly identityMap: ReadonlyMap<string, string>; + /** Every current-graph node's prediction (one entry per node, possibly empty). */ + readonly nodes: ReadonlyMap<string, SectionMoveNodePrediction>; + /** The `changed` set — the originating nodes (added created-root included). */ + readonly changed: ReadonlySet<string>; + /** Current identities added by the move: the created target root, if any. */ + readonly added: ReadonlySet<string>; + /** Per-node own-content token sequences, baseline side, baseline identities. */ + readonly beforeOwnTokens: ReadonlyMap<string, readonly SectionMoveOwnToken[]>; + /** Per-node own-content token sequences, current side, current identities. */ + readonly afterOwnTokens: ReadonlyMap<string, readonly SectionMoveOwnToken[]>; +} + +// --------------------------------------------------------------------------- +// Guards + +function misuse(message: string): never { + throw new Error(`section-move oracle misuse: ${message}`); +} + +function defect(message: string): never { + throw new Error(`section-move oracle defect: ${message}`); +} + +function hasTerminator(text: string): boolean { + return text.includes("\n") || text.includes("\r"); +} + +function isTerminatorCode(code: number): boolean { + return code === 0x0a || code === 0x0d; +} + +/** SPEC 1.4 whitespace-only (the classes P-2's oracle pins). */ +function isWhitespaceOnly(text: string): boolean { + return /^[\t\n\v\f\r ]*$/.test(text); +} + +// --------------------------------------------------------------------------- +// Piece-tree utilities + +/** The source text a piece list concatenates to (tags and bodies included). */ +export function sectionMoveSourceText( + pieces: readonly SectionMovePiece[], +): string { + let text = ""; + for (const piece of pieces) { + if (piece.kind === "section") { + text += + piece.open + sectionMoveSourceText(piece.body) + (piece.close ?? ""); + } else { + text += piece.text; + } + } + return text; +} + +interface LocatedSection { + readonly section: SectionMoveSection; + /** Construct-range string indices into the document's source text. */ + readonly start: number; + readonly end: number; +} + +/** Locate the section spelling `id`, with its source-text range. */ +function locateSection( + pieces: readonly SectionMovePiece[], + id: string, + offset: number, +): LocatedSection | null { + let cursor = offset; + for (const piece of pieces) { + if (piece.kind === "section") { + const length = + piece.open.length + + sectionMoveSourceText(piece.body).length + + (piece.close ?? "").length; + if (piece.id === id) { + return { section: piece, start: cursor, end: cursor + length }; + } + const inner = locateSection(piece.body, id, cursor + piece.open.length); + if (inner !== null) return inner; + cursor += length; + } else { + cursor += piece.text.length; + } + } + return null; +} + +/** + * Replace the section spelling `id` with one removal piece holding its full + * source text — 6.5's origin deletion as a rule-of-3 removal: the merged + * straddling line enters the compile with the construct's characters + * counting as source non-whitespace and is dropped exactly when the + * deletion leaves it empty or whitespace-only. + */ +function replaceWithRemoval( + pieces: readonly SectionMovePiece[], + id: string, +): { readonly pieces: SectionMovePiece[]; readonly found: boolean } { + const out: SectionMovePiece[] = []; + let found = false; + for (const piece of pieces) { + if (!found && piece.kind === "section") { + if (piece.id === id) { + out.push({ + kind: "removal", + text: + piece.open + + sectionMoveSourceText(piece.body) + + (piece.close ?? ""), + }); + found = true; + continue; + } + const inner = replaceWithRemoval(piece.body, id); + if (inner.found) { + out.push({ ...piece, body: inner.pieces }); + found = true; + continue; + } + } + out.push(piece); + } + return { pieces: out, found }; +} + +/** Rewrite the moved subtree's section ids by prefix replacement. */ +function mapMovedIds( + section: SectionMoveSection, + mapDotted: (dotted: string) => string, +): SectionMoveSection { + const mapPieces = (pieces: readonly SectionMovePiece[]): SectionMovePiece[] => + pieces.map((piece) => + piece.kind === "section" + ? { ...piece, id: mapDotted(piece.id), body: mapPieces(piece.body) } + : piece, + ); + return { + ...section, + id: mapDotted(section.id), + body: mapPieces(section.body), + }; +} + +/** Map every reference (embedding target, `d` target) through `mapIdentity`. */ +function mapReferencesDeep( + pieces: readonly SectionMovePiece[], + mapIdentity: (identity: string) => string, +): SectionMovePiece[] { + return pieces.map((piece) => { + if (piece.kind === "section") { + return { + ...piece, + depends: piece.depends.map(mapIdentity), + body: mapReferencesDeep(piece.body, mapIdentity), + }; + } + if (piece.kind === "embedding") { + return { ...piece, target: mapIdentity(piece.target) }; + } + return piece; + }); +} + +/** + * The paired form of a self-closing target parent (SPEC 6.5, T6.5-2): the + * `/` and any whitespace immediately before or after it deleted from the + * tag, and the closing tag matching the opening tag's name appended. + */ +function pairSelfClosing(open: string): { + readonly open: string; + readonly close: string; +} { + const nameMatch = /^<\s*(Spec|S)\b/.exec(open); + if (nameMatch === null) { + misuse( + `a section's open tag must begin <S or <Spec (SPEC 1.1); got ${JSON.stringify(open)}`, + ); + } + if (!open.endsWith(">")) { + misuse(`a tag's own characters end with ">"; got ${JSON.stringify(open)}`); + } + const inner = open.slice(0, -1); + const stripped = inner.replace(/[\t\n\v\f\r ]*\/[\t\n\v\f\r ]*$/, ""); + if (stripped === inner) { + misuse( + `pairSelfClosing called on a non-self-closing tag ${JSON.stringify(open)}`, + ); + } + return { open: `${stripped}>`, close: `</${nameMatch[1]}>` }; +} + +/** + * Insert `moved` as the last child of the section spelling `parentId` + * (`null` = the document root): appended to the parent's body immediately + * before its closing tag (at the end of the piece list for the root), + * followed by a U+000A content piece and preceded by one when the insertion + * point is not at a line start (`atLineStart`, judged over the + * post-deletion file bytes). A self-closing parent is first rewritten to + * paired form, the insertion point then following its opening tag's `>` — + * never at a line start (T6.5-2's worked bytes). + */ +function insertMoved( + pieces: readonly SectionMovePiece[], + parentId: string | null, + moved: SectionMoveSection, + atLineStart: boolean, +): { readonly pieces: SectionMovePiece[]; readonly found: boolean } { + const newline: SectionMovePiece = { kind: "content", text: "\n" }; + const splice = (lineStart: boolean): SectionMovePiece[] => [ + ...(lineStart ? [] : [newline]), + moved, + newline, + ]; + if (parentId === null) { + return { pieces: [...pieces, ...splice(atLineStart)], found: true }; + } + const out: SectionMovePiece[] = []; + let found = false; + for (const piece of pieces) { + if (!found && piece.kind === "section") { + if (piece.id === parentId) { + found = true; + if (piece.close === null) { + const paired = pairSelfClosing(piece.open); + out.push({ + ...piece, + open: paired.open, + close: paired.close, + body: splice(false), + }); + } else { + out.push({ ...piece, body: [...piece.body, ...splice(atLineStart)] }); + } + continue; + } + const inner = insertMoved(piece.body, parentId, moved, atLineStart); + if (inner.found) { + out.push({ ...piece, body: inner.pieces }); + found = true; + continue; + } + } + out.push(piece); + } + return { pieces: out, found }; +} + +// --------------------------------------------------------------------------- +// Edit-stage file bytes (for the insertion's line-start decision) +// +// 6.5's insertion is "preceded by [a U+000A] when the insertion point is +// not at the start of a line" — a fact about the file bytes the insertion +// edits: the target document as staged, or (same-file move) the +// post-deletion origin bytes, where the deletion has removed the +// construct's characters and dropped its merged straddling line when the +// deletion left it empty or whitespace-only. + +interface EditStageDeletion { + readonly start: number; + readonly end: number; +} + +/** Whether `position` in `source` starts a line after applying `deletion`. */ +function atLineStartAfterDeletion( + source: string, + position: number, + deletion: EditStageDeletion | null, +): boolean { + const removed: [number, number][] = []; + if (deletion !== null) { + // The deletion's merged line over the original bytes (SPEC 3's line + // model; CRLF pairs never straddle the construct, whose own characters + // begin `<` and end `>`). + let lineStart = deletion.start; + while ( + lineStart > 0 && + !isTerminatorCode(source.charCodeAt(lineStart - 1)) + ) { + lineStart -= 1; + } + let residueEnd = deletion.end; + while ( + residueEnd < source.length && + !isTerminatorCode(source.charCodeAt(residueEnd)) + ) { + residueEnd += 1; + } + let lineEnd = residueEnd; + if (lineEnd < source.length) { + lineEnd += + source.charCodeAt(lineEnd) === 0x0d && + source.charCodeAt(lineEnd + 1) === 0x0a + ? 2 + : 1; + } + const residue = + source.slice(lineStart, deletion.start) + + source.slice(deletion.end, residueEnd); + removed.push( + isWhitespaceOnly(residue) + ? [lineStart, lineEnd] // dropped with its terminator (SPEC 6.5, 3) + : [deletion.start, deletion.end], + ); + } + // Walk backwards from `position` over the post-deletion bytes. + let i = position; + for (;;) { + const skip = removed.find(([from, to]) => i > from && i <= to); + if (skip !== undefined) { + i = skip[0]; + continue; + } + if (i === 0) return true; + return isTerminatorCode(source.charCodeAt(i - 1)); + } +} + +/** + * String index of the insertion point in the concatenation of `pieces`: + * the first character of the target parent's closing tag, or the end of + * the document for a top-level new id. + */ +function insertionPoint( + pieces: readonly SectionMovePiece[], + parentDotted: string | null, + sourceLength: number, +): number { + if (parentDotted === null) return sourceLength; + const parent = locateSection(pieces, parentDotted, 0); + if (parent === null) { + misuse( + `the target document spells no section ${JSON.stringify(parentDotted)} ` + + `(a refused move; the oracle predicts successful moves only)`, + ); + } + return parent.end - (parent.section.close ?? "").length; +} + +// --------------------------------------------------------------------------- +// Attributed compilation: piece tree → per-node own-content sequences +// +// Mirrors the structure of P-2's oracle but delegates every logical line's +// keep/drop decision to it: the line's pieces (content chunks, tag/import/ +// comment removals, embeddings with their expansions) plus its terminator +// are handed to `compileMarkdown`, whose empty output is exactly "dropped" +// (a kept line always retains its terminator and an all-whitespace source +// line is kept; the terminator-less final line borrows a sentinel +// terminator, which cannot change the decision). + +interface DocumentStructure { + /** Identity → own-content token sequence, this document's nodes. */ + readonly sequences: Map<string, SectionMoveOwnToken[]>; + /** Identity → declared `d` targets, this document's sections. */ + readonly depends: Map<string, readonly string[]>; +} + +type FlatEntry = + | { readonly kind: "content"; readonly owner: string; readonly text: string } + | { readonly kind: "construct"; readonly piece: MarkdownPiece } + | { + readonly kind: "token"; + readonly owner: string; + readonly token: SectionMoveOwnToken; + }; + +function flattenInto( + pieces: readonly SectionMovePiece[], + path: string, + owner: string, + entries: FlatEntry[], + register: (identity: string, depends: readonly string[]) => void, +): void { + for (const piece of pieces) { + switch (piece.kind) { + case "content": + if (piece.text.length > 0) { + entries.push({ kind: "content", owner, text: piece.text }); + } + break; + case "removal": + entries.push({ + kind: "construct", + piece: { kind: "removal", text: piece.text }, + }); + break; + case "embedding": + entries.push({ kind: "token", owner, token: ["embed", piece.target] }); + entries.push({ + kind: "construct", + piece: { + kind: "embedding", + text: piece.text, + expansion: piece.expansion, + }, + }); + break; + case "section": { + if (hasTerminator(piece.open) || hasTerminator(piece.close ?? "")) { + misuse( + `section tags are single-line in the staged scope (the P-5 ` + + `generator and SPEC 6.2's worked material stage no multi-line ` + + `tag); got ${JSON.stringify(piece.open)}`, + ); + } + const identity = `${path}#${piece.id}`; + register(identity, piece.depends); + entries.push({ kind: "token", owner, token: ["child", identity] }); + entries.push({ + kind: "construct", + piece: { kind: "removal", text: piece.open }, + }); + if (piece.close === null) { + if (piece.body.length > 0) { + misuse( + `a self-closing section has no body (SPEC 1.1); ` + + `${identity} declares ${String(piece.body.length)} piece(s)`, + ); + } + } else { + flattenInto(piece.body, path, identity, entries, register); + entries.push({ + kind: "construct", + piece: { kind: "removal", text: piece.close }, + }); + } + break; + } + } + } +} + +/** Merge strictly-adjacent content entries (always same-owner by grammar). */ +function coalesceEntries(entries: readonly FlatEntry[]): FlatEntry[] { + const out: FlatEntry[] = []; + for (const entry of entries) { + const last = out[out.length - 1]; + if ( + entry.kind === "content" && + last !== undefined && + last.kind === "content" + ) { + if (last.owner !== entry.owner) { + defect( + "adjacent content with distinct owners — a section boundary " + + "always interposes a tag", + ); + } + out[out.length - 1] = { + kind: "content", + owner: last.owner, + text: last.text + entry.text, + }; + continue; + } + out.push(entry); + } + return out; +} + +function compileDocument(document: SectionMoveDocument): DocumentStructure { + const sequences = new Map<string, SectionMoveOwnToken[]>(); + const depends = new Map<string, readonly string[]>(); + const runs = new Map<string, string>(); + const register = (identity: string, deps: readonly string[]): void => { + if (sequences.has(identity)) { + misuse(`duplicate section identity ${identity} in ${document.path}`); + } + sequences.set(identity, []); + depends.set(identity, deps); + runs.set(identity, ""); + }; + // The implicit root (SPEC 1.2): no `d` targets (5.5). + register(document.path, []); + + const entries: FlatEntry[] = []; + flattenInto(document.pieces, document.path, document.path, entries, register); + + const appendRun = (owner: string, text: string): void => { + runs.set(owner, (runs.get(owner) ?? "") + text); + }; + const flushToken = (owner: string, token: SectionMoveOwnToken): void => { + const sequence = sequences.get(owner); + if (sequence === undefined) defect(`no stream for ${owner}`); + sequence.push(["run", runs.get(owner) ?? ""], token); + runs.set(owner, ""); + }; + + type LineEvent = + | { readonly kind: "bytes"; readonly owner: string; readonly text: string } + | { + readonly kind: "token"; + readonly owner: string; + readonly token: SectionMoveOwnToken; + }; + let linePieces: MarkdownPiece[] = []; + let lineEvents: LineEvent[] = []; + + const finalizeLine = (terminator: string, owner: string | null): void => { + if ( + linePieces.length === 0 && + lineEvents.length === 0 && + terminator === "" + ) { + return; // nothing pending at end of input + } + const probe: MarkdownPiece[] = [ + ...linePieces, + { kind: "content", text: terminator === "" ? "\n" : terminator }, + ]; + const dropped = compileMarkdown(probe) === ""; + for (const event of lineEvents) { + if (event.kind === "token") flushToken(event.owner, event.token); + else if (!dropped) appendRun(event.owner, event.text); + } + if (!dropped && terminator !== "" && owner !== null) { + appendRun(owner, terminator); + } + linePieces = []; + lineEvents = []; + }; + + for (const entry of coalesceEntries(entries)) { + if (entry.kind === "construct") { + linePieces.push(entry.piece); + continue; + } + if (entry.kind === "token") { + lineEvents.push({ + kind: "token", + owner: entry.owner, + token: entry.token, + }); + continue; + } + const text = entry.text; + let start = 0; + let i = 0; + while (i < text.length) { + const code = text.charCodeAt(i); + if (!isTerminatorCode(code)) { + i += 1; + continue; + } + // A CR ending the entry is a lone CR: adjacent content was coalesced, + // so the next source character (if any) is a construct's first own + // character — never the LF of a CRLF pair (the markdown oracle's + // rule). + const terminator = + code === 0x0d && text.charCodeAt(i + 1) === 0x0a ? "\r\n" : text[i]; + const chunk = text.slice(start, i); + if (chunk.length > 0) { + linePieces.push({ kind: "content", text: chunk }); + lineEvents.push({ kind: "bytes", owner: entry.owner, text: chunk }); + } + finalizeLine(terminator, entry.owner); + i += terminator.length; + start = i; + } + const tail = text.slice(start); + if (tail.length > 0) { + linePieces.push({ kind: "content", text: tail }); + lineEvents.push({ kind: "bytes", owner: entry.owner, text: tail }); + } + } + finalizeLine("", null); + + for (const [identity, sequence] of sequences) { + sequence.push(["run", runs.get(identity) ?? ""]); + } + return { sequences, depends }; +} + +// --------------------------------------------------------------------------- +// Graph derivation and the 5.6 cascade computation + +interface GraphNode { + readonly children: readonly string[]; + readonly edgeTargets: readonly string[]; +} + +function dedupSorted(values: readonly string[]): string[] { + return [...new Set(values)].sort(); +} + +function tokensJson(tokens: readonly SectionMoveOwnToken[]): string { + return JSON.stringify(tokens); +} + +function mapTokens( + tokens: readonly SectionMoveOwnToken[], + mapIdentity: (identity: string) => string, +): SectionMoveOwnToken[] { + return tokens.map(([kind, value]) => + kind === "run" ? [kind, value] : [kind, mapIdentity(value)], + ); +} + +function graphNodeOf( + tokens: readonly SectionMoveOwnToken[], + deps: readonly string[], +): GraphNode { + const children: string[] = []; + const embeds: string[] = []; + for (const [kind, value] of tokens) { + if (kind === "child") children.push(value); + else if (kind === "embed") embeds.push(value); + } + return { children, edgeTargets: dedupSorted([...deps, ...embeds]) }; +} + +/** Memoized strict-descendant sets over one side's `children` lists. */ +function strictDescendants( + graph: ReadonlyMap<string, GraphNode>, +): Map<string, Set<string>> { + const memo = new Map<string, Set<string>>(); + const visiting = new Set<string>(); + const resolve = (identity: string): Set<string> => { + const cached = memo.get(identity); + if (cached !== undefined) return cached; + if (visiting.has(identity)) { + defect(`contains-cycle through ${identity}`); + } + visiting.add(identity); + const node = graph.get(identity); + if (node === undefined) { + misuse( + `${identity} is a child of some node but has no node of its own — ` + + `otherNodes must cover every node of every untouched file`, + ); + } + const descendants = new Set<string>(); + for (const child of node.children) { + descendants.add(child); + for (const inner of resolve(child)) descendants.add(inner); + } + visiting.delete(identity); + memo.set(identity, descendants); + return descendants; + }; + for (const identity of graph.keys()) resolve(identity); + return memo; +} + +// --------------------------------------------------------------------------- +// The oracle + +export function predictSectionMoveImpact( + input: SectionMoveInput, +): SectionMovePrediction { + const { origin, movedId, newId } = input; + let targetDocument: SectionMoveDocument | null; + let targetPath: string; + if ("pieces" in input.target) { + targetDocument = input.target; + targetPath = input.target.path; + } else { + targetDocument = null; + targetPath = input.target.createdPath; + } + const created = targetDocument === null; + const coincident = + targetDocument !== null && targetDocument.path === origin.path; + if (coincident && targetDocument !== origin) { + misuse( + "a same-file move passes the identical document object as origin and target", + ); + } + if (created && targetPath === origin.path) { + misuse("the created target path collides with the origin document"); + } + + // --- The identity mapping (prefix replacement, SPEC 6.5) --- + const located = locateSection(origin.pieces, movedId, 0); + if (located === null) { + misuse(`the origin document spells no section ${JSON.stringify(movedId)}`); + } + const mapDotted = (dotted: string): string => { + if (dotted === movedId) return newId; + if (dotted.startsWith(`${movedId}.`)) { + return newId + dotted.slice(movedId.length); + } + misuse( + `section ${JSON.stringify(dotted)} inside the moved subtree does not ` + + `extend the moved id ${JSON.stringify(movedId)} (SPEC 1.3)`, + ); + }; + const identityMap = new Map<string, string>(); + const collectMapping = (section: SectionMoveSection): void => { + identityMap.set( + `${origin.path}#${section.id}`, + `${targetPath}#${mapDotted(section.id)}`, + ); + for (const piece of section.body) { + if (piece.kind === "section") collectMapping(piece); + } + }; + collectMapping(located.section); + const mapIdentity = (identity: string): string => + identityMap.get(identity) ?? identity; + + // --- Parents --- + const parentDottedOf = (dotted: string): string | null => { + const lastDot = dotted.lastIndexOf("."); + return lastDot === -1 ? null : dotted.slice(0, lastDot); + }; + const originParentDotted = parentDottedOf(movedId); + const originParent = + originParentDotted === null + ? origin.path + : `${origin.path}#${originParentDotted}`; + const targetParentDotted = parentDottedOf(newId); + if (created && targetParentDotted !== null) { + misuse( + "a created target file holds no sections, so a move creating it " + + "carries a single-segment new id (SPEC 6.5: the target parent must " + + "exist)", + ); + } + // The created root is `changed` by addition, not comparison (P-5). + const targetParent = created + ? null + : targetParentDotted === null + ? targetPath + : `${targetPath}#${targetParentDotted}`; + + // --- Before-side compilation --- + const beforeDocs: DocumentStructure[] = [compileDocument(origin)]; + if (!coincident && targetDocument !== null) { + beforeDocs.push(compileDocument(targetDocument)); + } + + // --- After-side trees (6.5's edits at the piece level) --- + const movedMapped = mapMovedIds(located.section, mapDotted); + const removedOrigin = replaceWithRemoval(origin.pieces, movedId); + if (!removedOrigin.found) { + defect("located section not found by the removal pass"); + } + const afterDocs: DocumentStructure[] = []; + if (coincident) { + const source = sectionMoveSourceText(origin.pieces); + const insertAt = insertionPoint( + origin.pieces, + targetParentDotted, + source.length, + ); + const atLineStart = atLineStartAfterDeletion(source, insertAt, { + start: located.start, + end: located.end, + }); + const spliced = insertMoved( + removedOrigin.pieces, + targetParentDotted, + movedMapped, + atLineStart, + ); + if (!spliced.found) { + misuse( + `the target parent ${JSON.stringify(targetParentDotted)} is missing ` + + `after the removal — absent or within the moved subtree (a ` + + `refused move; the oracle predicts successful moves only)`, + ); + } + afterDocs.push( + compileDocument({ + path: origin.path, + pieces: mapReferencesDeep(spliced.pieces, mapIdentity), + }), + ); + } else { + afterDocs.push( + compileDocument({ + path: origin.path, + pieces: mapReferencesDeep(removedOrigin.pieces, mapIdentity), + }), + ); + if (targetDocument === null) { + afterDocs.push( + compileDocument({ + path: targetPath, + pieces: mapReferencesDeep( + [movedMapped, { kind: "content", text: "\n" }], + mapIdentity, + ), + }), + ); + } else { + const source = sectionMoveSourceText(targetDocument.pieces); + const insertAt = insertionPoint( + targetDocument.pieces, + targetParentDotted, + source.length, + ); + const atLineStart = atLineStartAfterDeletion(source, insertAt, null); + const spliced = insertMoved( + targetDocument.pieces, + targetParentDotted, + movedMapped, + atLineStart, + ); + if (!spliced.found) { + misuse( + `the target document spells no section ` + + `${JSON.stringify(targetParentDotted)} (a refused move; the ` + + `oracle predicts successful moves only)`, + ); + } + afterDocs.push( + compileDocument({ + path: targetPath, + pieces: mapReferencesDeep(spliced.pieces, mapIdentity), + }), + ); + } + } + + // --- Merge sides; bring the baseline into current identities --- + const beforeRaw = new Map<string, readonly SectionMoveOwnToken[]>(); + const mappedBefore = new Map<string, readonly SectionMoveOwnToken[]>(); + const mappedBeforeGraph = new Map<string, GraphNode>(); + for (const doc of beforeDocs) { + for (const [identity, tokens] of doc.sequences) { + if (beforeRaw.has(identity)) { + misuse(`identity ${identity} appears in two documents`); + } + beforeRaw.set(identity, tokens); + const mapped = mapIdentity(identity); + const mappedTokens = mapTokens(tokens, mapIdentity); + if (mappedBefore.has(mapped)) { + defect(`the identity map collapsed ${mapped}`); + } + mappedBefore.set(mapped, mappedTokens); + mappedBeforeGraph.set( + mapped, + graphNodeOf( + mappedTokens, + (doc.depends.get(identity) ?? []).map(mapIdentity), + ), + ); + } + } + const after = new Map<string, readonly SectionMoveOwnToken[]>(); + const afterGraph = new Map<string, GraphNode>(); + for (const doc of afterDocs) { + for (const [identity, tokens] of doc.sequences) { + if (after.has(identity)) { + misuse(`identity ${identity} appears in two after-side documents`); + } + after.set(identity, tokens); + afterGraph.set( + identity, + graphNodeOf(tokens, doc.depends.get(identity) ?? []), + ); + } + } + for (const node of input.otherNodes ?? []) { + if (mappedBefore.has(node.identity) || identityMap.has(node.identity)) { + misuse( + `otherNodes entry ${node.identity} belongs to a document of the move`, + ); + } + if (afterGraph.has(node.identity)) { + misuse(`duplicate otherNodes entry ${node.identity}`); + } + const graphNode: GraphNode = { + children: node.children.map(mapIdentity), + edgeTargets: dedupSorted(node.edgeTargets.map(mapIdentity)), + }; + mappedBeforeGraph.set(node.identity, graphNode); + afterGraph.set(node.identity, graphNode); + } + for (const [identity, node] of afterGraph) { + for (const target of node.edgeTargets) { + if (!afterGraph.has(target)) { + misuse( + `${identity} has a dependency-edge target ${target} that is no ` + + `node — otherNodes must cover every node of every untouched file`, + ); + } + } + } + + // --- Kept/added bookkeeping --- + for (const identity of mappedBefore.keys()) { + if (!after.has(identity)) { + defect( + `${identity} is missing on the after side — a section move deletes ` + + `no node`, + ); + } + } + const added = new Set<string>(); + for (const identity of after.keys()) { + if (!mappedBefore.has(identity)) added.add(identity); + } + const expectedAdded = created ? [targetPath] : []; + if (JSON.stringify([...added].sort()) !== JSON.stringify(expectedAdded)) { + defect( + `added identities ${JSON.stringify([...added].sort())}; expected ` + + `exactly ${JSON.stringify(expectedAdded)}`, + ); + } + + // --- The changed set (P-5's exactly-three-groups pin) --- + const candidates = new Set<string>(identityMap.values()); + candidates.add(originParent); + if (targetParent !== null) candidates.add(targetParent); + const changed = new Set<string>(); + for (const [identity, beforeTokens] of mappedBefore) { + const afterTokens = after.get(identity); + if (afterTokens === undefined) continue; // unreachable: guarded above + if (tokensJson(beforeTokens) === tokensJson(afterTokens)) continue; + if (!candidates.has(identity)) { + misuse( + `the own-content sequence of ${identity} differs across the move, ` + + `but P-5 draws the changed set from exactly the origin parent, ` + + `the target parent, and the moved subtree's nodes — the generator ` + + `must never stage another node's bytes on a line whose keep/drop ` + + `status the move flips (TEST-SPEC 16 P-5; SPEC 6.2, 3)`, + ); + } + changed.add(identity); + } + for (const identity of added) changed.add(identity); + + // Dependency-edge sets are identity-stable across a section move + // (canonical identities, SPEC 5.4): guard that the two derivations agree. + for (const [identity, beforeNode] of mappedBeforeGraph) { + const afterNode = afterGraph.get(identity); + if (afterNode === undefined) continue; // unreachable: guarded above + if ( + JSON.stringify(beforeNode.edgeTargets) !== + JSON.stringify(afterNode.edgeTargets) + ) { + misuse( + `the dependency-edge target set of ${identity} differs across the ` + + `move (${JSON.stringify([...beforeNode.edgeTargets])} vs ` + + `${JSON.stringify([...afterNode.edgeTargets])}) — a section move ` + + `retargets spellings, never edges (SPEC 5.4, 6.5)`, + ); + } + } + + // --- 5.6 cascades from the changed nodes --- + const keptSet = new Set(mappedBeforeGraph.keys()); + const descBefore = strictDescendants(mappedBeforeGraph); + const descAfter = strictDescendants(afterGraph); + const descAt = ( + memo: Map<string, Set<string>>, + identity: string, + ): Set<string> => memo.get(identity) ?? new Set<string>(); + const commonChildren = (identity: string): string[] => { + const beforeNode = mappedBeforeGraph.get(identity); + const afterNode = afterGraph.get(identity); + if (beforeNode === undefined || afterNode === undefined) return []; + return beforeNode.children.filter( + (child) => keptSet.has(child) && afterNode.children.includes(child), + ); + }; + const edgeTargetsOf = (identity: string): readonly string[] => + (afterGraph.get(identity)?.edgeTargets ?? []).filter((target) => + keptSet.has(target), + ); + + // effCauses(n): the changed originators whose edits the SPEC 5.5 + // effectiveHash recursion propagates to n — n itself when changed, plus + // the causes of its both-sides children and of its dependency-edge + // targets (edge sets are identity-stable, guarded above). + const effCausesMemo = new Map<string, ReadonlySet<string>>(); + const effVisiting = new Set<string>(); + const effCauses = (identity: string): ReadonlySet<string> => { + const cached = effCausesMemo.get(identity); + if (cached !== undefined) return cached; + if (effVisiting.has(identity)) { + defect( + `dependency/contains cycle through ${identity} — staged graphs are ` + + `acyclic (SPEC 5.3)`, + ); + } + effVisiting.add(identity); + const causes = new Set<string>(); + if (changed.has(identity)) causes.add(identity); + for (const child of commonChildren(identity)) { + for (const cause of effCauses(child)) causes.add(cause); + } + for (const target of edgeTargetsOf(identity)) { + for (const cause of effCauses(target)) causes.add(cause); + } + effVisiting.delete(identity); + effCausesMemo.set(identity, causes); + return causes; + }; + + // directCauses(n): originators reaching n through a dependency edge of + // n's own — the 5.6 upstream-changed trigger at one node. + const directCauses = (identity: string): ReadonlySet<string> => { + const causes = new Set<string>(); + for (const target of edgeTargetsOf(identity)) { + for (const cause of effCauses(target)) causes.add(cause); + } + return causes; + }; + + const changedSorted = [...changed].sort(); + const changedEntry: SectionMoveCategoryPrediction = { + required: true, + attributionWithin: changedSorted, + attributionMustInclude: [], + }; + const nodes = new Map<string, SectionMoveNodePrediction>(); + for (const identity of [...afterGraph.keys()].sort()) { + const categories = new Map< + SectionMoveCategoryName, + SectionMoveCategoryPrediction + >(); + if (added.has(identity)) { + // An added node is `changed` and receives no category through its own + // hashes (SPEC 5.6; P-5: by addition, not comparison). + categories.set("changed", changedEntry); + nodes.set(identity, { categories }); + continue; + } + if (changed.has(identity)) categories.set("changed", changedEntry); + + const beforeDesc = descAt(descBefore, identity); + const afterDesc = descAt(descAfter, identity); + const bothDesc = [...beforeDesc].filter((d) => afterDesc.has(d)); + const oneSidedDesc = [...new Set([...beforeDesc, ...afterDesc])].filter( + (d) => keptSet.has(d) && !(beforeDesc.has(d) && afterDesc.has(d)), + ); + + // descendant-changed (SPEC 5.6): a changed descendant present on both + // sides makes it required; a changed relocated (one-side-only) + // descendant alone makes it tolerated-optional (T6.2-3's documented + // two-sided ambiguity), the attribution bounded by those descendants. + const changedBoth = bothDesc.filter((d) => changed.has(d)).sort(); + const changedOneSided = oneSidedDesc.filter((d) => changed.has(d)).sort(); + if (changedBoth.length > 0 || changedOneSided.length > 0) { + categories.set("descendant-changed", { + required: changedBoth.length > 0, + attributionWithin: dedupSorted([...changedBoth, ...changedOneSided]), + attributionMustInclude: changedBoth, + }); + } + + // upstream-changed (SPEC 5.6): a dependency-edge cause at the node + // itself or at a both-sides subtree member is required; a cause carried + // only by a relocated one-side-only member is tolerated-optional. + const requiredCauses = new Set<string>(directCauses(identity)); + for (const member of bothDesc) { + if (!keptSet.has(member)) continue; + for (const cause of directCauses(member)) requiredCauses.add(cause); + } + const optionalCauses = new Set<string>(); + for (const member of oneSidedDesc) { + for (const cause of directCauses(member)) { + if (!requiredCauses.has(cause)) optionalCauses.add(cause); + } + } + if (requiredCauses.size > 0 || optionalCauses.size > 0) { + categories.set("upstream-changed", { + required: requiredCauses.size > 0, + attributionWithin: dedupSorted([...requiredCauses, ...optionalCauses]), + attributionMustInclude: [...requiredCauses].sort(), + }); + } + nodes.set(identity, { categories }); + } + + return { + identityMap, + nodes, + changed, + added, + beforeOwnTokens: beforeRaw, + afterOwnTokens: after, + }; +} diff --git a/test/self/s6-section-move-oracle.test.ts b/test/self/s6-section-move-oracle.test.ts new file mode 100644 index 0000000..fd2a648 --- /dev/null +++ b/test/self/s6-section-move-oracle.test.ts @@ -0,0 +1,811 @@ +// S-6 section-move-oracle vectors (TEST-SPEC 17 S-6): the in-harness +// section-move category oracle for P-5 (test/helpers/oracles/section-move.ts) +// passes this fixed vector suite, derived from SPEC.md 6.2's worked +// straddling-line case plus the clean-boundary and final-position cases of +// TEST-SPEC T6.2-3/T6.2-4, before any property test trusts it. Every +// vector's expected sequences and category tables are hand-computed; no +// product is involved (the product's own 6.2/5.6 behavior is asserted by +// the suite's T6.2-* tests against fixtures, not against this oracle). +// +// Coverage, by the rules the vectors derive from: +// * T6.2-3's clean-boundary case: tags alone on their lines — every moved +// node's own-content sequence is preserved, the origin and target +// parents are each `changed`, the file roots' `descendant-changed` and +// the dependents' `upstream-changed` cascade with exact attributions +// (SPEC 6.2, 5.6); +// * SPEC 6.2's worked straddling-line case (T6.2-3's impure arm): the +// moved section's opening tag preceded on its origin line by +// non-whitespace and followed there only by whitespace — the +// within-construct remainder and terminator contribute at the origin +// (line kept) and not at the destination (line dropped, SPEC 3), the +// moved node itself `changed`, with the two-sided descendant-changed +// tolerance on the parents and roots exactly as T6.2-3 documents; +// * T6.2-4's final-position case: a parent's last child moved onto itself +// reproduces the parent's sequence — no node changes, no categories; +// its contrast: a non-final child re-inserted at the end changes the +// coincident parent; +// * P-5's created-target-file rule: the created root is `changed` as an +// added node and carries no other category — even over a changed moved +// descendant; +// * 6.5's insertion terminators (the preceding U+000A landing in the +// target parent's run when the insertion point is mid-line), the +// self-closing moved section, and the self-closing target parent +// rewrite (T6.5-2's byte rule); +// * the drop-rule delegation to P-2's oracle, expansion semantics +// included (a non-empty expansion keeps the origin straddling line); +// plus misuse guards: stagings outside P-5's exactly-three-groups changed +// set, degenerate constructs, and incomplete graphs throw plain errors +// (harness defects), never diagnosed product failures. + +import { expect, test } from "vitest"; +import { predictSectionMoveImpact } from "../helpers/oracles/section-move.js"; +import type { + SectionMoveDocument, + SectionMoveGraphNode, + SectionMovePiece, + SectionMovePrediction, +} from "../helpers/oracles/section-move.js"; + +// --- vector-side document builders ------------------------------------------- + +const content = (text: string): SectionMovePiece => ({ kind: "content", text }); + +/** A paired-form section with its open-tag props spelled by the vector. */ +function sec( + id: string, + props: string, + body: readonly SectionMovePiece[], + depends: readonly string[] = [], +): SectionMovePiece { + return { + kind: "section", + id, + open: `<S id="${id}"${props}>`, + close: "</S>", + body, + depends, + }; +} + +/** A self-closing section (SPEC 1.1): the whole tag, empty body. */ +function selfClosing(id: string, props: string): SectionMovePiece { + return { + kind: "section", + id, + open: `<S id="${id}"${props} />`, + close: null, + body: [], + depends: [], + }; +} + +function doc( + path: string, + pieces: readonly SectionMovePiece[], +): SectionMoveDocument { + return { path, pieces }; +} + +function node( + identity: string, + children: readonly string[] = [], + edgeTargets: readonly string[] = [], +): SectionMoveGraphNode { + return { identity, children, edgeTargets }; +} + +// --- expectation helpers ----------------------------------------------------- + +interface CategoryRow { + readonly required: boolean; + readonly within: readonly string[]; + readonly mustInclude: readonly string[]; +} + +/** The full prediction table as plain JSON (sorted members). */ +function tableOf( + prediction: SectionMovePrediction, +): Record<string, Record<string, CategoryRow>> { + const table: Record<string, Record<string, CategoryRow>> = {}; + for (const [identity, nodePrediction] of prediction.nodes) { + const categories: Record<string, CategoryRow> = {}; + for (const [name, category] of nodePrediction.categories) { + categories[name] = { + required: category.required, + within: [...category.attributionWithin], + mustInclude: [...category.attributionMustInclude], + }; + } + table[identity] = categories; + } + return table; +} + +/** Required with exact attribution: within = mustInclude = `ids`. */ +const req = (...ids: string[]): CategoryRow => ({ + required: true, + within: [...ids].sort(), + mustInclude: [...ids].sort(), +}); + +/** Required, attribution within `within`, must include `mustInclude`. */ +const reqWithin = ( + within: readonly string[], + mustInclude: readonly string[], +): CategoryRow => ({ + required: true, + within: [...within].sort(), + mustInclude: [...mustInclude].sort(), +}); + +/** Tolerated-optional with attribution bound `ids` (the T6.2-3 tolerance). */ +const opt = (...ids: string[]): CategoryRow => ({ + required: false, + within: [...ids].sort(), + mustInclude: [], +}); + +/** The `changed` row: attribution within the whole originating set. */ +const chg = (allChanged: readonly string[]): CategoryRow => ({ + required: true, + within: [...allChanged].sort(), + mustInclude: [], +}); + +function sortedSet(values: ReadonlySet<string>): string[] { + return [...values].sort(); +} + +// ============================================================================= +// T6.2-3 clean boundary (the C3 fixture shapes of the suite's section-6.2) +// ============================================================================= + +const ORIGIN = "specs/Origin.mdx"; +const OP = "specs/Origin.mdx#origin"; +const TARGET = "specs/Target.mdx"; +const TP = "specs/Target.mdx#tgt"; +const MV_POST = "specs/Target.mdx#tgt.mv"; +const KID_POST = "specs/Target.mdx#tgt.mv.kid"; +const WATCH = "specs/Watch.mdx"; +const W_TOP = "specs/Watch.mdx#watch"; +const W_ONORIGIN = "specs/Watch.mdx#watch.onorigin"; +const W_ONTARGET = "specs/Watch.mdx#watch.ontarget"; + +function cleanOrigin(): SectionMoveDocument { + // <S id="origin">\nOrigin holder text.\n\n<S id="origin.mv" …>\nMoved root + // text.\n\n<S id="origin.mv.kid">\nMoved kid text.\n</S>\n</S>\n</S>\n + return doc(ORIGIN, [ + sec("origin", "", [ + content("\nOrigin holder text.\n\n"), + sec("origin.mv", ' coverage="none" tags="keep mv"', [ + content("\nMoved root text.\n\n"), + sec("origin.mv.kid", "", [content("\nMoved kid text.\n")]), + content("\n"), + ]), + content("\n"), + ]), + content("\n"), + ]); +} + +function cleanTarget(): SectionMoveDocument { + return doc(TARGET, [ + sec("tgt", "", [content("\nTarget parent text.\n")]), + content("\n"), + ]); +} + +const WATCH_NODES: readonly SectionMoveGraphNode[] = [ + node(WATCH, [W_TOP]), + node(W_TOP, [W_ONORIGIN, W_ONTARGET]), + node(W_ONORIGIN, [], [OP]), + node(W_ONTARGET, [], [TP]), +]; + +test("S-6 (T6.2-3 clean boundary): parents changed, moved subtree preserved, cascades attributed per parent", () => { + const prediction = predictSectionMoveImpact({ + origin: cleanOrigin(), + target: cleanTarget(), + movedId: "origin.mv", + newId: "tgt.mv", + otherNodes: WATCH_NODES, + }); + + expect(Object.fromEntries(prediction.identityMap)).toEqual({ + "specs/Origin.mdx#origin.mv": MV_POST, + "specs/Origin.mdx#origin.mv.kid": KID_POST, + }); + expect(sortedSet(prediction.changed)).toEqual([OP, TP]); + expect(sortedSet(prediction.added)).toEqual([]); + + // Every moved node keeps its own-content sequence (clean boundary): the + // straddling tag-only lines are dropped at origin and destination alike. + expect(prediction.beforeOwnTokens.get("specs/Origin.mdx#origin.mv")).toEqual([ + ["run", "Moved root text.\n\n"], + ["child", "specs/Origin.mdx#origin.mv.kid"], + ["run", ""], + ]); + expect(prediction.afterOwnTokens.get(MV_POST)).toEqual([ + ["run", "Moved root text.\n\n"], + ["child", KID_POST], + ["run", ""], + ]); + expect( + prediction.beforeOwnTokens.get("specs/Origin.mdx#origin.mv.kid"), + ).toEqual([["run", "Moved kid text.\n"]]); + expect(prediction.afterOwnTokens.get(KID_POST)).toEqual([ + ["run", "Moved kid text.\n"], + ]); + + const changed = [OP, TP]; + expect(tableOf(prediction)).toEqual({ + [ORIGIN]: { "descendant-changed": req(OP) }, + [OP]: { changed: chg(changed) }, + [TARGET]: { "descendant-changed": req(TP) }, + [TP]: { changed: chg(changed) }, + [MV_POST]: {}, + [KID_POST]: {}, + [WATCH]: { "upstream-changed": req(OP, TP) }, + [W_TOP]: { "upstream-changed": req(OP, TP) }, + [W_ONORIGIN]: { "upstream-changed": req(OP) }, + [W_ONTARGET]: { "upstream-changed": req(TP) }, + }); +}); + +// ============================================================================= +// SPEC 6.2's worked straddling-line case (T6.2-3's impure arm; the I3 shapes) +// ============================================================================= + +const ROOM = "specs/Room.mdx"; +const I_OP = "specs/Room.mdx#op"; +const I_IMP_PRE = "specs/Room.mdx#op.imp"; +const HALL = "specs/Hall.mdx"; +const I_TP = "specs/Hall.mdx#tp"; +const I_IMP_POST = "specs/Hall.mdx#tp.imp"; +const DEPS = "specs/Deps.mdx"; +const D_TOP = "specs/Deps.mdx#watch"; +const D_ONIMP = "specs/Deps.mdx#watch.onimp"; + +function impureRoom(): SectionMoveDocument { + // <S id="op">\nOp holder text.\n\nLead-in prose.<S id="op.imp" …> \n + // Impure line one.\nImpure line two.\n</S>\n</S>\n — the moved section's + // opening tag preceded on its line by non-whitespace and followed there + // only by whitespace (SPEC 6.2's worked case). + return doc(ROOM, [ + sec("op", "", [ + content("\nOp holder text.\n\nLead-in prose."), + sec("op.imp", ' coverage="none" tags="edge imp"', [ + content(" \nImpure line one.\nImpure line two.\n"), + ]), + content("\n"), + ]), + content("\n"), + ]); +} + +function impureHall(): SectionMoveDocument { + return doc(HALL, [ + sec("tp", "", [content("\nHall parent text.\n")]), + content("\n"), + ]); +} + +const DEPS_NODES: readonly SectionMoveGraphNode[] = [ + node(DEPS, [D_TOP]), + node(D_TOP, [D_ONIMP]), + node(D_ONIMP, [], [I_IMP_PRE]), +]; + +test("S-6 (SPEC 6.2 worked case): the impure-boundary moved node contributes the remainder and terminator at the origin, not at the destination, and is itself changed", () => { + const prediction = predictSectionMoveImpact({ + origin: impureRoom(), + target: impureHall(), + movedId: "op.imp", + newId: "tp.imp", + otherNodes: DEPS_NODES, + }); + + // The straddling-line drop of 6.2, computed by the rules of 3: at the + // origin the opening tag's line is kept (preceded by `Lead-in prose.`), + // so the within-construct remainder ` ` and its terminator contribute; + // at the destination the tag-only line is dropped. + expect(prediction.beforeOwnTokens.get(I_IMP_PRE)).toEqual([ + ["run", " \nImpure line one.\nImpure line two.\n"], + ]); + expect(prediction.afterOwnTokens.get(I_IMP_POST)).toEqual([ + ["run", "Impure line one.\nImpure line two.\n"], + ]); + // The origin parent keeps the lead-in prose and the merged line's + // terminator after the deletion. + expect(prediction.afterOwnTokens.get(I_OP)).toEqual([ + ["run", "Op holder text.\n\nLead-in prose.\n"], + ]); + + expect(sortedSet(prediction.changed)).toEqual([I_TP, I_IMP_POST, I_OP]); + expect(sortedSet(prediction.added)).toEqual([]); + + const changed = [I_OP, I_TP, I_IMP_POST]; + expect(tableOf(prediction)).toEqual({ + [ROOM]: { + "descendant-changed": reqWithin([I_OP, I_IMP_POST], [I_OP]), + }, + [I_OP]: { + changed: chg(changed), + "descendant-changed": opt(I_IMP_POST), + }, + [HALL]: { + "descendant-changed": reqWithin([I_TP, I_IMP_POST], [I_TP]), + }, + [I_TP]: { + changed: chg(changed), + "descendant-changed": opt(I_IMP_POST), + }, + [I_IMP_POST]: { changed: chg(changed) }, + [DEPS]: { "upstream-changed": req(I_IMP_POST) }, + [D_TOP]: { "upstream-changed": req(I_IMP_POST) }, + [D_ONIMP]: { "upstream-changed": req(I_IMP_POST) }, + }); +}); + +// ============================================================================= +// T6.2-4 final position (the P4 shapes) and its non-final contrast +// ============================================================================= + +const P_FILE = "specs/P.mdx"; +const P_TOP = "specs/P.mdx#p"; +const P_FIRST = "specs/P.mdx#p.first"; +const P_LAST = "specs/P.mdx#p.last"; +const P_FINAL = "specs/P.mdx#p.final"; +const P_WATCH = "specs/Watch.mdx"; +const P_W_TOP = "specs/Watch.mdx#watch"; + +function pDoc(): SectionMoveDocument { + // <S id="p">\nParent text.\n\n<S id="p.first">\nFirst child text.\n</S>\n + // \n<S id="p.last" …>\nTail child text.\n</S>\n</S>\n + return doc(P_FILE, [ + sec("p", "", [ + content("\nParent text.\n\n"), + sec("p.first", "", [content("\nFirst child text.\n")]), + content("\n\n"), + sec("p.last", ' coverage="none" tags="tail"', [ + content("\nTail child text.\n"), + ]), + content("\n"), + ]), + content("\n"), + ]); +} + +const P_WATCH_NODES: readonly SectionMoveGraphNode[] = [ + node(P_WATCH, [P_W_TOP]), + // `d={P.p.last}` plus `{text(P.p.last)}`: two edge kinds, one target. + node(P_W_TOP, [], [P_LAST, P_LAST]), +]; + +test("S-6 (T6.2-4): a parent's last child moved onto itself reproduces the parent's sequence — no node changed, no categories", () => { + const document = pDoc(); + const prediction = predictSectionMoveImpact({ + origin: document, + target: document, + movedId: "p.last", + newId: "p.final", + otherNodes: P_WATCH_NODES, + }); + + expect(Object.fromEntries(prediction.identityMap)).toEqual({ + [P_LAST]: P_FINAL, + }); + expect(sortedSet(prediction.changed)).toEqual([]); + expect(sortedSet(prediction.added)).toEqual([]); + // The coincident parent's re-insertion reproduces its sequence exactly + // (SPEC 6.2: a final construct re-inserted at its own former position). + expect(prediction.afterOwnTokens.get(P_TOP)).toEqual([ + ["run", "Parent text.\n\n"], + ["child", P_FIRST], + ["run", "\n"], + ["child", P_FINAL], + ["run", ""], + ]); + expect(tableOf(prediction)).toEqual({ + [P_FILE]: {}, + [P_TOP]: {}, + [P_FIRST]: {}, + [P_FINAL]: {}, + [P_WATCH]: {}, + [P_W_TOP]: {}, + }); +}); + +test("S-6 (T6.2-4 contrast): a non-final child re-inserted at the end fails to reproduce the coincident parent's sequence — the parent alone is changed", () => { + const document = pDoc(); + const prediction = predictSectionMoveImpact({ + origin: document, + target: document, + movedId: "p.first", + newId: "p.zeta", + }); + + expect(sortedSet(prediction.changed)).toEqual([P_TOP]); + // Children reordered and the dropped/kept line pattern shifted: the + // parent's own-content sequence differs. + expect(prediction.afterOwnTokens.get(P_TOP)).toEqual([ + ["run", "Parent text.\n\n\n"], + ["child", P_LAST], + ["run", ""], + ["child", "specs/P.mdx#p.zeta"], + ["run", ""], + ]); + expect(tableOf(prediction)).toEqual({ + [P_FILE]: { "descendant-changed": req(P_TOP) }, + [P_TOP]: { changed: chg([P_TOP]) }, + [P_LAST]: {}, + ["specs/P.mdx#p.zeta"]: {}, + }); +}); + +// ============================================================================= +// Created target file: the root is changed as an added node (P-5) +// ============================================================================= + +const NEW_FILE = "specs/New.mdx"; +const NEW_IMP = "specs/New.mdx#imp2"; + +test("S-6 (P-5 created target): the created root is changed by addition and carries no other category — even over a changed moved descendant", () => { + const prediction = predictSectionMoveImpact({ + origin: impureRoom(), + target: { createdPath: NEW_FILE }, + movedId: "op.imp", + newId: "imp2", + otherNodes: DEPS_NODES, + }); + + expect(sortedSet(prediction.added)).toEqual([NEW_FILE]); + expect(sortedSet(prediction.changed)).toEqual([NEW_FILE, NEW_IMP, I_OP]); + // The created file's context is a line start with a trailing terminator + // (6.5), so the impure boundary still drops the tag-only line there. + expect(prediction.afterOwnTokens.get(NEW_IMP)).toEqual([ + ["run", "Impure line one.\nImpure line two.\n"], + ]); + + const changed = [I_OP, NEW_FILE, NEW_IMP]; + expect(tableOf(prediction)).toEqual({ + [ROOM]: { "descendant-changed": reqWithin([I_OP, NEW_IMP], [I_OP]) }, + [I_OP]: { + changed: chg(changed), + "descendant-changed": opt(NEW_IMP), + }, + // Added: `changed` only — never descendant-changed, whatever changed + // children it holds (SPEC 5.6; P-5: by addition, not comparison). + [NEW_FILE]: { changed: chg(changed) }, + [NEW_IMP]: { changed: chg(changed) }, + [DEPS]: { "upstream-changed": req(NEW_IMP) }, + [D_TOP]: { "upstream-changed": req(NEW_IMP) }, + [D_ONIMP]: { "upstream-changed": req(NEW_IMP) }, + }); +}); + +// ============================================================================= +// Self-closing arms (SPEC 1.1; T6.5-2's target-parent rewrite) +// ============================================================================= + +test("S-6 (6.5 self-closing moved section): the tag's own characters move; its empty sequence is preserved", () => { + const origin = doc("specs/O.mdx", [ + sec("op", "", [ + content("\nOp text.\n"), + selfClosing("op.solo", ""), + content("\n"), + ]), + content("\n"), + ]); + const target = doc("specs/H.mdx", [ + sec("tp", "", [content("\nHall parent text.\n")]), + content("\n"), + ]); + const prediction = predictSectionMoveImpact({ + origin, + target, + movedId: "op.solo", + newId: "tp.solo", + }); + expect(sortedSet(prediction.changed)).toEqual([ + "specs/H.mdx#tp", + "specs/O.mdx#op", + ]); + expect(prediction.afterOwnTokens.get("specs/H.mdx#tp.solo")).toEqual([ + ["run", ""], + ]); + const changed = ["specs/O.mdx#op", "specs/H.mdx#tp"]; + expect(tableOf(prediction)).toEqual({ + "specs/O.mdx": { "descendant-changed": req("specs/O.mdx#op") }, + "specs/O.mdx#op": { changed: chg(changed) }, + "specs/H.mdx": { "descendant-changed": req("specs/H.mdx#tp") }, + "specs/H.mdx#tp": { changed: chg(changed) }, + "specs/H.mdx#tp.solo": {}, + }); +}); + +test("S-6 (T6.5-2): a self-closing target parent is rewritten to paired form and gains the moved child, the moved subtree preserved", () => { + const origin = doc("specs/O.mdx", [ + sec("m", "", [content("\nMoved body.\n")]), + content("\n"), + ]); + const target = doc("specs/H.mdx", [selfClosing("tp", ""), content("\n")]); + const prediction = predictSectionMoveImpact({ + origin, + target, + movedId: "m", + newId: "tp.m", + }); + // The rewrite (`<S id="tp">` + U+000A + moved + U+000A + `</S>`) keeps + // the moved node's clean boundary: sequence preserved. + expect(prediction.beforeOwnTokens.get("specs/O.mdx#m")).toEqual([ + ["run", "Moved body.\n"], + ]); + expect(prediction.afterOwnTokens.get("specs/H.mdx#tp.m")).toEqual([ + ["run", "Moved body.\n"], + ]); + expect(prediction.afterOwnTokens.get("specs/H.mdx#tp")).toEqual([ + ["run", ""], + ["child", "specs/H.mdx#tp.m"], + ["run", ""], + ]); + const changed = ["specs/O.mdx", "specs/H.mdx#tp"]; + expect(tableOf(prediction)).toEqual({ + "specs/O.mdx": { changed: chg(changed) }, + "specs/H.mdx": { "descendant-changed": req("specs/H.mdx#tp") }, + "specs/H.mdx#tp": { changed: chg(changed) }, + "specs/H.mdx#tp.m": {}, + }); +}); + +// ============================================================================= +// Insertion terminators (SPEC 6.5): the mid-line insertion point +// ============================================================================= + +test("S-6 (6.5 insertion): a top-level move into a file whose last line has no terminator inserts the preceding U+000A into the target root's run", () => { + const origin = doc("specs/O.mdx", [ + sec("m", "", [content("\nM body.\n")]), + content("\n"), + ]); + // `<S id="tp">x</S>` with no trailing terminator: the insertion point + // (end of file) is not at a line start. + const target = doc("specs/T.mdx", [sec("tp", "", [content("x")])]); + const prediction = predictSectionMoveImpact({ + origin, + target, + movedId: "m", + newId: "z", + }); + expect(prediction.afterOwnTokens.get("specs/T.mdx")).toEqual([ + ["run", ""], + ["child", "specs/T.mdx#tp"], + ["run", "\n"], // the inserted preceding terminator (SPEC 6.5) + ["child", "specs/T.mdx#z"], + ["run", ""], + ]); + const changed = ["specs/O.mdx", "specs/T.mdx"]; + expect(sortedSet(prediction.changed)).toEqual([...changed].sort()); + expect(tableOf(prediction)).toEqual({ + "specs/O.mdx": { changed: chg(changed) }, + "specs/T.mdx": { changed: chg(changed) }, + "specs/T.mdx#tp": {}, + "specs/T.mdx#z": {}, + }); +}); + +// ============================================================================= +// Drop-rule delegation to P-2's oracle: expansion semantics +// ============================================================================= + +test("S-6 (3, delegated): a non-empty expansion keeps the origin straddling line — the moved node's leading terminator contributes there and not at the destination", () => { + const origin = doc("specs/E.mdx", [ + sec("op", "", [ + content("\nOp text.\n\n"), + { + kind: "embedding", + text: "{text(X)}", + expansion: "EXP", + target: "specs/X.mdx#x", + }, + sec("op.mv", "", [content("\nBody.\n")]), + content("\n"), + ]), + content("\n"), + ]); + const target = doc("specs/H2.mdx", [ + sec("tp", "", [content("\nHall text.\n")]), + content("\n"), + ]); + const prediction = predictSectionMoveImpact({ + origin, + target, + movedId: "op.mv", + newId: "tp.mv", + otherNodes: [node("specs/X.mdx", ["specs/X.mdx#x"]), node("specs/X.mdx#x")], + }); + + // Origin: the line `{text(X)}<S id="op.mv">` + terminator is kept — the + // non-empty expansion keeps it (3) — so the moved node's leading + // terminator contributes at the origin; the destination drops the + // tag-only line. + expect(prediction.beforeOwnTokens.get("specs/E.mdx#op.mv")).toEqual([ + ["run", "\nBody.\n"], + ]); + expect(prediction.afterOwnTokens.get("specs/H2.mdx#tp.mv")).toEqual([ + ["run", "Body.\n"], + ]); + // The origin parent keeps the embedding token and gains the merged + // line's terminator (the line stays kept after the deletion). + expect(prediction.afterOwnTokens.get("specs/E.mdx#op")).toEqual([ + ["run", "Op text.\n\n"], + ["embed", "specs/X.mdx#x"], + ["run", "\n"], + ]); + + const MV2 = "specs/H2.mdx#tp.mv"; + const changed = ["specs/E.mdx#op", "specs/H2.mdx#tp", MV2]; + expect(sortedSet(prediction.changed)).toEqual([...changed].sort()); + expect(tableOf(prediction)).toEqual({ + "specs/E.mdx": { + "descendant-changed": reqWithin( + ["specs/E.mdx#op", MV2], + ["specs/E.mdx#op"], + ), + }, + "specs/E.mdx#op": { + changed: chg(changed), + "descendant-changed": opt(MV2), + }, + "specs/H2.mdx": { + "descendant-changed": reqWithin( + ["specs/H2.mdx#tp", MV2], + ["specs/H2.mdx#tp"], + ), + }, + "specs/H2.mdx#tp": { + changed: chg(changed), + "descendant-changed": opt(MV2), + }, + [MV2]: { changed: chg(changed) }, + "specs/X.mdx": {}, + "specs/X.mdx#x": {}, + }); +}); + +// ============================================================================= +// Misuse guards +// ============================================================================= + +test("S-6: a staging whose move changes a node outside P-5's three groups throws — the sibling whose whitespace residue rides a flipped line", () => { + // Before: the line `<S id="p.x"> </S><S id="p.mv">` is dropped (residue + // ` ` is whitespace-only), so the sibling `p.x` contributes nothing. + // After the deletion the merged line keeps `tail`, so ` ` survives — the + // sibling's sequence changes, outside the changed-set pin of P-5. + const origin = doc("specs/G.mdx", [ + sec("p", "", [ + content("\n"), + sec("p.x", "", [content(" ")]), + sec("p.mv", "", [content("\nM.\n")]), + content("tail\n"), + ]), + content("\n"), + ]); + const target = doc("specs/H3.mdx", [ + sec("tp", "", [content("\nT.\n")]), + content("\n"), + ]); + expect(() => + predictSectionMoveImpact({ + origin, + target, + movedId: "p.mv", + newId: "tp.mv", + }), + ).toThrow(/oracle misuse:.*changed set from exactly/s); +}); + +test("S-6: a moved id the origin does not spell throws", () => { + expect(() => + predictSectionMoveImpact({ + origin: cleanOrigin(), + target: cleanTarget(), + movedId: "origin.absent", + newId: "tgt.z", + }), + ).toThrow(/oracle misuse:.*spells no section/); +}); + +test("S-6: a missing target parent throws — the oracle predicts successful moves only", () => { + expect(() => + predictSectionMoveImpact({ + origin: cleanOrigin(), + target: cleanTarget(), + movedId: "origin.mv", + newId: "zz.mv", + }), + ).toThrow(/oracle misuse:.*spells no section/); +}); + +test("S-6: a created target file with a multi-segment new id throws", () => { + expect(() => + predictSectionMoveImpact({ + origin: cleanOrigin(), + target: { createdPath: "specs/New.mdx" }, + movedId: "origin.mv", + newId: "a.b", + }), + ).toThrow(/oracle misuse:.*single-segment/); +}); + +test("S-6: a multi-line section tag throws (staged scope)", () => { + const origin = doc("specs/O.mdx", [ + { + kind: "section", + id: "m", + open: '<S\n id="m">', + close: "</S>", + body: [content("\nx\n")], + depends: [], + }, + content("\n"), + ]); + expect(() => + predictSectionMoveImpact({ + origin, + target: { createdPath: "specs/N.mdx" }, + movedId: "m", + newId: "m2", + }), + ).toThrow(/oracle misuse:.*single-line/); +}); + +test("S-6: a self-closing section declaring a body throws", () => { + const origin = doc("specs/O.mdx", [ + { + kind: "section", + id: "m", + open: '<S id="m" />', + close: null, + body: [content("x")], + depends: [], + }, + content("\n"), + ]); + expect(() => + predictSectionMoveImpact({ + origin, + target: { createdPath: "specs/N.mdx" }, + movedId: "m", + newId: "m2", + }), + ).toThrow(/oracle misuse:.*no body/); +}); + +test("S-6: an otherNodes edge target that is no node throws — the cascade graph must be complete", () => { + expect(() => + predictSectionMoveImpact({ + origin: cleanOrigin(), + target: cleanTarget(), + movedId: "origin.mv", + newId: "tgt.mv", + otherNodes: [node("specs/W.mdx", [], ["specs/Gone.mdx#nope"])], + }), + ).toThrow(/oracle misuse:.*no node/); +}); + +test("S-6: duplicate section identities in one document throw", () => { + const origin = doc("specs/O.mdx", [ + sec("m", "", [content("\nx\n")]), + content("\n"), + sec("m", "", [content("\ny\n")]), + content("\n"), + ]); + expect(() => + predictSectionMoveImpact({ + origin, + target: { createdPath: "specs/N.mdx" }, + movedId: "m", + newId: "m2", + }), + ).toThrow(/oracle misuse:.*duplicate section identity/); +}); From 1bd23d5f3a8490ceb54ce29f67dfdd5a95ea7258 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 15:32:12 +0000 Subject: [PATCH 129/226] sdg(phase-9): generalize P-5 to random section moves on the FP-083 oracle (FP-084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P-5 arm 2 now stages random section moves generally: randomized boundary layouts (flow / balanced inline / single-line collapse / self-closing, each byte form vetted against remark-mdx), created target files, purity and contrast coincident moves, self-closing target parents, and mid-line root insertions — asserted against predictSectionMoveImpact's full per-node prediction (straddling-line drops via P-2's oracle, created-root-as-added, metadata-changed on no node, attribution bounds). The trial builds oracle piece trees byte-locked to renderWorkspace instead of mutating the model; section-16-p4.ts exports its rendering primitives for that. Verified by productless dry-runs over the fixed seeds (24/24 in-space, all staged files parse, every class reached) plus 1,440 random-seed trials, and two reverted end-to-end mutation probes; suite section-16-p5-p6 genuinely passes against the built product; test:self unchanged (2 planned mid-loop reds, 289 passed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 40 +- test/suite/registry/section-16-p4.ts | 21 +- test/suite/registry/section-16-p5-p6.ts | 999 ++++++++++++++++++++---- 3 files changed, 901 insertions(+), 159 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 33dc57e..d71499c 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3762,13 +3762,51 @@ certify against FP-091's fixtures once those land. untouched — wiring is FP-084's task (FP-085 extracts the P-6 graph-diff oracle separately).] -- [ ] FP-084 — Generalize P-5 to random section moves using the full +- [x] FP-084 — Generalize P-5 to random section moves using the full 6.2/5.6 oracle. [R2 #37; TEST-SPEC §16 P-5] After FP-083. `test/suite/registry/section-16-p5-p6.ts` currently restricts the section-move arm to "clean-boundary" moves. Require: random section moves generally — straddling-line drops computed via the line-drop rules/P-2 oracle, created-target-file root as added, coincident-parent purity, `metadata-changed` on no node — anchored by T6.2-3/T6.2-4. + [Done 2026-08-29: arm 2 rebuilt on FP-083's `predictSectionMoveImpact`; + the trial no longer mutates the model. A piece-tree builder (sharing + section-16-p4.ts's now-exported renderRef/renderOpenTag/refIdentity) + reproduces renderWorkspace byte-for-byte when undecorated (asserted per + trial) and stages the origin/target files with randomized boundary + layouts; the runner computes the full prediction before any product call, + then asserts the impact report per node: required categories present, + unpredicted ones (metadata-changed included) rejected, tolerated-optional + accepted either way, attributions within `attributionWithin` and + including `attributionMustInclude`, deleted entries rejected, code empty. + Layouts (every byte form vetted against remark-mdx by an + implementation-time probe; findings in the module header): flow (any + subtree), balanced inline (childless plain-prose subtrees — remark-mdx + accepts a multi-line element only fully flow or fully inline, so SPEC + 6.2's worked shape is staged with an inline close such as `</S>ptail`), + single-line collapse, self-closing moved section; plus self-closing + target parents (T6.5-2), terminator-less-EOF root targets (mid-line + insertion), created target files (`specs/N0.mdx`; strict import window: + max referenced-out file strictly below min referencing-in file), and a + purity branch confined to final-child-onto-own-parent (the ordinary pick + excludes the pure-reproducing target). Embeddings keep prose-flanked + lines so no drop decision ever consults expansion emptiness; expansions + enter as emptiness-faithful sentinels from a model fixpoint. Arm-2 runs + 5→8. Verified: typecheck/format clean; scratch dry-runs (deleted before + commit) drove gen+builder+oracle productless — fixed seeds: 24/24 trials + accepted by the oracle's misuse guards, every staged file parses under + remark-mdx, reach = created ×3, same-file ×14 (pure ×5), cross-file ×7, + inline ×5 / collapse ×2 / selfClose ×1, moved-node straddle flips ×3, + self-closing target parent ×3, stripped EOF ×6, required + descendant/upstream attributions ×8/×6, optional (T6.2-3 tolerance) ×4 — + plus 1,440 random-seed trials, all accepted and parsing. Against the + built product, suite section-16-p5-p6 genuinely passes (2 passed, ~105s; + like P-2/P-3 since FP-082) — teeth proven by 2 end-to-end mutation + probes (each reverted): an oracle-unseen staged byte falsified with the + "no ground for" diagnosis, and a bogus attributionMustInclude entry + falsified in the attribution loop, the product's real attributions + flowing through the within-bound. `npm run test:self` unchanged 2 + planned mid-loop reds (certification-document ×2 → FP-091), 289 passed.] - [ ] FP-085 — Implement the P-6 baseline graph-diff oracle + its S-6 vetted suite; wire P-6 to it. [R2 #42; TEST-SPEC §16 P-6, §17 S-6; SPEC diff --git a/test/suite/registry/section-16-p4.ts b/test/suite/registry/section-16-p4.ts index d13ce9e..ddc3f89 100644 --- a/test/suite/registry/section-16-p4.ts +++ b/test/suite/registry/section-16-p4.ts @@ -217,7 +217,12 @@ function importBinding(fileIndex: number): string { return `M${String(fileIndex)}`; } -function refIdentity(ref: RefModel): string { +/** + * Workspace identity a reference resolves to (exported for the P-5 + * section-move piece-tree builder, which must speak the same identities — + * section-16-p5-p6.ts). + */ +export function refIdentity(ref: RefModel): string { return ref.dotted === "" ? filePath(ref.file) : `${filePath(ref.file)}#${ref.dotted}`; @@ -239,7 +244,12 @@ export function spellingVariants(ref: RefModel, hostFile: number): number { return ref.dotted === "" ? 1 : 3; } -function renderRef(ref: RefModel, hostFile: number): string { +/** + * Concrete spelling of a reference at its host file (exported for the P-5 + * section-move piece-tree builder — byte-exact agreement with + * renderWorkspace is guarded there). + */ +export function renderRef(ref: RefModel, hostFile: number): string { if (ref.file === hostFile) { if (ref.dotted === "") { throw new Error( @@ -261,7 +271,12 @@ function renderRef(ref: RefModel, hostFile: number): string { } } -function renderOpenTag( +/** + * A section's opening tag with its props, single-line (exported for the P-5 + * section-move piece-tree builder — byte-exact agreement with + * renderWorkspace is guarded there). + */ +export function renderOpenTag( section: SectionItem, dotted: string, hostFile: number, diff --git a/test/suite/registry/section-16-p5-p6.ts b/test/suite/registry/section-16-p5-p6.ts index 3424a55..d7eabf0 100644 --- a/test/suite/registry/section-16-p5-p6.ts +++ b/test/suite/registry/section-16-p5-p6.ts @@ -20,18 +20,24 @@ // no requirement categories and no impacted code (SPEC 6.2, 6.3, 9). // * P-5 arm 2 — random section moves. One random section-form `move`: any // section subtree to a random valid target parent (its own parent, a -// section of any file, or a file root — same-file and cross-file), under -// a fresh ID. Staged tags/coverage/`d` travel with the subtree. The -// impact report against the pre-move baseline must equal the oracle diff -// of the before/after workspace models: with the PROP-03 staging -// discipline every construct tag stands alone on its line, so no moved -// node has own-content bytes on the construct's straddling lines and the -// moved subtree keeps every hash (SPEC 6.2) — the only originators are -// the parents whose own-content sequence changed (origin and target; or -// none, when re-inserting a final child at its own former position -// reproduces the parent's content exactly), with the ordinary 5.6 -// cascades and nothing else: P-5's "only the predicted parents gain -// categories". +// section of any file, a file root — same-file and cross-file — or a +// freshly created target file), under a fresh ID, with the construct's +// byte layout at both boundaries randomized (see "arm-2 boundary +// staging" below). Staged tags/coverage/`d` travel with the subtree. +// The impact report against the pre-move baseline must satisfy the +// section-move category oracle (helpers/oracles/section-move.ts, vetted +// by its S-6 suite before this arm trusts it): the `changed` set drawn +// from exactly the origin parent, the target parent, and the moved +// subtree's nodes — a moved node `changed` iff the straddling-line +// drops of 6.2 change its runs, computed by the line-drop rules of 3 +// (every keep/drop decision delegated to P-2's markdown oracle) — a +// created target file's root `changed` as an added node carrying no +// other category, a coincident parent pure when the re-insertion +// reproduces its sequence (a final child re-inserted at its own former +// position, T6.2-4), `metadata-changed` on no node (SPEC 6.2), and +// `descendant-changed`/`upstream-changed` exactly per 5.6's cascades +// with per-category attribution bounds — anchored by T6.2-3/T6.2-4 +// (TEST-SPEC §16 P-5). // * P-6 — baseline replay. A random interleaving of staged edits (the // PROP-03 edit classes), `rename`, file-form `move`, and commits; then // `impact --base` against every historical baseline must equal the @@ -40,7 +46,57 @@ // harness composes the per-operation mappings it requested, which is // exactly the journal suffix a conforming product replays. // -// The oracle (shared by P-5 arm 2 and P-6) computes SPEC 5.6 categories from +// Arm-2 boundary staging (the generalization past PROP-03's tag-alone-line +// discipline; TEST-SPEC §16 P-5 "random section moves"). The two files a +// move textually touches are staged from piece trees (the FP-083 oracle's +// input form) built to reproduce renderWorkspace byte-for-byte when +// undecorated — asserted every trial — and then decorated at the moved +// construct's boundaries. Every decorated byte form was vetted against +// remark-mdx by an implementation-time probe (staged sources must parse, +// SPEC 1; findings below), which pinned this validity rule: a multi-line +// element parses only fully flow (tags at line starts, at most trailing +// whitespace sharing a tag's line) or fully inline (the whole element +// inside one paragraph, non-whitespace forcers on BOTH sides — an element +// opened inline must also close inline, so SPEC 6.2's worked shape is +// staged with a balanced close such as `</S>ptail`). The staged layouts: +// * flow — the PROP-03 form; any subtree (child sections, blanks, +// comments, embeddings); clean boundaries, moved subtree keeps every +// hash; +// * inline — parent prose immediately before the opening tag +// (`plead. <S …>`), moved-root text or whitespace-only residue after it +// on the same line (the SPEC 6.2 worked straddling case), moved-root +// text or residue before the closing tag, parent prose after it — +// balanced combinations only; requires a childless subtree of +// plain-text prose items (no embeddings, blanks, comments — an inline +// element's interior must stay inside one paragraph), or an empty body +// with parent prose on both sides; +// * collapse — a single-prose-item section as one line (`<S …>text</S>`, +// SPEC 3's in-line example): complete on its line, valid in every +// context, optional parent prose on either side (with embeddings in the +// prose, only the undecorated line-start form); +// * self-closing — an empty moved section as `<S … />`, optional parent +// prose on either side. +// The target side adds two forms: an empty target parent rendered +// self-closing (T6.5-2's rewrite exercised against the product) and, for an +// existing-file root target, the file's final line terminator stripped so +// the insertion point is mid-line (6.5's preceding-U+000A rule). Decoration +// bytes are owned by exactly the origin parent (outside the tags) and the +// moved root (inside them), and the construct's first and last body lines +// carry no other node's bytes, so no line whose keep/drop status the move +// flips holds a third node's bytes — the oracle's exactly-three-groups +// misuse guard enforces this, throwing a harness defect (H-8), never a +// diagnosed product failure. Embeddings keep the PROP-03 prose-flanked +// staging everywhere (never on a straddling or decorated line), so no +// line-drop decision ever consults an expansion's emptiness and the +// oracle's emptiness-stability contract holds trivially; expansion values +// are emptiness-faithful sentinels ("E"/"") from the model's expanded-text +// fixpoint — only emptiness enters the drop rule (SPEC 3), which never +// fires here. Import rewrites the move performs (additions as own lines, +// removals with their adjunct drops, 6.5) touch no node's runs, and +// reference respells never enter any hash (SPEC 5.4), so the oracle's +// derived after-side stays exact without modeling them. +// +// The graph-diff oracle below (P-6's) computes SPEC 5.6 categories from // the harness's own model semantics (section-16-p4.ts `semanticsOf`): per // node, `changed` iff added or its own-content token sequence changed; // `metadata-changed` iff its `d`-target set, coverage, or tag set changed; @@ -56,19 +112,25 @@ // suite's fixed T1.5-1 interpretation (SPEC 9.3 groups output by category), // carried through SUITE-20/22; entry granularity is merged per node // identity (the SUITE-20 convention). -// - Category sets are asserted exactly per node; attributions are asserted -// within the diff's originating-node set (SPEC 5.6: every category MUST be -// attributed to its originating nodes), the empty list accepted — exact -// causal attribution is pinned by the deterministic tests (SUITE-20/22). +// - P-6 asserts category sets exactly per node with attributions within the +// diff's originating-node set (SPEC 5.6: every category MUST be attributed +// to its originating nodes), the empty list accepted — exact causal +// attribution is pinned by the deterministic tests (SUITE-20/22). P-5's +// section-move arm asserts the tighter per-category bounds its oracle +// states: reported attributions lie within `attributionWithin` and include +// `attributionMustInclude` (TEST-SPEC §16 P-5, "attributions included"). // - The two-sided ambiguity documented by T6.2-3 — a node whose one-side-only -// subtree member carries the cause — is kept out of the required diff: the -// generators never let a changed or metadata-changed node relocate (guarded -// as a harness defect), P-6 stages no section moves and never deletes -// nodes, and added sections carry no dependency edges. The one residual -// case — an ancestor holding a *relocated* dependency-bearing node on one -// side only while that node's target changed effectively — makes +// subtree member carries the cause — is kept out of P-6's required diff: +// its generator never lets a changed or metadata-changed node relocate +// (guarded as a harness defect), stages no section moves, never deletes +// nodes, and adds only dependency-free sections. The one residual case — +// an ancestor holding a *relocated* dependency-bearing node on one side +// only while that node's target changed effectively — makes // `upstream-changed` optional on exactly those ancestors, accepted present -// or absent (mirroring T6.2-3's documented tolerance). +// or absent. P-5's section moves relocate whole subtrees by design; there +// the section-move oracle predicts each category as required or +// tolerated-optional per exactly that documented tolerance (its module +// header), and the assertion honors the flag. // - Every `impact` run follows a successful `build` (the SUITE-20/22 // protocol); P-5's operations regenerate as `build` does (SPEC 6.4), so no // extra build is needed between operations. @@ -106,6 +168,17 @@ import { decodeNodeRowsReport, } from "../../helpers/adapters/index.js"; import { fail } from "../../helpers/assertions.js"; +import type { + SectionMoveCategoryName, + SectionMoveDocument, + SectionMoveGraphNode, + SectionMovePiece, + SectionMovePrediction, +} from "../../helpers/oracles/section-move.js"; +import { + predictSectionMoveImpact, + sectionMoveSourceText, +} from "../../helpers/oracles/section-move.js"; import type { Choices, Gen } from "../../helpers/property.js"; import { checkProperty } from "../../helpers/property.js"; import { defineProductTest } from "../../helpers/registry.js"; @@ -116,6 +189,7 @@ import type { BodyItem, Edit, EditClass, + ProseItem, RefModel, SectionItem, WorkspaceModel, @@ -124,6 +198,9 @@ import { applyEdit, genEditOfClass, genWorkspaceModel, + refIdentity, + renderOpenTag, + renderRef, renderWorkspace, semanticsOf, } from "./section-16-p4.js"; @@ -800,71 +877,6 @@ function applyPureOp(state: TrialState, op: PureOp): AppliedOp { : applyMoveFile(state, op); } -interface SectionMoveOp { - readonly fromFile: number; - readonly dotted: string; - readonly toFile: number; - /** Target parent's dotted ID; null = the target file's root. */ - readonly targetDotted: string | null; - readonly newSeg: string; -} - -function applySectionMove(state: TrialState, op: SectionMoveOp): AppliedOp { - const located = locateSection(state.model, op.fromFile, op.dotted); - const section = located.items[located.index]; - if (section.kind !== "section") { - throw new Error("unreachable: locateSection returns a section index"); - } - const newDotted = - op.targetDotted === null ? op.newSeg : `${op.targetDotted}.${op.newSeg}`; - const oldSub = subtreeDotteds(section, op.dotted); - located.items.splice(located.index, 1); - section.seg = op.newSeg; - if (op.targetDotted === null) { - state.model.files[op.toFile].items.push(section); - } else { - const target = locateSection(state.model, op.toFile, op.targetDotted); - const parent = target.items[target.index]; - if (parent.kind !== "section") { - throw new Error("unreachable: locateSection returns a section index"); - } - parent.items.push(section); - } - state.model.files[op.toFile].nextSeg += 1; - const fromModelPath = state.modelPaths[op.fromFile]; - const toModelPath = state.modelPaths[op.toFile]; - const internalMap: Record<string, string> = {}; - const dottedMap: Record<string, string> = {}; - for (const dotted of oldSub) { - const mapped = rewriteDotted(dotted, op.dotted, newDotted); - if (mapped === null) { - throw new Error("unreachable: subtree dotteds share the prefix"); - } - dottedMap[dotted] = mapped; - internalMap[`${fromModelPath}#${dotted}`] = `${toModelPath}#${mapped}`; - } - forEachRef(state.model, (ref) => { - if (ref.file !== op.fromFile) return; - const mapped = dottedMap[ref.dotted]; - if (mapped !== undefined) { - ref.file = op.toFile; - ref.dotted = mapped; - } - }); - return { - argv: [ - "move", - `${state.paths[op.fromFile]}#${op.dotted}`, - `${state.paths[op.toFile]}#${newDotted}`, - ], - internalMap, - wsMap: {}, - description: - `move section ${state.paths[op.fromFile]}#${op.dotted} -> ` + - `${state.paths[op.toFile]}#${newDotted}`, - }; -} - // --------------------------------------------------------------------------- // Staged-edit application (P-6): rewrite edited files from the model // @@ -1095,18 +1107,165 @@ async function runPurityTrial( } // --------------------------------------------------------------------------- -// P-5 arm 2 — random section moves +// P-5 arm 2 — random section moves (module header: arm-2 boundary staging) + +/** + * Byte layout staged around the moved construct (module header). JSON-safe; + * `flow` is the undecorated PROP-03 form. + */ +interface MovedLayout { + readonly form: "flow" | "inline" | "collapse" | "selfClose"; + /** Origin-parent prose immediately before the opening tag (same line). */ + readonly leadOutside: string | null; + /** Moved-root bytes after the opening tag on its line (`inline` only). */ + readonly leadInside: string | null; + /** Moved-root bytes before the closing tag on its line (`inline` only). */ + readonly tailInside: string | null; + /** Origin-parent bytes immediately after the closing tag (same line). */ + readonly tailOutside: string | null; +} + +const FLOW_LAYOUT: MovedLayout = { + form: "flow", + leadOutside: null, + leadInside: null, + tailInside: null, + tailOutside: null, +}; + +// Fixed decoration bytes (deterministic staging, HARNESS-01): MDX-safe plain +// prose per the PROP-03 alphabet, whitespace residues two spaces (never four +// or more — line-start indentation must not open a Markdown code block). +const LEAD_OUTSIDE = "plead. "; +const LEAD_INSIDE = "k9 lead"; +const TAIL_INSIDE = "k9 tail"; +const TAIL_OUTSIDE = "ptail"; +const WS_RESIDUE = " "; + +/** + * Every inline combination the remark-mdx probe accepts (module header's + * balance rule): a non-whitespace open-side forcer — parent lead before the + * tag, or moved-root text after it — iff a non-whitespace close-side forcer; + * whitespace residues force nothing and ride either side. Enumerated in a + * fixed order, simplest first (shrinking). + */ +const INLINE_LAYOUTS: readonly MovedLayout[] = (() => { + const layouts: MovedLayout[] = []; + for (const leadOutside of [null, LEAD_OUTSIDE]) { + for (const leadInside of [null, WS_RESIDUE, LEAD_INSIDE]) { + for (const tailInside of [null, WS_RESIDUE, TAIL_INSIDE]) { + for (const tailOutside of [null, WS_RESIDUE, TAIL_OUTSIDE]) { + const openForced = leadOutside !== null || leadInside === LEAD_INSIDE; + const closeForced = + tailInside === TAIL_INSIDE || tailOutside === TAIL_OUTSIDE; + if (openForced && closeForced) { + layouts.push({ + form: "inline", + leadOutside, + leadInside, + tailInside, + tailOutside, + }); + } + } + } + } + } + return layouts; +})(); + +/** + * The probed inline form for an empty moved section (`plead. <S …>` + + * terminator + `</S>ptail`): parent prose on both sides, nothing inside. + */ +const EMPTY_INLINE_LAYOUT: MovedLayout = { + form: "inline", + leadOutside: LEAD_OUTSIDE, + leadInside: null, + tailInside: null, + tailOutside: TAIL_OUTSIDE, +}; + +const OUTSIDE_LEADS: readonly (string | null)[] = [null, LEAD_OUTSIDE]; +const OUTSIDE_TAILS: readonly (string | null)[] = [ + null, + WS_RESIDUE, + TAIL_OUTSIDE, +]; + +/** A prose item whose parts are all plain text (no embeddings). */ +function isPlainProse(item: BodyItem): item is ProseItem { + return item.kind === "prose" && item.parts.every((p) => p.kind === "text"); +} + +/** + * One random byte layout valid for the moved section's shape (module + * header): inline requires a childless all-plain-prose body (or an empty + * one, in the probed both-sides form), collapse a single prose item. + */ +function genMovedLayout(choices: Choices, section: SectionItem): MovedLayout { + const options: (readonly [number, () => MovedLayout])[] = [ + [4, () => FLOW_LAYOUT], + ]; + if (section.items.length === 0) { + options.push([ + 3, + () => ({ + form: "selfClose", + leadOutside: choices.pick(OUTSIDE_LEADS), + leadInside: null, + tailInside: null, + tailOutside: choices.pick(OUTSIDE_TAILS), + }), + ]); + options.push([2, () => EMPTY_INLINE_LAYOUT]); + } else { + if (section.items.every(isPlainProse)) { + options.push([10, () => choices.pick(INLINE_LAYOUTS)]); + } + if (section.items.length === 1 && section.items[0].kind === "prose") { + const plain = isPlainProse(section.items[0]); + options.push([ + 3, + () => ({ + form: "collapse", + // Embeddings stay valid only in the undecorated line-start + // collapse (module header / the probe). + leadOutside: plain ? choices.pick(OUTSIDE_LEADS) : null, + leadInside: null, + tailInside: null, + tailOutside: plain ? choices.pick(OUTSIDE_TAILS) : null, + }), + ]); + } + } + return choices.weightedPick(options)(); +} interface SectionMoveTrial { readonly model: WorkspaceModel; - readonly move: SectionMoveOp; + readonly fromFile: number; + /** Dotted ID of the moved section in the origin file. */ + readonly dotted: string; + readonly target: MoveCandidate; + readonly newSeg: string; + readonly layout: MovedLayout; + /** Render the (empty) target parent self-closing (T6.5-2's rewrite). */ + readonly selfCloseTargetParent: boolean; + /** Strip the root-target file's final terminator (mid-line insertion). */ + readonly stripFinalNewline: boolean; } interface MoveCandidate { - readonly toFile: number; + /** Existing target file index; null = the move creates the target file. */ + readonly toFile: number | null; + /** Target parent's dotted ID; null = the target file's root. */ readonly targetDotted: string | null; } +/** The created-target path (`specs/**` keeps it in the spec group, 6.5). */ +const CREATED_TARGET_PATH = "specs/N0.mdx"; + /** * Valid target parents for moving `moved`, mirroring SPEC 6.5's refusals * over the staged space (module header): the target is not within the moved @@ -1116,12 +1275,16 @@ interface MoveCandidate { * import-cycle-free window — every file referenced from the subtree at or * before it, every file referencing into the subtree at or after it (the * base import graph is the complete downward DAG, so any other destination - * would need a forward import that closes a cycle). + * would need a forward import that closes a cycle). A created target file + * (`createdOk`) sits strictly between the two: it must import every file + * the subtree references while every file referencing into the subtree + * imports it, so the window must be strict — max referenced-out index + * strictly below min referencing-in index. */ function moveCandidates( model: WorkspaceModel, moved: SectionSite, -): MoveCandidate[] { +): { readonly candidates: MoveCandidate[]; readonly createdOk: boolean } { const movedKeys = new Set( subtreeDotteds(moved.section, moved.dotted).map( (dotted) => `${String(moved.file)}#${dotted}`, @@ -1148,20 +1311,22 @@ function moveCandidates( for (const ref of moved.section.deps ?? []) insideRefs.add(ref); collectInside(moved.section.items); - let maxOut = 0; - let minIn = model.files.length - 1; + const outFiles = new Set<number>(); + const inFiles = new Set<number>(); const outTargets = new Set<string>(); forEachRef(model, (ref, hostFile) => { const targetsMoved = movedKeys.has(refKey(ref)); if (insideRefs.has(ref)) { if (!targetsMoved) { - maxOut = Math.max(maxOut, ref.file); + outFiles.add(ref.file); outTargets.add(refKey(ref)); } } else if (targetsMoved) { - minIn = Math.min(minIn, hostFile); + inFiles.add(hostFile); } }); + const maxOut = outFiles.size > 0 ? Math.max(...outFiles) : -1; + const minIn = inFiles.size > 0 ? Math.min(...inFiles) : model.files.length; const candidates: MoveCandidate[] = []; const consider = ( @@ -1196,7 +1361,7 @@ function moveCandidates( } consider(site.file, site.dotted, ancestorKeys); } - return candidates; + return { candidates, createdOk: maxOut < minIn }; } const genSectionMoveTrial: Gen<SectionMoveTrial> = (choices) => { @@ -1223,7 +1388,7 @@ const genSectionMoveTrial: Gen<SectionMoveTrial> = (choices) => { withChildren.length > 0 && choices.boolean(0.5) ? choices.pick(withChildren) : choices.pick(sections); - const candidates = moveCandidates(model, moved); + const { candidates, createdOk } = moveCandidates(model, moved); if (candidates.length === 0) { // The moved section's own parent is always a valid target (same file, // ancestors unchanged), so an empty candidate list is a harness defect. @@ -1232,41 +1397,560 @@ const genSectionMoveTrial: Gen<SectionMoveTrial> = (choices) => { `${String(moved.file)}#${moved.dotted}`, ); } - // Bias toward section target parents (nesting under a section, the deeper - // 6.5 insertion) over file roots, which otherwise dominate small models. - const sectionTargets = candidates.filter( - (candidate) => candidate.targetDotted !== null, - ); - const target = - sectionTargets.length > 0 && choices.boolean(0.65) - ? choices.pick(sectionTargets) - : choices.pick(candidates); + // Target pick: sometimes a created target file (the created-root-as-added + // arm) when the strict import window allows; sometimes the final child + // re-inserted at its own former position (T6.2-4's purity, reached in the + // random space — and confined to this branch: the ordinary pick excludes + // the pure-reproducing own-parent target so no-op trials stay rare); + // otherwise biased toward section parents (nesting under a section, the + // deeper 6.5 insertion) over file roots, which dominate small models. + const container = locateSection(model, moved.file, moved.dotted); + const isFinalChild = container.index === container.items.length - 1; + const ownParent: MoveCandidate = { + toFile: moved.file, + targetDotted: moved.parentDotted === "" ? null : moved.parentDotted, + }; + let target: MoveCandidate; + if (createdOk && choices.boolean(0.2)) { + target = { toFile: null, targetDotted: null }; + } else if (isFinalChild && choices.boolean(0.2)) { + target = ownParent; + } else { + const pool = isFinalChild + ? candidates.filter( + (candidate) => + candidate.toFile !== ownParent.toFile || + candidate.targetDotted !== ownParent.targetDotted, + ) + : candidates; + const effective = pool.length > 0 ? pool : candidates; + const sectionTargets = effective.filter( + (candidate) => candidate.targetDotted !== null, + ); + target = + sectionTargets.length > 0 && choices.boolean(0.65) + ? choices.pick(sectionTargets) + : choices.pick(effective); + } + const layout = genMovedLayout(choices, moved.section); + let selfCloseTargetParent = false; + if (target.toFile !== null && target.targetDotted !== null) { + const located = locateSection(model, target.toFile, target.targetDotted); + const parent = located.items[located.index]; + if ( + parent.kind === "section" && + parent.items.length === 0 && + choices.boolean(0.5) + ) { + selfCloseTargetParent = true; + } + } + let stripFinalNewline = false; + if (target.toFile !== null && target.targetDotted === null) { + const rendered = renderWorkspace(model); + const text = rendered[Object.keys(rendered)[target.toFile]]; + // Effective only when stripping actually leaves EOF mid-line: the last + // line non-empty and singly terminated. + const effective = + text.endsWith("\n") && + text.length > 1 && + text[text.length - 2] !== "\n" && + text[text.length - 2] !== "\r"; + if (effective && choices.boolean(0.5)) stripFinalNewline = true; + } return { model, - move: { - fromFile: moved.file, - dotted: moved.dotted, - toFile: target.toFile, - targetDotted: target.targetDotted, - newSeg: `s${String(model.files[target.toFile].nextSeg)}`, - }, + fromFile: moved.file, + dotted: moved.dotted, + target, + newSeg: + target.toFile === null + ? "s0" + : `s${String(model.files[target.toFile].nextSeg)}`, + layout, + selfCloseTargetParent, + stripFinalNewline, }; }; +// --- piece-tree staging (the FP-083 oracle's input form) --------------------- + +/** + * Emptiness-faithful expansion sentinels (module header): "E" when the + * identity's fully-expanded subtree text is non-empty, "" when empty. Only + * emptiness enters any drop decision (the oracle's contract; SPEC 3), and + * the prose-flanked embedding staging keeps even that from ever firing. + */ +function expansionSentinels(sems: SemanticsMap): (identity: string) => string { + const memo = new Map<string, boolean>(); + const visiting = new Set<string>(); + const nonempty = (identity: string): boolean => { + const cached = memo.get(identity); + if (cached !== undefined) return cached; + if (visiting.has(identity)) { + throw new Error( + `P-5 harness defect: contains/embeds cycle through ${identity} — ` + + `staged graphs are acyclic by construction (SPEC 5.3)`, + ); + } + const sem = sems.get(identity); + if (sem === undefined) { + throw new Error(`P-5 harness defect: no semantics for ${identity}`); + } + visiting.add(identity); + const tokens = JSON.parse(sem.ownTokens) as [string, string][]; + const result = tokens.some(([kind, value]) => + kind === "run" ? value !== "" : nonempty(value), + ); + visiting.delete(identity); + memo.set(identity, result); + return result; + }; + return (identity) => (nonempty(identity) ? "E" : ""); +} + +/** Decorations applied to one staged file (module header). */ +interface FileDecorations { + readonly moved?: { readonly dotted: string; readonly layout: MovedLayout }; + /** Dotted ID of an empty section to render self-closing. */ + readonly selfCloseDotted?: string; + readonly stripFinalNewline?: boolean; +} + +function stagingDefect(message: string): never { + throw new Error(`P-5 harness defect: ${message}`); +} + +/** + * The file's piece tree: byte-identical to renderWorkspace's output when + * `deco` is empty — locked by an equality assertion per trial — with the + * arm-2 boundary decorations applied where staged (module header). + */ +function buildFilePieces( + model: WorkspaceModel, + fileIndex: number, + modelPaths: readonly string[], + expansionOf: (identity: string) => string, + deco: FileDecorations, +): SectionMovePiece[] { + const prosePieces = ( + item: ProseItem, + withTerminator: boolean, + ): SectionMovePiece[] => { + const out: SectionMovePiece[] = []; + for (const part of item.parts) { + if (part.kind === "text") { + out.push({ kind: "content", text: part.text }); + } else { + const identity = refIdentity(part.ref); + out.push({ + kind: "embedding", + text: `{text(${renderRef(part.ref, fileIndex)})}`, + expansion: expansionOf(identity), + target: identity, + }); + } + } + if (withTerminator) out.push({ kind: "content", text: "\n" }); + return out; + }; + const newline: SectionMovePiece = { kind: "content", text: "\n" }; + const walk = ( + items: readonly BodyItem[], + parentDotted: string, + ): SectionMovePiece[] => { + const out: SectionMovePiece[] = []; + for (const item of items) { + switch (item.kind) { + case "blank": + out.push(newline); + break; + case "comment": + out.push({ kind: "removal", text: `{/* ${item.words} */}` }); + out.push(newline); + break; + case "prose": + out.push(...prosePieces(item, true)); + break; + case "section": { + const dotted = + parentDotted === "" ? item.seg : `${parentDotted}.${item.seg}`; + const open = renderOpenTag(item, dotted, fileIndex); + const selfClosed = `${open.slice(0, -1)} />`; + const depends = (item.deps ?? []).map(refIdentity); + const layout = + deco.moved !== undefined && deco.moved.dotted === dotted + ? deco.moved.layout + : null; + if (deco.selfCloseDotted === dotted) { + if (item.items.length > 0 || layout !== null) { + stagingDefect( + `self-closing decoration on ${dotted}, which has body items ` + + `or is the moved section`, + ); + } + out.push({ + kind: "section", + id: dotted, + open: selfClosed, + close: null, + body: [], + depends, + }); + out.push(newline); + break; + } + if (layout === null || layout.form === "flow") { + out.push({ + kind: "section", + id: dotted, + open, + close: "</S>", + body: [newline, ...walk(item.items, dotted)], + depends, + }); + out.push(newline); + break; + } + // A decorated moved construct (module header's staged forms). + if (layout.leadOutside !== null) { + out.push({ kind: "content", text: layout.leadOutside }); + } + if (layout.form === "selfClose") { + if (item.items.length > 0) { + stagingDefect(`selfClose layout on non-empty ${dotted}`); + } + out.push({ + kind: "section", + id: dotted, + open: selfClosed, + close: null, + body: [], + depends, + }); + } else if (layout.form === "collapse") { + const only = item.items[0]; + if (item.items.length !== 1 || only.kind !== "prose") { + stagingDefect( + `collapse layout on ${dotted} without exactly one prose item`, + ); + } + out.push({ + kind: "section", + id: dotted, + open, + close: "</S>", + body: prosePieces(only, false), + depends, + }); + } else { + if (!item.items.every(isPlainProse)) { + stagingDefect( + `inline layout on ${dotted}, whose body is not all ` + + `plain-text prose (module header)`, + ); + } + const body: SectionMovePiece[] = [ + { kind: "content", text: `${layout.leadInside ?? ""}\n` }, + ...walk(item.items, dotted), + ]; + if (layout.tailInside !== null) { + body.push({ kind: "content", text: layout.tailInside }); + } + out.push({ + kind: "section", + id: dotted, + open, + close: "</S>", + body, + depends, + }); + } + if (layout.tailOutside !== null) { + out.push({ kind: "content", text: layout.tailOutside }); + } + out.push(newline); + break; + } + } + } + return out; + }; + + const pieces: SectionMovePiece[] = []; + for (let j = 0; j < fileIndex; j += 1) { + pieces.push({ + kind: "removal", + text: `import M${String(j)} from "./${specBasename(modelPaths[j])}.xspec"`, + }); + pieces.push(newline); + } + // Mandatory blank line after the import block (PROP-03 module header). + if (fileIndex > 0) pieces.push(newline); + pieces.push(...walk(model.files[fileIndex].items, "")); + if (deco.stripFinalNewline === true) { + const last = pieces[pieces.length - 1]; + if ( + last === undefined || + last.kind !== "content" || + !last.text.endsWith("\n") + ) { + stagingDefect( + "stripFinalNewline on a file not ending with a content terminator", + ); + } + const trimmed = last.text.slice(0, -1); + if (trimmed === "") pieces.pop(); + else pieces[pieces.length - 1] = { kind: "content", text: trimmed }; + } + return pieces; +} + +interface BuiltSectionMove { + readonly origin: SectionMoveDocument; + readonly target: SectionMoveDocument | { readonly createdPath: string }; + /** The move's dotted new ID (SPEC 6.5). */ + readonly newId: string; + readonly otherNodes: readonly SectionMoveGraphNode[]; + readonly argv: readonly string[]; + /** Every workspace file as staged (decorations applied). */ + readonly files: Record<string, string>; + readonly description: string; +} + +/** + * Materialize a trial: piece trees for the involved files (decorated), the + * untouched files' graph nodes, the staged bytes, and the move's argv. Pure + * — identical trials build identical stagings (H-10) — and independent of + * the product, so every staging defect (including the oracle's misuse + * guards downstream) surfaces as a harness error, never a diagnosed + * failure (H-8). + */ +function buildSectionMove(trial: SectionMoveTrial): BuiltSectionMove { + const { model, target } = trial; + const rendered = renderWorkspace(model); + const modelPaths = Object.keys(rendered); + const sems = semanticsOf(model); + const expansionOf = expansionSentinels(sems); + + const originPath = modelPaths[trial.fromFile]; + const { toFile } = target; + const coincident = toFile === trial.fromFile; + const targetPath = toFile === null ? CREATED_TARGET_PATH : modelPaths[toFile]; + + // Builder-vs-renderer byte lock (module header): the undecorated piece + // tree reproduces renderWorkspace exactly for every involved file. + const involvedIndexes = new Set<number>([trial.fromFile]); + if (toFile !== null && !coincident) involvedIndexes.add(toFile); + for (const fileIndex of involvedIndexes) { + const undecorated = sectionMoveSourceText( + buildFilePieces(model, fileIndex, modelPaths, expansionOf, {}), + ); + if (undecorated !== rendered[modelPaths[fileIndex]]) { + stagingDefect( + `piece-tree builder diverges from renderWorkspace for ` + + `${modelPaths[fileIndex]}`, + ); + } + } + + const targetSideDeco: FileDecorations = { + ...(trial.selfCloseTargetParent && target.targetDotted !== null + ? { selfCloseDotted: target.targetDotted } + : {}), + ...(trial.stripFinalNewline ? { stripFinalNewline: true } : {}), + }; + const origin: SectionMoveDocument = { + path: originPath, + pieces: buildFilePieces(model, trial.fromFile, modelPaths, expansionOf, { + moved: { dotted: trial.dotted, layout: trial.layout }, + ...(coincident ? targetSideDeco : {}), + }), + }; + const targetDocument: SectionMoveDocument | { createdPath: string } = + toFile === null + ? { createdPath: targetPath } + : coincident + ? origin + : { + path: targetPath, + pieces: buildFilePieces( + model, + toFile, + modelPaths, + expansionOf, + targetSideDeco, + ), + }; + + const involvedPaths = new Set([originPath, targetPath]); + const otherNodes: SectionMoveGraphNode[] = []; + for (const [identity, sem] of sems) { + const hash = identity.indexOf("#"); + const path = hash === -1 ? identity : identity.slice(0, hash); + if (involvedPaths.has(path)) continue; + otherNodes.push({ + identity, + children: sem.children, + edgeTargets: sem.edgeTargets, + }); + } + + const newId = + target.targetDotted === null + ? trial.newSeg + : `${target.targetDotted}.${trial.newSeg}`; + const files: Record<string, string> = { ...rendered }; + files[originPath] = sectionMoveSourceText(origin.pieces); + if ("pieces" in targetDocument && !coincident) { + files[targetPath] = sectionMoveSourceText(targetDocument.pieces); + } + return { + origin, + target: targetDocument, + newId, + otherNodes, + argv: ["move", `${originPath}#${trial.dotted}`, `${targetPath}#${newId}`], + files, + description: + `move section ${originPath}#${trial.dotted} -> ${targetPath}#${newId} ` + + `(${trial.layout.form} layout${toFile === null ? ", created target" : ""}` + + `${trial.selfCloseTargetParent ? ", self-closing target parent" : ""}` + + `${trial.stripFinalNewline ? ", terminator-less EOF" : ""})`, + }; +} + +// --- prediction assertion (SPEC 6.2, 5.6, 9.1, 9.3; SUITE-20 merging) -------- + +function assertImpactMatchesPrediction( + report: ImpactReport, + prediction: SectionMovePrediction, + context: string, +): void { + const merged = new Map<string, Map<ChangeCategory, string[]>>(); + for (const entry of report.requirements) { + for (const identity of entry.nodes) { + if (!prediction.nodes.has(identity)) { + fail( + `${context}: the report names ${JSON.stringify(identity)}, which ` + + `is no current node of the workspace (in the workspace-relative ` + + `identity form of SPEC 1.5) — a pre-move identity here means the ` + + `product failed to unify identities through the journaled ` + + `mapping (SPEC 6.3, 6.5, 9.2); entry: ${JSON.stringify(entry)}`, + ); + } + if (entry.deleted) { + fail( + `${context}: an entry names ${JSON.stringify(identity)} as ` + + `deleted — a section move deletes no node: every moved node is ` + + `re-identified through the journaled mapping (SPEC 6.2, 6.5, ` + + `9.3); entry: ${JSON.stringify(entry)}`, + ); + } + let categories = merged.get(identity); + if (categories === undefined) { + categories = new Map(); + merged.set(identity, categories); + } + for (const category of entry.categories) { + const attributed = categories.get(category.category) ?? []; + attributed.push(...category.attributedTo); + categories.set(category.category, attributed); + } + } + } + + for (const [identity, node] of prediction.nodes) { + const reported = + merged.get(identity) ?? new Map<ChangeCategory, string[]>(); + for (const name of reported.keys()) { + if (name === "metadata-changed") { + fail( + `${context}: ${identity} is reported metadata-changed — a section ` + + `move changes no node's metadataHash: every moved node keeps ` + + `its own, and canonical identities preserve every other node's ` + + `(SPEC 6.2; TEST-SPEC §16 P-5)`, + ); + } + if (!node.categories.has(name as SectionMoveCategoryName)) { + fail( + `${context}: ${identity} carries the category ${name}, which the ` + + `section-move oracle gives it no ground for — expected within ` + + `${JSON.stringify([...node.categories.keys()].sort())} ` + + `(SPEC 6.2, 5.6, 9.1)`, + ); + } + } + for (const [name, category] of node.categories) { + const attribution = reported.get(name); + if (attribution === undefined) { + if (category.required) { + fail( + `${context}: ${identity} must carry ${name} — the section-move ` + + `oracle derives it from the staged move (SPEC 6.2, 5.6, 9.1) ` + + `— but the report gives it only ` + + `${JSON.stringify([...reported.keys()].sort())}`, + ); + } + // Tolerated-optional (the T6.2-3 two-sided tolerance): absence is + // accepted. + continue; + } + const attributed = [...new Set(attribution)].sort(); + const within = new Set(category.attributionWithin); + for (const source of attributed) { + if (!within.has(source)) { + fail( + `${context}: the ${name} category of ${identity} is attributed ` + + `to ${JSON.stringify(source)}, outside the oracle's ` + + `originating-node bound ` + + `${JSON.stringify([...category.attributionWithin])} — every ` + + `category is attributed to its originating nodes, the nodes ` + + `where edits occurred (SPEC 5.6)`, + ); + } + } + const attributedSet = new Set(attributed); + for (const source of category.attributionMustInclude) { + if (!attributedSet.has(source)) { + fail( + `${context}: the ${name} category of ${identity} must be ` + + `attributed to ${JSON.stringify(source)} — the originating ` + + `node its cause traces to through both-sides members ` + + `(SPEC 5.6: every category MUST be attributed to its ` + + `originating nodes) — but the report attributes it to ` + + `${JSON.stringify(attributed)}`, + ); + } + } + } + } + + assertSameJson( + report.code, + { direct: [], transitive: [] }, + `${context}: no code groups are configured, so no code location is ` + + `impacted (SPEC 9.2)`, + ); +} + async function runSectionMoveTrial( product: ProductBinding, trial: SectionMoveTrial, ): Promise<void> { - const state = initTrialState(trial.model); - const beforeSems = mapSemantics( - semanticsOf(state.model), - workspaceIdentityFn(state), - ); + const built = buildSectionMove(trial); + // The full prediction is computed before any product invocation: a + // staging outside the oracle's input space throws here as a harness + // defect (H-8), never a diagnosed product failure. + const prediction = predictSectionMoveImpact({ + origin: built.origin, + target: built.target, + movedId: trial.dotted, + newId: built.newId, + otherNodes: built.otherNodes, + }); const workspace = await TestWorkspace.create({ - files: { - "xspec.config.ts": SPECS_ONLY_CONFIG, - ...renderWorkspace(state.model), - }, + files: { "xspec.config.ts": SPECS_ONLY_CONFIG, ...built.files }, }); try { await workspace.gitInit(); @@ -1275,17 +1959,16 @@ async function runSectionMoveTrial( product, workspace, "P-5: `build` of the generated workspace (the generator stages only " + - "valid workspaces)", + "valid workspaces; every decorated byte form parses — module header)", ); - const applied = applySectionMove(state, trial.move); - const context = `P-5 section move — ${applied.description} —`; + const context = `P-5 section move — ${built.description} —`; await expectExit( product, workspace, - applied.argv, + built.argv, 0, - `P-5: \`${applied.argv.join(" ")}\` satisfies every 6.5 validation ` + - `over the staged space (module header), so the move must succeed`, + `P-5: \`${built.argv.join(" ")}\` satisfies every 6.5 validation over ` + + `the staged space (module header), so the move must succeed`, ); await expectExit( product, @@ -1295,23 +1978,18 @@ async function runSectionMoveTrial( `${context} \`check\` must pass: all rewritten references resolve and ` + `the journal replays (SPEC 6.5, 12.2)`, ); - const afterSems = mapSemantics( - semanticsOf(state.model), - workspaceIdentityFn(state), - ); - const diff = computeOracleDiff( - mapSemantics(beforeSems, composeIdentityMaps([applied.internalMap])), - afterSems, - ); const label = `${context} \`impact --base <pre-move ref> --json\``; - assertImpactMatchesOracle( + assertImpactMatchesPrediction( await impactAgainst(product, workspace, base, label), - diff, - `${label} — only the predicted parents originate categories: the ` + - `moved subtree keeps every hash (no own-content bytes on the ` + - `construct's straddling lines, SPEC 6.2), so the oracle diff holds ` + - `exactly the parents whose own-content sequence changed, with their ` + - `5.6 cascades`, + prediction, + `${label} — the report must match the section-move oracle's ` + + `prediction: the changed set drawn from exactly the origin parent, ` + + `the target parent, and the moved subtree's nodes (straddling-line ` + + `drops computed by the line-drop rules of 3), a created target ` + + `file's root changed as an added node, a coincident parent pure ` + + `when re-insertion reproduces its sequence, metadata-changed on no ` + + `node, and the 5.6 cascades with their attributions (TEST-SPEC §16 ` + + `P-5; SPEC 6.2, 5.6)`, ); } finally { await workspace.dispose(); @@ -1529,9 +2207,15 @@ function renderPurityTrial(trial: PurityTrial): string { } function renderSectionMoveTrial(trial: SectionMoveTrial): string { + // The staged bytes (decorations applied) are what reproduces the trial; + // buildSectionMove is pure. renderValue guards against a builder throw. + const built = buildSectionMove(trial); return JSON.stringify({ - files: renderWorkspace(trial.model), - move: trial.move, + files: built.files, + move: built.argv.slice(1).join(" -> "), + layout: trial.layout, + selfCloseTargetParent: trial.selfCloseTargetParent, + stripFinalNewline: trial.stripFinalNewline, }); } @@ -1549,12 +2233,17 @@ const P_5 = defineProductTest({ "pure — after every operation each node's four hashes are byte-identical under the " + "operation's identity map, `check` passes (all references resolve, the journal replays), " + "and `impact --base` against every prior commit in the sequence reports no categories and " + - "no impacted code; random clean-boundary section moves produce exactly the oracle-predicted " + - "impact: only the parents whose own-content sequence changed originate categories, with " + - "their ordinary 5.6 cascades (SPEC 5.4-5.6, 6.1-6.5, 9, 12.2; TEST-SPEC §16 P-5)", - // Wall-clock hang guard only (H-10): three fixed seeds (E-5), and per - // purity trial up to 3 operations x (sweep of every node + impact against - // every prior commit), plus the shrink budget on falsification. + "no impacted code; random section moves — boundary layouts randomized, same-file, " + + "cross-file, and created-target-file — produce exactly the section-move oracle's " + + "prediction: the changed set drawn from the origin parent, the target parent, and the " + + "moved subtree via the straddling-line drop rules of 3, a created target root changed as " + + "added, a coincident parent pure on exact re-insertion, metadata-changed on no node, and " + + "the 5.6 cascades with their attributions (SPEC 3, 5.4-5.6, 6.1-6.5, 9, 12.2; TEST-SPEC " + + "§16 P-5)", + // Wall-clock hang guard only (H-10): three fixed seeds (E-5); per purity + // trial up to 3 operations x (sweep of every node + impact against every + // prior commit), 8 section-move trials per seed (each one build + move + + // check + impact), plus the shrink budget on falsification. timeoutMs: 600_000, run: async (product) => { await checkProperty( @@ -1571,7 +2260,7 @@ const P_5 = defineProductTest({ async (trial) => { await runSectionMoveTrial(product, trial); }, - { runs: 5, maxShrinkExecutions: 80, render: renderSectionMoveTrial }, + { runs: 8, maxShrinkExecutions: 80, render: renderSectionMoveTrial }, ); }, }); From 5c55122733bcfeb0c6c34d5f697284c0ff590ec1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 15:57:38 +0000 Subject: [PATCH 130/226] sdg(phase-9): implement FP-085, the P-6 graph-diff oracle + its S-6 suite Extract P-6's inline SPEC 5.6 category diff into the baseline graph-diff oracle (test/helpers/oracles/graph-diff.ts), generalized to the added AND deleted convention of T5.6-6 with documented one-sided upstream tolerances and misuse guards; vet it with the S-6 fixed vector suite (test/self/s6-graph-diff-oracle.test.ts: SPEC 5.6's three worked examples plus T5.6-6, 12 vectors, mutation-probed); wire P-6 to computeGraphDiff, keeping its input-space guards at the call site. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 24 +- test/helpers/oracles/graph-diff.ts | 349 +++++++++++++ test/self/s6-graph-diff-oracle.test.ts | 622 ++++++++++++++++++++++++ test/suite/registry/section-16-p5-p6.ts | 290 +++-------- 4 files changed, 1050 insertions(+), 235 deletions(-) create mode 100644 test/helpers/oracles/graph-diff.ts create mode 100644 test/self/s6-graph-diff-oracle.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index d71499c..166c972 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3808,10 +3808,32 @@ certify against FP-091's fixtures once those land. flowing through the within-bound. `npm run test:self` unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091), 289 passed.] -- [ ] FP-085 — Implement the P-6 baseline graph-diff oracle + its S-6 +- [x] FP-085 — Implement the P-6 baseline graph-diff oracle + its S-6 vetted suite; wire P-6 to it. [R2 #42; TEST-SPEC §16 P-6, §17 S-6; SPEC 5.6] Vetted vectors: SPEC 5.6's three worked examples + the T5.6-6 case. + [Done 2026-08-29: P-6's inline category diff extracted to + test/helpers/oracles/graph-diff.ts — opaque 5.5-preimage keys + (ownKey/metaKey/pairKey), generalized past P-6's input space so the + vetted vectors can cover the worked material: added AND deleted nodes + exactly `changed` per T5.6-6 (deleted keyed by baseline identity, + flagged in `deleted`), edge-bearing added/deleted subtree members + joining relocated kept members in the tolerated-optional upstream + reading; misuse guards (relocated originators, incomplete graphs, + contains/dependency cycles, an ownKey not covering child tokens) throw + plain errors. Vetted by test/self/s6-graph-diff-oracle.test.ts: 12 + vectors — the three 5.6 worked examples (T5.6-1/-2/-3 shapes) plus the + coverage/tags-only closing sentence (T5.6-4 shapes), T5.6-6's + added/deleted convention, both one-sided tolerances, 5 misuse guards; + vector teeth proven by 4 oracle mutation probes (each reverted), all + falsified. P-6 wired to computeGraphDiff with its + no-deletion/dependency-free-addition input-space guards kept at the + call site; wiring teeth: one end-to-end probe (originators emptied, + reverted) falsified P-6 with the no-originating-node diagnosis. + Verified: typecheck/format clean; suite section-16-p5-p6 vs the built + product 2 passed (~110s, unchanged from FP-084); `npm run test:self` + 301 passed, unchanged 2 planned mid-loop reds (certification-document + ×2 → FP-091).] - [ ] FP-086 — P-7 generator: add the `$`-at-capture-boundary literal forms (`$0`, `$` before a non-digit, trailing `$`). [R2 #38; TEST-SPEC §16 P-7] diff --git a/test/helpers/oracles/graph-diff.ts b/test/helpers/oracles/graph-diff.ts new file mode 100644 index 0000000..b7e7cab --- /dev/null +++ b/test/helpers/oracles/graph-diff.ts @@ -0,0 +1,349 @@ +// In-harness baseline graph-diff oracle (TEST-SPEC 16 P-6, 17 S-6): an +// independent implementation of SPEC.md 5.6's change categories over two +// workspace graphs — a baseline graph whose identities the caller has +// already mapped forward through the journal suffix into current identities +// (SPEC 6.3; P-6 composes the per-operation mappings it requested) and the +// current graph. Per S-6, the oracle passes its fixed vector suite +// (test/self/s6-graph-diff-oracle.test.ts) — derived from SPEC.md 5.6's +// three worked examples plus the added/deleted convention of T5.6-6 — +// before any property test trusts it. Harness machinery only: pure +// functions, no product imports, no I/O, no test-framework dependence. +// +// The oracle hashes nothing: each side supplies, per node, opaque +// comparable keys standing in for the SPEC 5.5 hash preimages — equal keys +// exactly when the preimage is unchanged — plus the structure the cascades +// walk (children in document order, dependency-edge targets). SPEC 5.6 as +// implemented here, per node: +// +// * `changed`: the node was added or deleted, or its own-content key +// (`ownKey` — the 1.6 sequence: runs plus one reference token per child +// construct and per embedding, references as canonical identities) +// differs; adding, removing, or reordering children changes the parent's +// key, since identities enter the sequence at their positions (5.5: +// structural edits originate at the parent). +// * `metadata-changed`: the node's metadata key (`metaKey`: `d`-target set, +// coverage, tags — the metadataHash preimage, 5.5) differs. +// * `descendant-changed`: a changed node lies among the node's strict +// descendants on either side — an own-changed, added, or deleted +// descendant (5.6's worked examples pin the ancestors of added and of +// deleted children to exactly this category). +// * `upstream-changed`: the node's effective state changed through a +// dependency-edge cause — a both-sides dependency-edge target (of the +// node, or of a both-sides subtree node) whose effective state changed, +// or a strict-subtree node other than the node itself whose +// dependency-edge pair multiset (`pairKey`, one entry per edge, `depends` +// and `embeds` alike, 5.5/5.2) changed. Effective state is the 5.5 +// effectiveHash recursion evaluated as a fixpoint over both-sides nodes: +// own content changed, own pair multiset changed, a both-sides child +// changed effectively, or a both-sides dependency-edge target changed +// effectively (added and removed children and edges surface through +// `ownKey`/`pairKey`). +// * An added or deleted node receives no category through its own hashes — +// exactly `changed`, whatever metadata, children, or dependency edges it +// carries (5.6: baseline hash comparison is defined only for a node +// present on both sides; T5.6-6). Deleted nodes are keyed by their +// baseline (journal-mapped) identities and flagged in `deleted`. +// +// The two-sided tolerance (the ambiguity T6.2-3 documents, met here by +// relocations and by edge-bearing added or deleted subtree members): where +// a node's effective state changed but every dependency-edge cause traces +// only through one-side-only subtree members — a relocated (kept, +// one-side-only) member with a cause, or an added or deleted member +// carrying dependency edges, its edges arriving or departing with the node +// — `upstream-changed` is predicted tolerated-optional (`optionalUpstream`, +// accepted present or absent), while any both-sides cause makes it +// required. No SPEC.md worked material pins those one-sided readings, and +// P-6's generator keeps them out of its input space (its module header). +// +// Misuse guards (H-8) — each throws a plain error, a harness defect, never +// a diagnosed product failure: a relocated originator (a kept `changed` or +// `metadata-changed` node whose kept strict-ancestor sets differ across +// sides) would make `descendant-changed` two-sidedly ambiguous on its +// holders and is outside the oracle's input space; so are incomplete +// graphs (a child or walked dependency-edge target with no node on its +// side), contains or dependency cycles (5.3), and an `ownKey` that fails +// to cover the child reference tokens. + +// --------------------------------------------------------------------------- +// Input and output model + +/** One node of one side's graph, in the diff's shared identity space. */ +export interface GraphDiffNode { + /** Direct child identities in document order. */ + readonly children: readonly string[]; + /** + * Opaque key of the node's own content sequence (SPEC 1.6) — the ownHash + * preimage (5.5): equal keys iff the runs and the child and embedding + * reference tokens, at their positions, are unchanged. It MUST therefore + * cover the `children` list (guarded) and the embedding references. + */ + readonly ownKey: string; + /** + * Opaque key of (`d`-target set, coverage, tags) — the metadataHash + * preimage (SPEC 5.5). + */ + readonly metaKey: string; + /** + * Opaque key of the node's dependency-edge identity-pair multiset — one + * entry per edge, `depends` and `embeds` alike (SPEC 5.5, 5.2). + */ + readonly pairKey: string; + /** Deduplicated dependency-edge target identities (the closure walk). */ + readonly edgeTargets: readonly string[]; +} + +/** One side of the diff: every node of that graph, keyed by identity. */ +export type GraphDiffSide = ReadonlyMap<string, GraphDiffNode>; + +/** SPEC 5.6's category vocabulary. */ +export type GraphDiffCategory = + "changed" | "metadata-changed" | "descendant-changed" | "upstream-changed"; + +/** The oracle's prediction (module header). */ +export interface GraphDiff { + /** + * Exact required category set per node: kept and added nodes under + * current identities, deleted nodes under their baseline identities. + */ + readonly required: ReadonlyMap<string, ReadonlySet<GraphDiffCategory>>; + /** + * Nodes that may additionally carry `upstream-changed` — the documented + * one-sided-cause tolerance (module header), accepted present or absent. + */ + readonly optionalUpstream: ReadonlySet<string>; + /** + * Attribution bound: every originating node — those carrying `changed` + * (added and deleted included) or `metadata-changed` (SPEC 5.6: every + * category MUST be attributed to its originating nodes). + */ + readonly originators: ReadonlySet<string>; + /** Nodes present on the current side only (each required `changed`). */ + readonly added: ReadonlySet<string>; + /** Nodes present on the baseline side only (each required `changed`). */ + readonly deleted: ReadonlySet<string>; +} + +function misuse(message: string): never { + throw new Error(`graph-diff oracle misuse: ${message}`); +} + +/** Memoized strict-descendant sets over one side's `children` lists. */ +function strictDescendants( + side: GraphDiffSide, + label: string, +): Map<string, Set<string>> { + const memo = new Map<string, Set<string>>(); + const visiting = new Set<string>(); + const resolve = (identity: string): Set<string> => { + const cached = memo.get(identity); + if (cached !== undefined) return cached; + if (visiting.has(identity)) { + misuse( + `contains-cycle through ${identity} in the ${label} graph — ` + + `workspace graphs are acyclic (SPEC 5.3)`, + ); + } + visiting.add(identity); + const node = side.get(identity); + if (node === undefined) { + misuse( + `no ${label} node for ${identity} — every child identity must have ` + + `a node on its side`, + ); + } + const descendants = new Set<string>(); + for (const child of node.children) { + descendants.add(child); + for (const inner of resolve(child)) descendants.add(inner); + } + visiting.delete(identity); + memo.set(identity, descendants); + return descendants; + }; + for (const identity of side.keys()) resolve(identity); + return memo; +} + +/** + * Diff two workspace graphs per SPEC 5.6 (module header): the baseline side + * already mapped into current identities (SPEC 6.3), the current side as it + * stands. Returns the exact required category set per node, the + * tolerated-optional `upstream-changed` set, the originating-node + * attribution bound, and the added and deleted identity sets. + */ +export function computeGraphDiff( + before: GraphDiffSide, + after: GraphDiffSide, +): GraphDiff { + const kept = [...before.keys()].filter((identity) => after.has(identity)); + const added = [...after.keys()].filter((identity) => !before.has(identity)); + const deleted = [...before.keys()].filter((identity) => !after.has(identity)); + const beforeAt = (identity: string): GraphDiffNode => { + const node = before.get(identity); + if (node === undefined) misuse(`no baseline node for ${identity}`); + return node; + }; + const afterAt = (identity: string): GraphDiffNode => { + const node = after.get(identity); + if (node === undefined) misuse(`no current node for ${identity}`); + return node; + }; + + const keptSet = new Set(kept); + const ownChanged = new Set( + kept.filter((id) => beforeAt(id).ownKey !== afterAt(id).ownKey), + ); + const metaChanged = new Set( + kept.filter((id) => beforeAt(id).metaKey !== afterAt(id).metaKey), + ); + const pairChanged = new Set( + kept.filter((id) => beforeAt(id).pairKey !== afterAt(id).pairKey), + ); + const changedSet = new Set([...ownChanged, ...added, ...deleted]); + const originators = new Set([...changedSet, ...metaChanged]); + + // Input-contract guard: ownKey covers the child reference tokens (SPEC + // 1.6, 5.5 — identities enter the own-content sequence at their + // positions, so a differing child list forces a differing key). + for (const id of kept) { + if ( + !ownChanged.has(id) && + JSON.stringify(beforeAt(id).children) !== + JSON.stringify(afterAt(id).children) + ) { + misuse( + `the children of ${id} differ across sides while its ownKey ` + + `compares equal — ownKey must cover the child reference tokens ` + + `at their positions (SPEC 1.6, 5.5)`, + ); + } + } + + const descBefore = strictDescendants(before, "baseline"); + const descAfter = strictDescendants(after, "current"); + const descAt = ( + memo: Map<string, Set<string>>, + identity: string, + ): Set<string> => memo.get(identity) ?? new Set<string>(); + + // Misuse guard (module header): an originator never relocates — its + // kept strict-ancestor relation is two-sided — so `descendant-changed` + // is never ambiguous. Added and deleted nodes are one-sided by nature + // (the 5.6 worked examples pin their ancestors' category). + for (const id of kept) { + if (!ownChanged.has(id) && !metaChanged.has(id)) continue; + const beforeHolders = kept.filter((a) => descAt(descBefore, a).has(id)); + const afterHolders = kept.filter((a) => descAt(descAfter, a).has(id)); + if ( + JSON.stringify(beforeHolders.sort()) !== + JSON.stringify(afterHolders.sort()) + ) { + misuse( + `originating node ${id} relocated between baseline and current — ` + + `descendant-changed would be two-sidedly ambiguous on its ` + + `holders (the T6.2-3 ambiguity); the caller must keep changed ` + + `and metadata-changed nodes in place`, + ); + } + } + + // effChanged fixpoint over kept nodes: own content changed, own pair + // multiset changed, a both-sides child changed effectively, or a + // both-sides dependency-edge target changed effectively (SPEC 5.5; added + // or removed children and edges surface through ownKey/pairKey). + const effMemo = new Map<string, boolean>(); + const effVisiting = new Set<string>(); + const commonOf = ( + beforeList: readonly string[], + afterList: readonly string[], + ): string[] => + beforeList.filter((id) => keptSet.has(id) && afterList.includes(id)); + const effChanged = (id: string): boolean => { + const cached = effMemo.get(id); + if (cached !== undefined) return cached; + if (effVisiting.has(id)) { + misuse( + `dependency/contains cycle through ${id} — workspace graphs are ` + + `acyclic (SPEC 5.3)`, + ); + } + effVisiting.add(id); + const result = + ownChanged.has(id) || + pairChanged.has(id) || + commonOf(beforeAt(id).children, afterAt(id).children).some(effChanged) || + commonOf(beforeAt(id).edgeTargets, afterAt(id).edgeTargets).some( + effChanged, + ); + effVisiting.delete(id); + effMemo.set(id, result); + return result; + }; + + // A node's dependency-edge cause (SPEC 5.6 upstream-changed): a common + // dependency-edge target of the node itself or of a subtree node whose + // effective state changed, or a strict-subtree node (not the node itself) + // whose pair multiset changed. Both-sides subtree members give the + // required cause; one-side-only members give the optional tolerance + // (module header). + const targetCause = (id: string): boolean => + commonOf(beforeAt(id).edgeTargets, afterAt(id).edgeTargets).some( + effChanged, + ); + const memberCause = (member: string): boolean => + pairChanged.has(member) || targetCause(member); + + const required = new Map<string, Set<GraphDiffCategory>>(); + const optionalUpstream = new Set<string>(); + for (const id of kept) { + const categories = new Set<GraphDiffCategory>(); + if (ownChanged.has(id)) categories.add("changed"); + if (metaChanged.has(id)) categories.add("metadata-changed"); + const beforeDesc = descAt(descBefore, id); + const afterDesc = descAt(descAfter, id); + const eitherDesc = new Set([...beforeDesc, ...afterDesc]); + if ([...eitherDesc].some((d) => changedSet.has(d))) { + categories.add("descendant-changed"); + } + if (effChanged(id)) { + const bothMembers = [...beforeDesc].filter( + (d) => keptSet.has(d) && afterDesc.has(d), + ); + if (targetCause(id) || bothMembers.some(memberCause)) { + categories.add("upstream-changed"); + } else { + // Only a one-side-only subtree member's dependency cause remains: + // a relocated kept member with a cause, or an added or deleted + // member whose dependency edges arrived or departed with it — + // tolerable but not required (module header). + const oneSidedCause = [...eitherDesc].some((d) => { + if (keptSet.has(d)) { + return !(beforeDesc.has(d) && afterDesc.has(d)) && memberCause(d); + } + return afterDesc.has(d) + ? afterAt(d).edgeTargets.length > 0 + : beforeAt(d).edgeTargets.length > 0; + }); + if (oneSidedCause) optionalUpstream.add(id); + } + } + required.set(id, categories); + } + for (const id of added) { + // An added node is `changed` and receives no category through its own + // hashes (SPEC 5.6, T5.6-6). + required.set(id, new Set<GraphDiffCategory>(["changed"])); + } + for (const id of deleted) { + // A deleted node reports as deleted, under its baseline identity, and + // `changed` only (SPEC 5.6, T5.6-6). + required.set(id, new Set<GraphDiffCategory>(["changed"])); + } + return { + required, + optionalUpstream, + originators, + added: new Set(added), + deleted: new Set(deleted), + }; +} diff --git a/test/self/s6-graph-diff-oracle.test.ts b/test/self/s6-graph-diff-oracle.test.ts new file mode 100644 index 0000000..3322a96 --- /dev/null +++ b/test/self/s6-graph-diff-oracle.test.ts @@ -0,0 +1,622 @@ +// S-6 baseline graph-diff-oracle vectors (TEST-SPEC 17 S-6): the in-harness +// graph-diff oracle for P-6 (test/helpers/oracles/graph-diff.ts) passes this +// fixed vector suite, derived from SPEC.md 5.6's three worked examples plus +// the added/deleted convention of TEST-SPEC T5.6-6, before any property test +// trusts it. Every vector's category table is hand-computed; no product is +// involved (the product's own 5.6 behavior is asserted by the suite's +// T5.6-* tests against fixtures, not against this oracle). +// +// Coverage, by the worked material the vectors derive from: +// * 5.6's first worked example (T5.6-1's shapes): a single leaf-text edit +// — the leaf `changed`; every ancestor `descendant-changed`; sibling +// subtrees uncategorized; dependents of nodes on the path and those +// dependents' ancestors `upstream-changed`; the leaf the sole +// originating node ("all attributed to the leaf"); +// * 5.6's second worked example (T5.6-2's shapes): a child added and a +// child removed — C `changed` (added or deleted), P `changed` and +// `descendant-changed`, P's ancestors `descendant-changed`, and the +// upstream cascade to each parent's dependents, with no +// `upstream-changed` on the parents' own ancestor chains (no +// dependency-edge cause); +// * 5.6's third worked example (T5.6-3's shapes): `d`-target edits — D +// `metadata-changed`; no node `changed` or `descendant-changed`; every +// node whose effective state changed (ancestors, dependents, their +// dependents, and their ancestors, transitively) `upstream-changed` — +// plus its closing sentence (T5.6-4's shapes): a coverage/tags-only +// metadata edit changes no effective state and propagates no category; +// * T5.6-6's added/deleted convention: an added and a deleted +// file-and-subtree whose roots carry `d` targets (one to a node also +// edited since the baseline), coverage, tags, children, and an +// embedding — every added and every deleted node exactly `changed`, +// the deleted ones flagged deleted under their baseline identities; +// * the documented one-sided tolerances (the oracle's module header): +// a relocated non-originating member with a dependency cause, and +// edge-bearing added/deleted members under kept ancestors, each +// predicting `upstream-changed` as tolerated-optional; +// plus misuse guards: relocated originators, an ownKey not covering the +// child tokens, incomplete graphs, and contains/dependency cycles throw +// plain errors (harness defects), never diagnosed product failures. + +import { expect, test } from "vitest"; +import { computeGraphDiff } from "../helpers/oracles/graph-diff.js"; +import type { + GraphDiff, + GraphDiffNode, + GraphDiffSide, +} from "../helpers/oracles/graph-diff.js"; + +// --- vector-side graph builder ----------------------------------------------- + +interface NodeSpec { + /** Direct child identities in document order. */ + readonly children?: readonly string[]; + /** The node's own text runs, standing in for every content byte (1.6). */ + readonly own?: string; + /** `d`-declared dependency targets (metadata and edges, SPEC 2.2, 5.5). */ + readonly d?: readonly string[]; + /** `text(...)` embedding targets (own-content tokens and edges, 2.3). */ + readonly embeds?: readonly string[]; + /** Coverage/tags stand-in (a metadataHash input beside the `d` set). */ + readonly meta?: string; +} + +/** + * Build one side from per-node specs, deriving the opaque keys exactly as + * SPEC 5.5 frames the hash preimages: own content covers the runs plus the + * child and embedding reference tokens at their positions; metadata covers + * the `d`-target set, coverage, and tags; the pair multiset carries one + * entry per dependency edge, `depends` and `embeds` alike. + */ +function graph(nodes: Record<string, NodeSpec>): GraphDiffSide { + const side = new Map<string, GraphDiffNode>(); + for (const [identity, spec] of Object.entries(nodes)) { + const children = spec.children ?? []; + const d = [...(spec.d ?? [])].sort(); + const embeds = [...(spec.embeds ?? [])].sort(); + side.set(identity, { + children, + ownKey: JSON.stringify([spec.own ?? "", children, embeds]), + metaKey: JSON.stringify([d, spec.meta ?? ""]), + pairKey: JSON.stringify([...d, ...embeds].sort()), + edgeTargets: [...new Set([...d, ...embeds])].sort(), + }); + } + return side; +} + +// --- expectation helpers ----------------------------------------------------- + +/** The full required-category table as plain JSON (sorted members). */ +function tableOf(diff: GraphDiff): Record<string, string[]> { + const table: Record<string, string[]> = {}; + for (const [identity, categories] of diff.required) { + table[identity] = [...categories].sort(); + } + return table; +} + +function sortedSet(values: ReadonlySet<string>): string[] { + return [...values].sort(); +} + +// ============================================================================= +// 5.6's first worked example: a single edit to a leaf's text (T5.6-1 shapes) +// ============================================================================= + +const TREE = "specs/Tree.mdx"; +const TOP = "specs/Tree.mdx#top"; +const MID = "specs/Tree.mdx#top.mid"; +const LEAF = "specs/Tree.mdx#top.mid.leaf"; +const SIB = "specs/Tree.mdx#top.mid.sib"; +const SIB_INNER = "specs/Tree.mdx#top.mid.sib.inner"; +const OTHER = "specs/Tree.mdx#top.other"; +const DEPS = "specs/Deps.mdx"; +const ONLEAF = "specs/Deps.mdx#onleaf"; +const ONLEAF_DEP = "specs/Deps.mdx#onleaf.dep"; +const ONMID = "specs/Deps.mdx#onmid"; +const ONMID_DEP = "specs/Deps.mdx#onmid.dep"; + +/** The leaf-edit workspace, parameterized by the leaf's text run. */ +const leafEditSide = (leafText: string): GraphDiffSide => + graph({ + [TREE]: { children: [TOP] }, + [TOP]: { own: "Top text.", children: [MID, OTHER] }, + [MID]: { own: "Mid text.", children: [LEAF, SIB] }, + [LEAF]: { own: leafText }, + [SIB]: { own: "Sibling text.", children: [SIB_INNER] }, + [SIB_INNER]: { own: "Inner sibling text." }, + [OTHER]: { own: "Other subtree text." }, + [DEPS]: { children: [ONLEAF, ONMID] }, + [ONLEAF]: { own: "On-leaf holder text.", children: [ONLEAF_DEP] }, + [ONLEAF_DEP]: { own: "Depends on the edited leaf.", d: [LEAF] }, + [ONMID]: { own: "On-mid holder text.", children: [ONMID_DEP] }, + [ONMID_DEP]: { own: "Depends on an ancestor on the path.", d: [MID] }, + }); + +test("S-6 (5.6 leaf edit): leaf changed; ancestors descendant-changed; siblings uncategorized; dependents of path nodes and their ancestors upstream-changed; the leaf the sole originator", () => { + const diff = computeGraphDiff( + leafEditSide("Leaf text v1."), + leafEditSide("Leaf text v2."), + ); + expect(sortedSet(diff.added)).toEqual([]); + expect(sortedSet(diff.deleted)).toEqual([]); + expect(sortedSet(diff.optionalUpstream)).toEqual([]); + // "all attributed to the leaf": the attribution bound is exactly the leaf. + expect(sortedSet(diff.originators)).toEqual([LEAF]); + expect(tableOf(diff)).toEqual({ + [LEAF]: ["changed"], + [MID]: ["descendant-changed"], + [TOP]: ["descendant-changed"], + [TREE]: ["descendant-changed"], + [SIB]: [], + [SIB_INNER]: [], + [OTHER]: [], + [ONLEAF_DEP]: ["upstream-changed"], + [ONLEAF]: ["upstream-changed"], + [ONMID_DEP]: ["upstream-changed"], + [ONMID]: ["upstream-changed"], + [DEPS]: ["upstream-changed"], + }); +}); + +// ============================================================================= +// 5.6's second worked example: a child added and a child removed (T5.6-2) +// ============================================================================= + +const ADD = "specs/Add.mdx"; +const WRAP = "specs/Add.mdx#wrap"; +const P_ADD = "specs/Add.mdx#wrap.parent"; +const OLD = "specs/Add.mdx#wrap.parent.old"; +const NEW = "specs/Add.mdx#wrap.parent.new"; +const ADD_DEPS = "specs/AddDeps.mdx"; +const HOLDADD = "specs/AddDeps.mdx#holdadd"; +const HOLDADD_DEP = "specs/AddDeps.mdx#holdadd.dep"; +const REM = "specs/Rem.mdx"; +const WRAP2 = "specs/Rem.mdx#wrap2"; +const P_REM = "specs/Rem.mdx#wrap2.parent2"; +const KEEP = "specs/Rem.mdx#wrap2.parent2.keep"; +const GONE = "specs/Rem.mdx#wrap2.parent2.gone"; +const REM_DEPS = "specs/RemDeps.mdx"; +const HOLDREM = "specs/RemDeps.mdx#holdrem"; +const HOLDREM_DEP = "specs/RemDeps.mdx#holdrem.dep"; + +const childArmsSide = (withNew: boolean, withGone: boolean): GraphDiffSide => + graph({ + [ADD]: { children: [WRAP] }, + [WRAP]: { own: "Wrap text.", children: [P_ADD] }, + [P_ADD]: { + own: "Parent text.", + children: withNew ? [OLD, NEW] : [OLD], + }, + [OLD]: { own: "Existing child text." }, + ...(withNew ? { [NEW]: { own: "Added child text." } } : {}), + [ADD_DEPS]: { children: [HOLDADD] }, + [HOLDADD]: { own: "Add-side holder text.", children: [HOLDADD_DEP] }, + [HOLDADD_DEP]: { own: "Depends on the gaining parent.", d: [P_ADD] }, + [REM]: { children: [WRAP2] }, + [WRAP2]: { own: "Wrap-two text.", children: [P_REM] }, + [P_REM]: { + own: "Parent-two text.", + children: withGone ? [KEEP, GONE] : [KEEP], + }, + [KEEP]: { own: "Kept child text." }, + ...(withGone ? { [GONE]: { own: "Removed child text." } } : {}), + [REM_DEPS]: { children: [HOLDREM] }, + [HOLDREM]: { own: "Remove-side holder text.", children: [HOLDREM_DEP] }, + [HOLDREM_DEP]: { own: "Depends on the losing parent.", d: [P_REM] }, + }); + +test("S-6 (5.6 child add/remove): C changed as added or deleted, P changed and descendant-changed, P's ancestors descendant-changed only, and each parent's dependents upstream-changed", () => { + const diff = computeGraphDiff( + childArmsSide(false, true), + childArmsSide(true, false), + ); + expect(sortedSet(diff.added)).toEqual([NEW]); + expect(sortedSet(diff.deleted)).toEqual([GONE]); + expect(sortedSet(diff.optionalUpstream)).toEqual([]); + expect(sortedSet(diff.originators)).toEqual([NEW, P_ADD, GONE, P_REM].sort()); + expect(tableOf(diff)).toEqual({ + // Add arm. + [NEW]: ["changed"], + [P_ADD]: ["changed", "descendant-changed"], + [WRAP]: ["descendant-changed"], + [ADD]: ["descendant-changed"], + [OLD]: [], + [HOLDADD_DEP]: ["upstream-changed"], + [HOLDADD]: ["upstream-changed"], + [ADD_DEPS]: ["upstream-changed"], + // Remove arm: the removed child under its baseline identity. + [GONE]: ["changed"], + [P_REM]: ["changed", "descendant-changed"], + [WRAP2]: ["descendant-changed"], + [REM]: ["descendant-changed"], + [KEEP]: [], + [HOLDREM_DEP]: ["upstream-changed"], + [HOLDREM]: ["upstream-changed"], + [REM_DEPS]: ["upstream-changed"], + }); +}); + +// ============================================================================= +// 5.6's third worked example: d-target edits (T5.6-3), and its closing +// sentence: a coverage/tags-only metadata edit propagates nothing (T5.6-4) +// ============================================================================= + +const TARGETS = "specs/Targets.mdx"; +const T1 = "specs/Targets.mdx#t1"; +const T2 = "specs/Targets.mdx#t2"; +const GROW_FILE = "specs/Grow.mdx"; +const OUTERGROW = "specs/Grow.mdx#outergrow"; +const GROW = "specs/Grow.mdx#outergrow.grow"; +const SHRINK_FILE = "specs/Shrink.mdx"; +const OUTERSHRINK = "specs/Shrink.mdx#outershrink"; +const SHRINK = "specs/Shrink.mdx#outershrink.shrink"; +const GROW_DEPS = "specs/GrowDeps.mdx"; +const GROWHOLD = "specs/GrowDeps.mdx#growhold"; +const GROWHOLD_DIRECT = "specs/GrowDeps.mdx#growhold.direct"; +const GROWHOLD_CHAIN = "specs/GrowDeps.mdx#growhold.chain"; +const SHRINK_DEPS = "specs/ShrinkDeps.mdx"; +const SHRINKHOLD = "specs/ShrinkDeps.mdx#shrinkhold"; +const SHRINKHOLD_DIRECT = "specs/ShrinkDeps.mdx#shrinkhold.direct"; +const SHRINKHOLD_CHAIN = "specs/ShrinkDeps.mdx#shrinkhold.chain"; + +const dEditSide = ( + growD: readonly string[], + shrinkD: readonly string[], +): GraphDiffSide => + graph({ + [TARGETS]: { children: [T1, T2] }, + [T1]: { own: "Target one text." }, + [T2]: { own: "Target two text." }, + [GROW_FILE]: { children: [OUTERGROW] }, + [OUTERGROW]: { own: "Grow-side outer text.", children: [GROW] }, + [GROW]: { own: "Node whose target set grows.", d: growD }, + [SHRINK_FILE]: { children: [OUTERSHRINK] }, + [OUTERSHRINK]: { own: "Shrink-side outer text.", children: [SHRINK] }, + [SHRINK]: { own: "Node whose target set shrinks.", d: shrinkD }, + [GROW_DEPS]: { children: [GROWHOLD] }, + [GROWHOLD]: { + own: "Grow-dependent holder text.", + children: [GROWHOLD_DIRECT, GROWHOLD_CHAIN], + }, + [GROWHOLD_DIRECT]: { own: "Direct dependent.", d: [GROW] }, + [GROWHOLD_CHAIN]: { own: "Transitive dependent.", d: [GROWHOLD_DIRECT] }, + [SHRINK_DEPS]: { children: [SHRINKHOLD] }, + [SHRINKHOLD]: { + own: "Shrink-dependent holder text.", + children: [SHRINKHOLD_DIRECT, SHRINKHOLD_CHAIN], + }, + [SHRINKHOLD_DIRECT]: { own: "Direct dependent.", d: [SHRINK] }, + [SHRINKHOLD_CHAIN]: { + own: "Transitive dependent.", + d: [SHRINKHOLD_DIRECT], + }, + }); + +test("S-6 (5.6 d-target edit): D metadata-changed; nothing changed or descendant-changed; ancestors, dependents, their dependents, and their ancestors upstream-changed transitively, per arm", () => { + const diff = computeGraphDiff( + dEditSide([T1], [T1, T2]), + dEditSide([T1, T2], [T1]), + ); + expect(sortedSet(diff.added)).toEqual([]); + expect(sortedSet(diff.deleted)).toEqual([]); + expect(sortedSet(diff.optionalUpstream)).toEqual([]); + expect(sortedSet(diff.originators)).toEqual([GROW, SHRINK].sort()); + expect(tableOf(diff)).toEqual({ + // The originating nodes: metadata-changed, never upstream-changed from + // their own edge edits ("other than the node itself"). + [GROW]: ["metadata-changed"], + [SHRINK]: ["metadata-changed"], + // The targets gain and lose incoming edges only: uncategorized. + [T1]: [], + [T2]: [], + [TARGETS]: [], + // Grow arm cascade. + [OUTERGROW]: ["upstream-changed"], + [GROW_FILE]: ["upstream-changed"], + [GROWHOLD_DIRECT]: ["upstream-changed"], + [GROWHOLD_CHAIN]: ["upstream-changed"], + [GROWHOLD]: ["upstream-changed"], + [GROW_DEPS]: ["upstream-changed"], + // Shrink arm cascade. + [OUTERSHRINK]: ["upstream-changed"], + [SHRINK_FILE]: ["upstream-changed"], + [SHRINKHOLD_DIRECT]: ["upstream-changed"], + [SHRINKHOLD_CHAIN]: ["upstream-changed"], + [SHRINKHOLD]: ["upstream-changed"], + [SHRINK_DEPS]: ["upstream-changed"], + }); +}); + +const META_FILE = "specs/Meta.mdx"; +const META_OUTER = "specs/Meta.mdx#outer"; +const META_M = "specs/Meta.mdx#outer.m"; +const META_DEP = "specs/Meta.mdx#outer.dep"; + +const metaOnlySide = (meta: string): GraphDiffSide => + graph({ + [META_FILE]: { children: [META_OUTER] }, + [META_OUTER]: { + own: "Outer holder text.", + children: [META_M, META_DEP], + }, + [META_M]: { own: "Metadata-bearing node text.", meta }, + [META_DEP]: { own: "Depends on the metadata bearer.", d: [META_M] }, + }); + +test("S-6 (5.6 coverage/tags-only edit): the node metadata-changed alone — no effective state changes, so dependent and ancestors receive no category", () => { + const diff = computeGraphDiff( + metaOnlySide("required alpha beta"), + metaOnlySide("none alpha gamma"), + ); + expect(sortedSet(diff.added)).toEqual([]); + expect(sortedSet(diff.deleted)).toEqual([]); + expect(sortedSet(diff.optionalUpstream)).toEqual([]); + expect(sortedSet(diff.originators)).toEqual([META_M]); + expect(tableOf(diff)).toEqual({ + [META_M]: ["metadata-changed"], + [META_DEP]: [], + [META_OUTER]: [], + [META_FILE]: [], + }); +}); + +// ============================================================================= +// T5.6-6's added/deleted convention +// ============================================================================= + +const PRESENT = "specs/Present.mdx"; +const TGT = "specs/Present.mdx#tgt"; +const EMB = "specs/Present.mdx#emb"; +const DOOMED = "specs/Doomed.mdx"; +const GONE6 = "specs/Doomed.mdx#gone"; +const GONE6_KID = "specs/Doomed.mdx#gone.kid"; +const GONE6_KID2 = "specs/Doomed.mdx#gone.kid2"; +const FRESH = "specs/Fresh.mdx"; +const BORN = "specs/Fresh.mdx#born"; +const BORN_KID = "specs/Fresh.mdx#born.kid"; +const BORN_KID2 = "specs/Fresh.mdx#born.kid2"; + +/** + * The T5.6-6 staging: `Present.mdx` persists (its `tgt` edited across the + * baseline, `emb` the embedding target); `Doomed.mdx` exists only at the + * baseline and `Fresh.mdx` only currently — each root subtree carrying the + * full feature set: `d` targets (one to the also-edited `tgt`), coverage, + * tags, children, and an embedding. + */ +const conventionSide = ( + tgtText: string, + extra: "doomed" | "fresh", +): GraphDiffSide => + graph({ + [PRESENT]: { children: [TGT, EMB] }, + [TGT]: { own: tgtText }, + [EMB]: { own: "Embedding target text." }, + ...(extra === "doomed" + ? { + [DOOMED]: { children: [GONE6] }, + [GONE6]: { + own: "Doomed subtree root embedding: ", + children: [GONE6_KID, GONE6_KID2], + d: [TGT, EMB], + embeds: [EMB], + meta: "none legacy stale", + }, + [GONE6_KID]: { own: "Doomed child text." }, + [GONE6_KID2]: { own: "Second doomed child text." }, + } + : { + [FRESH]: { children: [BORN] }, + [BORN]: { + own: "Added subtree root embedding: ", + children: [BORN_KID, BORN_KID2], + d: [TGT, EMB], + embeds: [EMB], + meta: "none fresh added", + }, + [BORN_KID]: { own: "Added child text." }, + [BORN_KID2]: { own: "Second added child text." }, + }), + }); + +test("S-6 (T5.6-6): every added and every deleted node is changed only — whatever metadata, children, or dependency edges it carries — the deleted ones flagged under their baseline identities", () => { + const diff = computeGraphDiff( + conventionSide("Edited target text v1.", "doomed"), + conventionSide("Edited target text v2.", "fresh"), + ); + expect(sortedSet(diff.added)).toEqual( + [FRESH, BORN, BORN_KID, BORN_KID2].sort(), + ); + expect(sortedSet(diff.deleted)).toEqual( + [DOOMED, GONE6, GONE6_KID, GONE6_KID2].sort(), + ); + expect(sortedSet(diff.optionalUpstream)).toEqual([]); + // Added and deleted nodes are originating nodes beside the edited target. + expect(sortedSet(diff.originators)).toEqual( + [ + TGT, + FRESH, + BORN, + BORN_KID, + BORN_KID2, + DOOMED, + GONE6, + GONE6_KID, + GONE6_KID2, + ].sort(), + ); + expect(tableOf(diff)).toEqual({ + // The persisting side: the edited target and its cascade. + [TGT]: ["changed"], + [PRESENT]: ["descendant-changed"], + [EMB]: [], + // Every added node — the created file's root included — is changed + // only: never metadata-changed, descendant-changed, or + // upstream-changed, despite metadata, children, and a `d` target to a + // node also edited since the baseline. + [FRESH]: ["changed"], + [BORN]: ["changed"], + [BORN_KID]: ["changed"], + [BORN_KID2]: ["changed"], + // Every deleted node likewise, under its baseline identity. + [DOOMED]: ["changed"], + [GONE6]: ["changed"], + [GONE6_KID]: ["changed"], + [GONE6_KID2]: ["changed"], + }); +}); + +// ============================================================================= +// The documented one-sided tolerances (the oracle's module header) +// ============================================================================= + +const R_A = "specs/R.mdx"; +const R_H = "specs/R.mdx#h"; +const R_M = "specs/R.mdx#h.m"; // relocated: re-read as #m's node after the move +const R_T = "specs/R.mdx#t"; + +test("S-6 (tolerance, relocated member): a relocated non-originating member with a dependency cause makes upstream-changed optional on its one-side holder and required on its both-sides holder", () => { + // Before: A holds H and T; M (d -> T) sits under H. After: M sits + // directly under A; T's text is edited. M itself is unchanged (its key + // and metadata are identical), so it may relocate; H (child list) and A + // (child list) and T (text) are the originators and stay in place. + const before = graph({ + [R_A]: { children: [R_H, R_T] }, + [R_H]: { own: "Holder text.", children: [R_M] }, + [R_M]: { own: "Mover text.", d: [R_T] }, + [R_T]: { own: "Target text v1." }, + }); + const after = graph({ + [R_A]: { children: [R_H, R_M, R_T] }, + [R_H]: { own: "Holder text." }, + [R_M]: { own: "Mover text.", d: [R_T] }, + [R_T]: { own: "Target text v2." }, + }); + const diff = computeGraphDiff(before, after); + expect(sortedSet(diff.added)).toEqual([]); + expect(sortedSet(diff.deleted)).toEqual([]); + expect(sortedSet(diff.originators)).toEqual([R_A, R_H, R_T].sort()); + // H's only member cause is the relocated M (one-side-only): optional. + expect(sortedSet(diff.optionalUpstream)).toEqual([R_H]); + expect(tableOf(diff)).toEqual({ + // A holds M on both sides — its member cause is two-sided: required. + [R_A]: ["changed", "descendant-changed", "upstream-changed"], + [R_H]: ["changed"], + [R_M]: ["upstream-changed"], + [R_T]: ["changed"], + }); +}); + +const E_A = "specs/E.mdx"; +const E_P = "specs/E.mdx#p"; +const E_C = "specs/E.mdx#p.c"; +const E_Q = "specs/E.mdx#q"; +const E_G = "specs/E.mdx#q.g"; +const E_T = "specs/E.mdx#t"; + +test("S-6 (tolerance, edge-bearing added/deleted members): an added and a deleted member carrying dependency edges make upstream-changed optional on their kept ancestors, never required", () => { + // P gains child C (d -> T) and Q loses child G (d -> T) while T's text + // is edited: the members' edges arrive and depart with them, one-sided + // causes only (5.6's both-sides restriction), so every kept ancestor's + // upstream-changed is tolerated-optional. + const before = graph({ + [E_A]: { children: [E_P, E_Q, E_T] }, + [E_P]: { own: "Gaining parent text." }, + [E_Q]: { own: "Losing parent text.", children: [E_G] }, + [E_G]: { own: "Departing member text.", d: [E_T] }, + [E_T]: { own: "Edge target text v1." }, + }); + const after = graph({ + [E_A]: { children: [E_P, E_Q, E_T] }, + [E_P]: { own: "Gaining parent text.", children: [E_C] }, + [E_C]: { own: "Arriving member text.", d: [E_T] }, + [E_Q]: { own: "Losing parent text." }, + [E_T]: { own: "Edge target text v2." }, + }); + const diff = computeGraphDiff(before, after); + expect(sortedSet(diff.added)).toEqual([E_C]); + expect(sortedSet(diff.deleted)).toEqual([E_G]); + expect(sortedSet(diff.originators)).toEqual([E_P, E_Q, E_T, E_C, E_G].sort()); + expect(sortedSet(diff.optionalUpstream)).toEqual([E_A, E_P, E_Q].sort()); + expect(tableOf(diff)).toEqual({ + [E_A]: ["descendant-changed"], + [E_P]: ["changed", "descendant-changed"], + [E_Q]: ["changed", "descendant-changed"], + [E_C]: ["changed"], + [E_G]: ["changed"], + [E_T]: ["changed"], + }); +}); + +// ============================================================================= +// Misuse guards +// ============================================================================= + +test("S-6: a relocated originating node throws — descendant-changed would be two-sidedly ambiguous", () => { + const before = graph({ + [R_A]: { children: [R_H] }, + [R_H]: { own: "Holder text.", children: [R_M] }, + [R_M]: { own: "Mover text v1." }, + }); + const after = graph({ + [R_A]: { children: [R_H, R_M] }, + [R_H]: { own: "Holder text." }, + [R_M]: { own: "Mover text v2." }, + }); + expect(() => computeGraphDiff(before, after)).toThrow( + /oracle misuse:.*relocated/, + ); +}); + +test("S-6: an ownKey that fails to cover a differing child list throws", () => { + const raw = (children: readonly string[]): GraphDiffNode => ({ + children, + ownKey: "constant", + metaKey: "m", + pairKey: "p", + edgeTargets: [], + }); + const leaf: GraphDiffNode = { + children: [], + ownKey: "leaf", + metaKey: "m", + pairKey: "p", + edgeTargets: [], + }; + const before: GraphDiffSide = new Map([ + ["specs/A.mdx", raw(["specs/A.mdx#b"])], + ["specs/A.mdx#b", leaf], + ]); + const after: GraphDiffSide = new Map([ + ["specs/A.mdx", raw([])], + ["specs/A.mdx#b", leaf], + ]); + expect(() => computeGraphDiff(before, after)).toThrow( + /oracle misuse:.*ownKey must cover the child reference tokens/, + ); +}); + +test("S-6: a child identity with no node on its side throws — the graph must be complete", () => { + const side = graph({ [R_A]: { children: [R_H] } }); + expect(() => computeGraphDiff(side, side)).toThrow( + /oracle misuse:.*no baseline node/, + ); +}); + +test("S-6: a contains-cycle throws", () => { + const side = graph({ + [R_A]: { children: [R_H] }, + [R_H]: { own: "h", children: [R_A] }, + }); + expect(() => computeGraphDiff(side, side)).toThrow( + /oracle misuse:.*contains-cycle/, + ); +}); + +test("S-6: a dependency cycle throws", () => { + const side = graph({ + [R_A]: { own: "a", d: [R_H] }, + [R_H]: { own: "h", d: [R_A] }, + }); + expect(() => computeGraphDiff(side, side)).toThrow(/oracle misuse:.*cycle/); +}); diff --git a/test/suite/registry/section-16-p5-p6.ts b/test/suite/registry/section-16-p5-p6.ts index d7eabf0..d036a61 100644 --- a/test/suite/registry/section-16-p5-p6.ts +++ b/test/suite/registry/section-16-p5-p6.ts @@ -96,16 +96,18 @@ // reference respells never enter any hash (SPEC 5.4), so the oracle's // derived after-side stays exact without modeling them. // -// The graph-diff oracle below (P-6's) computes SPEC 5.6 categories from -// the harness's own model semantics (section-16-p4.ts `semanticsOf`): per -// node, `changed` iff added or its own-content token sequence changed; -// `metadata-changed` iff its `d`-target set, coverage, or tag set changed; -// `descendant-changed` iff a changed node lies among its strict descendants -// (either side); `upstream-changed` iff its effective state changed through a -// dependency-edge cause — a dependency-edge target (of the node or of a -// both-sides subtree node) whose effective state changed, or a strict-subtree -// node whose dependency-edge pair multiset changed (SPEC 5.5's effectiveHash -// recursion, evaluated as a fixpoint over the model). +// P-6's category oracle is the baseline graph-diff oracle +// (helpers/oracles/graph-diff.ts, vetted by its S-6 suite — SPEC 5.6's +// three worked examples plus T5.6-6's added/deleted convention — before +// this arm trusts it): per node, `changed` iff added or its own-content +// key changed; `metadata-changed` iff its `d`-target set, coverage, or tag +// set changed; `descendant-changed` iff a changed node lies among its +// strict descendants (either side); `upstream-changed` iff its effective +// state changed through a dependency-edge cause (SPEC 5.5's effectiveHash +// recursion, evaluated as a fixpoint). It is fed the harness's own model +// semantics (section-16-p4.ts `semanticsOf`), every identity mapped into +// the current workspace space, the JSON semantic keys standing in for the +// 5.5 hash preimages. // // Conservative operationalizations (noted per H-4): // - "No change categories" is asserted as an empty `requirements` list — the @@ -122,8 +124,12 @@ // - The two-sided ambiguity documented by T6.2-3 — a node whose one-side-only // subtree member carries the cause — is kept out of P-6's required diff: // its generator never lets a changed or metadata-changed node relocate -// (guarded as a harness defect), stages no section moves, never deletes -// nodes, and adds only dependency-free sections. The one residual case — +// (the graph-diff oracle's relocated-originator misuse guard), stages no +// section moves, never deletes nodes, and adds only dependency-free +// sections (both guarded at the call site as harness defects — the oracle +// itself handles deletions and edge-bearing additions per SPEC 5.6 and +// its documented tolerance, but this generator stages neither). The one +// residual case — // an ancestor holding a *relocated* dependency-bearing node on one side // only while that node's target changed effectively — makes // `upstream-changed` optional on exactly those ancestors, accepted present @@ -168,6 +174,12 @@ import { decodeNodeRowsReport, } from "../../helpers/adapters/index.js"; import { fail } from "../../helpers/assertions.js"; +import type { + GraphDiff, + GraphDiffNode, + GraphDiffSide, +} from "../../helpers/oracles/graph-diff.js"; +import { computeGraphDiff } from "../../helpers/oracles/graph-diff.js"; import type { SectionMoveCategoryName, SectionMoveDocument, @@ -228,7 +240,7 @@ import { type IdentityFn = (identity: string) => string; -/** Semantic content of one node, in whatever identity space it was mapped to. */ +/** Semantic content of one node in model space (`semanticsOf`'s shape). */ interface NodeSemantics { readonly children: readonly string[]; readonly ownTokens: string; @@ -252,14 +264,16 @@ function composeIdentityMaps( } /** - * Map every identity occurrence of a semantics map — keys, child lists, the - * reference tokens inside `ownTokens`, the `d`-target set inside `metaKey`, - * the dependency-edge pair multiset `pairKey`, and `edgeTargets` — through - * `fn`, re-sorting the sorted components (mapping is injective over the - * staged spaces, so deduplicated sets stay deduplicated). + * Map every identity occurrence of a model semantics map — keys, child + * lists, the reference tokens inside `ownTokens`, the `d`-target set inside + * `metaKey`, the dependency-edge pair multiset `pairKey`, and `edgeTargets` + * — through `fn`, re-sorting the sorted components (mapping is injective + * over the staged spaces, so deduplicated sets stay deduplicated). The + * result is one side of the graph-diff oracle's input: the mapped JSON + * semantic keys stand in for the SPEC 5.5 hash preimages. */ -function mapSemantics(sems: SemanticsMap, fn: IdentityFn): SemanticsMap { - const mapped = new Map<string, NodeSemantics>(); +function mapSemantics(sems: SemanticsMap, fn: IdentityFn): GraphDiffSide { + const mapped = new Map<string, GraphDiffNode>(); for (const [identity, sem] of sems) { const tokens = JSON.parse(sem.ownTokens) as [string, string][]; const [deps, coverage, tags] = JSON.parse(sem.metaKey) as [ @@ -270,7 +284,7 @@ function mapSemantics(sems: SemanticsMap, fn: IdentityFn): SemanticsMap { const pairs = JSON.parse(sem.pairKey) as string[]; mapped.set(fn(identity), { children: sem.children.map(fn), - ownTokens: JSON.stringify( + ownKey: JSON.stringify( tokens.map(([kind, value]) => kind === "run" ? [kind, value] : [kind, fn(value)], ), @@ -289,223 +303,12 @@ function mapSemantics(sems: SemanticsMap, fn: IdentityFn): SemanticsMap { return mapped; } -// --------------------------------------------------------------------------- -// The SPEC 5.6 category oracle -// -// Inputs are two semantics maps in one identity space (the baseline mapped -// forward to current identities). Output: per current-graph node the exact -// required category set, the optional-upstream tolerance set, and the -// originating-node attribution bound (module header, H-4). - -interface OracleDiff { - /** Exact required category set per current-graph node identity. */ - readonly required: ReadonlyMap<string, ReadonlySet<ChangeCategory>>; - /** Nodes that may additionally carry `upstream-changed` (module header). */ - readonly optionalUpstream: ReadonlySet<string>; - /** Attribution bound: every originating node's current identity. */ - readonly originators: ReadonlySet<string>; -} - -/** Memoized strict-descendant sets over one side's `children` lists. */ -function strictDescendants(sems: SemanticsMap): Map<string, Set<string>> { - const memo = new Map<string, Set<string>>(); - const visiting = new Set<string>(); - const resolve = (identity: string): Set<string> => { - const cached = memo.get(identity); - if (cached !== undefined) return cached; - if (visiting.has(identity)) { - throw new Error( - `P-5/P-6 harness defect: contains-cycle through ${identity}`, - ); - } - visiting.add(identity); - const sem = sems.get(identity); - if (sem === undefined) { - throw new Error(`P-5/P-6 harness defect: no semantics for ${identity}`); - } - const descendants = new Set<string>(); - for (const child of sem.children) { - descendants.add(child); - for (const inner of resolve(child)) descendants.add(inner); - } - visiting.delete(identity); - memo.set(identity, descendants); - return descendants; - }; - for (const identity of sems.keys()) resolve(identity); - return memo; -} - -function computeOracleDiff( - before: SemanticsMap, - after: SemanticsMap, -): OracleDiff { - const kept = [...before.keys()].filter((identity) => after.has(identity)); - const added = [...after.keys()].filter((identity) => !before.has(identity)); - const deleted = [...before.keys()].filter((identity) => !after.has(identity)); - if (deleted.length > 0) { - throw new Error( - `P-5/P-6 harness defect: the generated history deleted node(s) ` + - `${deleted.join(", ")} — deletions are outside PROP-04's input space ` + - `(module header)`, - ); - } - const beforeAt = (identity: string): NodeSemantics => { - const sem = before.get(identity); - if (sem === undefined) { - throw new Error( - `P-5/P-6 harness defect: no baseline semantics for ${identity}`, - ); - } - return sem; - }; - const afterAt = (identity: string): NodeSemantics => { - const sem = after.get(identity); - if (sem === undefined) { - throw new Error( - `P-5/P-6 harness defect: no current semantics for ${identity}`, - ); - } - return sem; - }; - - const keptSet = new Set(kept); - const ownChanged = new Set( - kept.filter((id) => beforeAt(id).ownTokens !== afterAt(id).ownTokens), - ); - const metaChanged = new Set( - kept.filter((id) => beforeAt(id).metaKey !== afterAt(id).metaKey), - ); - const pairChanged = new Set( - kept.filter((id) => beforeAt(id).pairKey !== afterAt(id).pairKey), - ); - const changedSet = new Set([...ownChanged, ...added]); - const originators = new Set([...changedSet, ...metaChanged]); - - const descBefore = strictDescendants(before); - const descAfter = strictDescendants(after); - const descAt = ( - memo: Map<string, Set<string>>, - identity: string, - ): Set<string> => memo.get(identity) ?? new Set<string>(); - - // Input-space guard (module header, H-4): an originator never relocates — - // its strict-ancestor relation is two-sided — so `descendant-changed` is - // never ambiguous. Added nodes are one-sided by nature (the 5.6 worked - // example pins their ancestors' category) and carry no dependency edges. - for (const id of kept) { - if (!ownChanged.has(id) && !metaChanged.has(id)) continue; - const beforeHolders = kept.filter((a) => descAt(descBefore, a).has(id)); - const afterHolders = kept.filter((a) => descAt(descAfter, a).has(id)); - if ( - JSON.stringify(beforeHolders.sort()) !== - JSON.stringify(afterHolders.sort()) - ) { - throw new Error( - `P-5/P-6 harness defect: originating node ${id} relocated between ` + - `baseline and current — the generators must never move a changed ` + - `node (module header)`, - ); - } - } - for (const id of added) { - if (afterAt(id).edgeTargets.length > 0) { - throw new Error( - `P-5/P-6 harness defect: added node ${id} carries dependency edges — ` + - `added sections must be dependency-free (module header)`, - ); - } - } - - // effChanged fixpoint over kept nodes: own content changed, own pair - // multiset changed, a both-sides child changed effectively, or a - // both-sides dependency-edge target changed effectively (SPEC 5.5; added - // or removed children and edges surface through ownTokens/pairKey). - const effMemo = new Map<string, boolean>(); - const effVisiting = new Set<string>(); - const commonOf = ( - beforeList: readonly string[], - afterList: readonly string[], - ): string[] => - beforeList.filter((id) => keptSet.has(id) && afterList.includes(id)); - const effChanged = (id: string): boolean => { - const cached = effMemo.get(id); - if (cached !== undefined) return cached; - if (effVisiting.has(id)) { - throw new Error( - `P-5/P-6 harness defect: dependency/contains cycle through ${id} — ` + - `generated graphs are acyclic by construction (SPEC 5.3)`, - ); - } - effVisiting.add(id); - const result = - ownChanged.has(id) || - pairChanged.has(id) || - commonOf(beforeAt(id).children, afterAt(id).children).some(effChanged) || - commonOf(beforeAt(id).edgeTargets, afterAt(id).edgeTargets).some( - effChanged, - ); - effVisiting.delete(id); - effMemo.set(id, result); - return result; - }; - - // A node's dependency-edge cause (SPEC 5.6 upstream-changed): a common - // dependency-edge target of the node itself or of a subtree node whose - // effective state changed, or a strict-subtree node (not the node itself) - // whose pair multiset changed. Both-sides subtree members give the - // required cause; one-side-only kept members (relocated subtrees) give the - // optional tolerance (module header, H-4). - const targetCause = (id: string): boolean => - commonOf(beforeAt(id).edgeTargets, afterAt(id).edgeTargets).some( - effChanged, - ); - const memberCause = (member: string): boolean => - pairChanged.has(member) || targetCause(member); - - const required = new Map<string, Set<ChangeCategory>>(); - const optionalUpstream = new Set<string>(); - for (const id of kept) { - const categories = new Set<ChangeCategory>(); - if (ownChanged.has(id)) categories.add("changed"); - if (metaChanged.has(id)) categories.add("metadata-changed"); - const beforeDesc = descAt(descBefore, id); - const afterDesc = descAt(descAfter, id); - const eitherDesc = new Set([...beforeDesc, ...afterDesc]); - if ([...eitherDesc].some((d) => changedSet.has(d))) { - categories.add("descendant-changed"); - } - if (effChanged(id)) { - const bothMembers = [...beforeDesc].filter( - (d) => keptSet.has(d) && afterDesc.has(d), - ); - if (targetCause(id) || bothMembers.some(memberCause)) { - categories.add("upstream-changed"); - } else { - const oneSided = [...eitherDesc].filter( - (d) => keptSet.has(d) && !(beforeDesc.has(d) && afterDesc.has(d)), - ); - // Only a relocated (one-side-only) subtree member's dependency cause - // makes the category tolerable-but-not-required (module header, H-4). - if (oneSided.some(memberCause)) optionalUpstream.add(id); - } - } - required.set(id, categories); - } - for (const id of added) { - // An added node is `changed` and receives no category through its own - // hashes (SPEC 5.6). - required.set(id, new Set<ChangeCategory>(["changed"])); - } - return { required, optionalUpstream, originators }; -} - // --------------------------------------------------------------------------- // Impact-report-vs-oracle assertion (SPEC 5.6, 9.1, 9.3; SUITE-20 merging) function assertImpactMatchesOracle( report: ImpactReport, - oracle: OracleDiff, + oracle: GraphDiff, context: string, ): void { interface MergedNode { @@ -2179,7 +1982,26 @@ async function runReplayTrial( composeIdentityMaps(internalMaps.slice(snapshot.mapsFrom))(identity), ), ); - const diff = computeOracleDiff(mapped, currentSems); + const diff = computeGraphDiff(mapped, currentSems); + // Input-space guards (module header, H-4): the oracle defines + // deletions and edge-bearing additions, but this generator stages + // neither — meeting one is a harness defect (H-8), never a diagnosed + // product failure. + if (diff.deleted.size > 0) { + throw new Error( + `P-6 harness defect: the generated history deleted node(s) ` + + `${[...diff.deleted].sort().join(", ")} — deletions are outside ` + + `PROP-04's input space (module header)`, + ); + } + for (const id of diff.added) { + if ((currentSems.get(id)?.edgeTargets.length ?? 0) > 0) { + throw new Error( + `P-6 harness defect: added node ${id} carries dependency edges ` + + `— added sections must be dependency-free (module header)`, + ); + } + } const label = `P-6 \`impact --base <${snapshot.label}> --json\` — full history: ` + `${history.join("; ") || "no steps"}`; From b6930785a35cc480390d6f2065c5c8f042773cfc Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 16:18:10 +0000 Subject: [PATCH 131/226] sdg(phase-9): add the P-7 literal $-at-capture-boundary forms (FP-086) The 7.5 policy-pattern generators now draw $0 and $<non-digit> as atomic literal tokens and append trailing $ segment-final, so no accidental $<digit 1-9> capture can form; $ joins the capture-side path alphabet. S-6 gains the T7.5-5 literal-$ vector pins for from and to patterns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 28 ++++- test/self/s6-glob-oracle.test.ts | 37 +++++++ test/suite/registry/section-16-p7.ts | 150 +++++++++++++++++++++------ 3 files changed, 180 insertions(+), 35 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 166c972..8d4584b 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3835,12 +3835,38 @@ certify against FP-091's fixtures once those land. 301 passed, unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091).] -- [ ] FP-086 — P-7 generator: add the `$`-at-capture-boundary literal forms +- [x] FP-086 — P-7 generator: add the `$`-at-capture-boundary literal forms (`$0`, `$` before a non-digit, trailing `$`). [R2 #38; TEST-SPEC §16 P-7] `test/suite/registry/section-16-p7.ts` (+ glob-input generator); keep the S-6 glob-oracle suite (`test/self/s6-glob-oracle.test.ts`) green — `test/helpers/oracles/glob.ts` already models patterns; extend vectors if the oracle needs the literal-`$` forms pinned. + [Done 2026-08-29: the 7.5 policy-pattern generators now draw the literal + forms — `$0` and `$<non-digit>` as atomic literal tokens in `from`, `to` + middle, and `to` final segments; trailing `$` appended segment-final + (followed only by `/`, end of pattern, or a `$`-initial capture/ref + token, so no accidental `$<digit 1–9>` can form — invariant argued in + the module header and checked mechanically: scratch probe over 3,135 + generated rules found no from-repeat/absent-ref and, on the real CI + seeds/runs, counted `$0` ×5, `$<non-digit>` ×15, segment-final `$` ×8 + across 23 rules). `$` also became a capture-side PATH byte (an ordinary + literal in the staging discovery globs, SPEC 7.5, so paths still match + themselves; 25 staged sources / 6 targets carried `$` in the CI trials). + Oracle unchanged (already literal-correct); S-6 vectors extended with the + T7.5-5 pins: from-side `a$0.ts`≠`ab.ts`, trailing-`$`≠anchor, `$$1` + literal-then-capture, and to-side `$0`/segment-final-`$` referencing no + absent capture — 29 passed. Verified: typecheck/format clean; + `npm run test:self` 303 passed with the unchanged 2 planned mid-loop + reds (certification-document ×2 → FP-091; S-7 sweep green, so P-7 with + the new generator still fails-as-diagnosed against the stub). Against + the built product, P-7's discovery property passes in full; the capture + property falsifies on the PRE-EXISTING findings-shape red (product emits + old-shape `condition` members vs the form-exact 12.7 decode of FP-001) — + confirmed identical on the pre-change baseline (same seed 271828183, + same diagnosis), a genuine product conformance red for phase 10, not a + harness defect; the pre-shrink falsifying trial carried `$` forms + through config load, `build` exit 0, and `check` exit 1, proving the + `$`-bearing staging sound end-to-end.] - [ ] FP-087 — Implement P-11: availability robustness fuzz over `occurrences`/`view`/`at`. [R2 #14; TEST-SPEC §16 P-11] New §16 registry diff --git a/test/self/s6-glob-oracle.test.ts b/test/self/s6-glob-oracle.test.ts index a30d435..59a4f96 100644 --- a/test/self/s6-glob-oracle.test.ts +++ b/test/self/s6-glob-oracle.test.ts @@ -23,6 +23,11 @@ // empty; whole-pattern left-to-right shortest-match disambiguation with // SPEC.md 7.5's two worked examples; `to` expansion agreement matching // captured bytes literally (7.5; T7.5-5); +// * the `$` forms at the capture boundary — `$0`, `$` before a non-digit, +// a trailing `$` — are literal bytes in `from` and `to` patterns alike, +// never captures, never capture violations: they match exactly the paths +// spelling those bytes, and in a `to` they reference no absent capture +// (7.5; T7.5-5, P-7); // plus misuse guards: a `from` repeating a capture and a `to` referencing an // unvalued capture throw plain errors (harness defects), never diagnosed // product failures. @@ -193,6 +198,38 @@ test("S-6 (7.5): capture wildcards are `$1`…`$9` exactly — `$12` is capture expectCaptures("a$", "a$", {}); }); +test("S-6 (7.5): the literal `$` forms in a `from` match exactly the paths spelling those bytes — never what a capture reading would match (T7.5-5)", () => { + // T7.5-5's worked near-miss: `a$0.ts` matches the file `a$0.ts` and never + // `ab.ts`. + expectCaptures("a$0.ts", "a$0.ts", {}); + expectCaptures("a$0.ts", "ab.ts", null); + // `$` before a non-digit. + expectCaptures("a$x", "a$x", {}); + expectCaptures("a$x", "aQx", null); + // A trailing `$` is a byte to match, not an anchor and not a capture. + expectCaptures("ab$", "ab$", {}); + expectCaptures("ab$", "ab", null); + // A literal `$` directly before a capture: `$$1` is the literal byte `$` + // followed by capture 1 (shortest match grows $1 to "ab" so `.ts` fits). + expectCaptures("$$1.ts", "$ab.ts", { 1: "ab" }); + expectCaptures("$$1.ts", "ab.ts", null); +}); + +test("S-6 (7.5): the literal `$` forms in a `to` reference no capture — the pattern loads and matches exactly its own bytes (T7.5-5)", () => { + // A `to` containing `$0` or ending in `$` references no absent capture + // (SPEC.md 7.5): no misuse throw under an empty capture map, and plain + // byte-literal matching. + expect(matchToPattern("tgt/$0.mdx", "tgt/$0.mdx", values({}))).toBe(true); + expect(matchToPattern("tgt/$0.mdx", "tgt/ab.mdx", values({}))).toBe(false); + expect(matchToPattern("a$/b.mdx", "a$/b.mdx", values({}))).toBe(true); + expect(matchToPattern("a$/b.mdx", "a/b.mdx", values({}))).toBe(false); + expect(matchToPattern("x$y", "x$y", values({}))).toBe(true); + // A literal `$` directly before a referenced capture: `$$1` is the byte + // `$` followed by the captured bytes. + expect(matchToPattern("$$1.mdx", "$a.mdx", values({ 1: "a" }))).toBe(true); + expect(matchToPattern("$$1.mdx", "a.mdx", values({ 1: "a" }))).toBe(false); +}); + test("S-6 (7.5): a capture never matches the empty string", () => { expectCaptures("a$1", "a", null); expectCaptures("$1x", "x", null); diff --git a/test/suite/registry/section-16-p7.ts b/test/suite/registry/section-16-p7.ts index 33c2e18..1e943be 100644 --- a/test/suite/registry/section-16-p7.ts +++ b/test/suite/registry/section-16-p7.ts @@ -5,10 +5,13 @@ // E-5) produce random patterns and paths over SPEC 7's glob grammar and SPEC // 7.5's capture grammar — with the glob metacharacters of common dialects // (`[` `]` `{` `}` `!` `+` `(` `)`) in both the pattern and the path -// alphabets, and `$` as a discovery-glob literal — and assert that the -// product's match decisions and capture values equal the harness's -// independent spec oracle (helpers/oracles/glob.ts, HARNESS-09), which S-6 -// certifies against fixed vectors before any property trusts it +// alphabets, `$` as a discovery-glob literal, and the `$` forms at the +// capture boundary in 7.5 policy patterns (`$0`, `$` before a non-digit, +// trailing `$` — literal bytes in `from` and `to` alike, never captures or +// capture violations; SPEC 7.5, T7.5-5, P-7) — and assert that the product's +// match decisions and capture values equal the harness's independent spec +// oracle (helpers/oracles/glob.ts, HARNESS-09), which S-6 certifies against +// fixed vectors before any property trusts it // (test/self/s6-glob-oracle.test.ts). // // Two properties under the one P-7 entry, one per black-box channel: @@ -70,10 +73,14 @@ // exactly its own bytes — the dot rule reads the pattern as written), so // the generated policy patterns are the trial's only wildcard matching. // For that staging to be sound under SPEC 7 itself, capture-side PATH -// bytes exclude `*`, `?`, and `$` (a source path `t*t/x.ts` would, as its -// own literal glob, legitimately match under `tgt/` and could collide -// with the spec group, 14.14) — the foreign-dialect metacharacters stay, -// probing literal-ness through the config→discovery channel too. Source +// bytes exclude `*` and `?` (a source path `t*t/x.ts` would, as its own +// literal glob, legitimately match under `tgt/` and could collide with +// the spec group, 14.14) — the foreign-dialect metacharacters stay, and +// so does `$`, an ordinary literal in a discovery glob (SPEC 7.5 +// confines captures to policy `files` selectors), so a `$`-bearing path +// still matches exactly itself while the policy patterns meet `$` bytes +// in the paths they judge — probing literal-ness through the +// config→discovery channel too. Source // paths never start with `tgt` (the target namespace: keeps spec and // code groups file-disjoint, 7.2) and never contain `.xspec.` (never a // product-written derived path, 13.4). Targets end in `.mdx` (7.1). @@ -86,8 +93,15 @@ // * A rejected capture-side `to` never references an index absent from its // `from` and a `from` never repeats an index (both 14.14 configuration // errors, not match decisions): capture tokens are injected from a -// managed distinct-index set and `$` is absent from the capture-side -// literal alphabets, so no accidental `$<digit>` can form. +// managed distinct-index set, and `$` enters the policy-pattern literals +// only through three atomic forms — `$0`, `$` fused to a non-digit +// character, and a bare `$` appended as a segment's final token — so no +// accidental `$<digit 1–9>` can form: the fused forms carry no digit +// `1`–`9` after their `$`, and a segment-final `$` is followed in the +// rendered pattern only by `/`, the end of the pattern, or a +// later-spliced capture/reference token, whose rendering starts with `$` +// (making the pair the literal-`$`-before-non-digit form `$$<d>`, a +// literal `$` then a modeled capture — the oracle reads the same bytes). // // The capture-side protocol follows T7.5-4/T7.5-5: `build` first (exit 0 — // sources are valid by construction and build does not evaluate policy, SPEC @@ -196,10 +210,37 @@ const DISCOVERY_PATH_ALPHABET: Weighted = [ [2, "?"], ]; -// Path-segment characters for the capture property: no `*`/`?`/`$`, so the +// Path-segment characters for the capture property: no `*`/`?`, so the // literal path-globs that stage discovery are metacharacter-free and match -// exactly their own path under SPEC 7 (module header). -const CAPTURE_PATH_ALPHABET: Weighted = PATTERN_LITERAL_ALPHABET; +// exactly their own path under SPEC 7 — but `$` is a path byte here: in a +// discovery glob it is an ordinary literal (SPEC 7.5), so a staged path +// still matches exactly itself, while the 7.5 policy patterns then meet `$` +// bytes in the paths they judge (module header). +const CAPTURE_PATH_ALPHABET: Weighted = [...PATTERN_LITERAL_ALPHABET, [2, "$"]]; + +// The `$<non-digit>` literal form draws its fused character from the +// digit-free pattern literals: a capture is exactly `$` followed by one +// digit `1`–`9` (SPEC 7.5), so with every digit excluded the fused pair can +// never spell one (`$0`, the digit form that is still a literal, is its own +// atomic arm below). +const NON_DIGIT_PATTERN_LITERALS: Weighted = PATTERN_LITERAL_ALPHABET.filter( + ([, char]) => char < "0" || char > "9", +); + +/** + * A position-free literal `$` form at the capture boundary (SPEC 7.5: a + * capture is exactly `$` followed by one digit `1`–`9`; every other `$` — + * `$0` and a trailing `$` included — is a literal byte in either pattern, + * never a capture or a capture violation; TEST-SPEC §16 P-7, T7.5-5): `$0`, + * or `$` fused to a non-digit character. Injected as one atomic literal + * token, so no rendering adjacency can turn the `$` into a capture (module + * header). The position-dependent trailing-`$` form is `withTrailingDollar`. + */ +function dollarLiteral(choices: Choices): string { + return choices.boolean(0.4) + ? "$0" + : `$${choices.weightedPick(NON_DIGIT_PATTERN_LITERALS)}`; +} const pathChar = (alphabet: Weighted): Gen<string> => @@ -253,24 +294,53 @@ function renderPattern(segs: readonly PatternSeg[]): string { return segs.map(renderSegment).join("/"); } -/** A token segment of 1..3 tokens over the given literal alphabet. */ -function tokenSegmentGen(literalAlphabet: Weighted): Gen<PatternSeg> { +/** + * A token segment of 1..3 tokens over the given literal alphabet. With + * `dollarForms` (the 7.5 policy-pattern generators), tokens also draw the + * atomic literal `$` forms of `dollarLiteral`. + */ +function tokenSegmentGen( + literalAlphabet: Weighted, + dollarForms = false, +): Gen<PatternSeg> { return (choices) => { const count = choices.intInclusive(1, 3); const tokens: PatternToken[] = []; for (let i = 0; i < count; i += 1) { - tokens.push( - choices.weightedPick<PatternToken>([ - [6, { kind: "lit", text: fillerGen(literalAlphabet, 1, 3)(choices) }], - [3, { kind: "star" }], - [2, { kind: "question" }], - ]), - ); + const entries: Array<readonly [number, PatternToken]> = [ + [6, { kind: "lit", text: fillerGen(literalAlphabet, 1, 3)(choices) }], + [3, { kind: "star" }], + [2, { kind: "question" }], + ]; + if (dollarForms) { + entries.push([2, { kind: "lit", text: dollarLiteral(choices) }]); + } + tokens.push(choices.weightedPick(entries)); } return { kind: "tokens", tokens }; }; } +/** + * With probability 0.15, append the trailing-`$` literal form to a token + * segment (SPEC 7.5: a trailing `$` is a literal byte, never a capture or a + * capture violation; TEST-SPEC §16 P-7, T7.5-5). Appended as the segment's + * final token, the `$` is followed in the rendered pattern only by `/`, the + * end of the pattern, or a later-spliced capture/reference token — whose + * rendering starts with `$`, not a digit — so it can never spell a capture + * (module header); a pattern-final segment stages the genuinely trailing + * form. Applied after `repairPatternSegment`, whose `.`/`..` checks read the + * pre-append rendering. + */ +function withTrailingDollar(seg: PatternSeg, choices: Choices): PatternSeg { + const trailing = choices.boolean(0.15); + if (!trailing || seg.kind === "globstar") return seg; + return { + kind: "tokens", + tokens: [...seg.tokens, { kind: "lit", text: "$" }], + }; +} + /** * Deterministic pattern-segment repairs (module header): `.` and `..` * segments get a leading `q` literal (14.14 outside-root hazard), and — when @@ -702,7 +772,11 @@ function captureIndicesGen(choices: Choices): number[] { /** * A `from` pattern: 1..3 repaired segments with every capture index injected - * exactly once into a token segment (SPEC 7.5: each at most once). + * exactly once into a token segment (SPEC 7.5: each at most once). Its + * segments draw the literal `$` forms — `$0`/`$<non-digit>` in-segment, + * trailing `$` segment-final (P-7, T7.5-5); a capture spliced after a + * trailing `$` renders as the literal-`$`-before-`$` pair `$$<d>` (module + * header). */ function fromPatternSegments( choices: Choices, @@ -713,8 +787,8 @@ function fromPatternSegments( for (let i = 0; i < count; i += 1) { const seg = choices.boolean(0.12) ? ({ kind: "globstar" } as const) - : tokenSegmentGen(PATTERN_LITERAL_ALPHABET)(choices); - segs.push(repairPatternSegment(seg, false)); + : tokenSegmentGen(PATTERN_LITERAL_ALPHABET, true)(choices); + segs.push(withTrailingDollar(repairPatternSegment(seg, false), choices)); } const tokenPositions = segs.flatMap((seg, index) => seg.kind === "tokens" ? [index] : [], @@ -744,7 +818,10 @@ function fromPatternSegments( * A `to` pattern: literal first segment `tgt`, an optional middle segment, * and a final token segment referencing 0..2 of the `from` captures (repeats * allowed) with a forced literal `.mdx` suffix (targets are spec sources, - * SPEC 7.1). + * SPEC 7.1). The middle and final segments draw the literal `$` forms too — + * in a `to`, `$0` and a segment-final `$` reference no absent capture (SPEC + * 7.5, T7.5-5): they are literal bytes, so the pattern loads without 14.14 + * and must match exactly the paths spelling them. */ function toPatternSegments( choices: Choices, @@ -757,9 +834,12 @@ function toPatternSegments( segs.push( choices.boolean(0.25) ? { kind: "globstar" } - : repairPatternSegment( - tokenSegmentGen(PATTERN_LITERAL_ALPHABET)(choices), - false, + : withTrailingDollar( + repairPatternSegment( + tokenSegmentGen(PATTERN_LITERAL_ALPHABET, true)(choices), + false, + ), + choices, ), ); } @@ -777,6 +857,7 @@ function toPatternSegments( ], [2, { kind: "star" }], [1, { kind: "question" }], + [1, { kind: "lit", text: dollarLiteral(choices) }], ]), ); } @@ -1071,11 +1152,12 @@ const P_7 = defineProductTest({ id: "P-7", title: "property: over random patterns and paths (foreign-dialect metacharacters " + - "included), discovery match decisions equal the spec oracle via `ids`, and " + - "policy capture matching — from-match, unique shortest-match capture " + - "values, to-expansion agreement, captures never spanning `/` or matching " + - "empty — equals the oracle via `check` findings (SPEC 7, 7.5; TEST-SPEC " + - "§16 P-7)", + "and the literal `$` capture-boundary forms `$0`/`$<non-digit>`/trailing " + + "`$` included), discovery match decisions equal the spec oracle via " + + "`ids`, and policy capture matching — from-match, unique shortest-match " + + "capture values, to-expansion agreement, captures never spanning `/` or " + + "matching empty — equals the oracle via `check` findings (SPEC 7, 7.5; " + + "TEST-SPEC §16 P-7)", // Wall-clock hang guard only (H-10): two properties over three fixed seeds // (E-5); one workspace and one to three subprocess runs per trial, plus the // shrink budgets on falsification. From 464ec196d50ade7108a60f533a9989093e3f16e2 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 16:36:53 +0000 Subject: [PATCH 132/226] sdg(phase-9): implement P-11, the availability robustness fuzz (FP-087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fuzzed/mutated spec and code sources (P-8's generators, shared via the new FUZZ_BASE_FILES/drawFuzzMutation exports — P-8's trial stream proven byte-identical across the refactor) driven through occurrences, view (with and without --text), and at at random offsets: termination, one complete JSON document, exit 0/1 with 2 only for staged argument errors, the form-exact three-state 12.7 datum decode, and 11.2's finding/unavailability-to-exit iff closed both ways. New registry module + wrapper + H-7 mapping; config never mutated so no unstaged exit-2 can arise. test:self 303 passed, unchanged 2 planned reds (FP-091); P-11 red-as-diagnosed against stub and built product alike. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 35 +- test/suite/registry/index.ts | 2 + test/suite/registry/section-16-p11.ts | 610 ++++++++++++++++++++++++++ test/suite/registry/section-16-p8.ts | 45 +- test/suite/registry/traceability.ts | 1 + test/suite/section-16-p11.test.ts | 9 + 6 files changed, 693 insertions(+), 9 deletions(-) create mode 100644 test/suite/registry/section-16-p11.ts create mode 100644 test/suite/section-16-p11.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 8d4584b..e5d9fc1 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3868,11 +3868,44 @@ certify against FP-091's fixtures once those land. through config load, `build` exit 0, and `check` exit 1, proving the `$`-bearing staging sound end-to-end.] -- [ ] FP-087 — Implement P-11: availability robustness fuzz over +- [x] FP-087 — Implement P-11: availability robustness fuzz over `occurrences`/`view`/`at`. [R2 #14; TEST-SPEC §16 P-11] New §16 registry module + wrapper; H-7 map per the passages its TEST-SPEC entry asserts. Property machinery: `test/helpers/property.ts` (fixed default seed set — see AGENTS.md). + [Done 2026-08-29: test/suite/registry/section-16-p11.ts (+ wrapper, + manifest spread, H-7 entry "P-11": ["11.2", "11.4", "12.7"]). Input + space per the entry's "P-8's generators": section-16-p8.ts now exports + FUZZ_BASE_FILES and drawFuzzMutation (the weighted mutation menu), and + P-11 draws 1–3 mutations over the three SOURCES only — never + xspec.config.ts, which must stay valid so no unstaged 14.14 exit-2 can + arise (P-8's own trial stream proven byte-identical across the refactor + by a pre/post generation diff over the fixed seeds). Per trial, 2–4 + drawn invocations from a 17-entry menu — 7 answer arms (bare/--file/--to + `occurrences`; bare/operand/--file `view`, each side of `--text`; `at` + with offsets drawn over [0, staged byte length]) and 10 + staged-argument-error arms (wrong-kind / unknown / operand-plus---file + `view`; wrong-kind, out-of-range, malformed-spelling `at`; malformed + `--to`; outside-root glob; repeated flag; unknown flag) — asserting: + termination (hang/overflow guards converted to diagnosed failures), no + signal death, exit 0/1 on answer arms with exit 2 only on the staged + errors (which must exit 2 with the 12.7 error document — 11.2: argument + checks precede answering), stdout exactly one complete JSON document, + the form-exact 12.7 decode per surface (three-state datum discipline via + the decoders and their marker walk), and 11.2's iff closed both ways + (any finding or unavailable datum ⟹ exit 1 with the full document + emitted; clean ⟹ exit 0). No staging build (11.2 never-stale: these + surfaces answer from current sources). Seed-coverage dry-run over the + CI-pinned 36 trials: every menu entry, mutation kind, and mutation + target occurs. Verified: typecheck/format clean; `npm run test:self` + 303 passed with the unchanged 2 planned mid-loop reds + (certification-document ×2 → FP-091; S-1 green with the new mapping, + S-7 sweep green so P-11 fails-as-diagnosed against the stub). Against + the built product P-11 falsifies immediately as a genuine phase-10 red: + bare `occurrences` exits 2 — the product implements none of the §11 + surfaces ("unknown command 'occurrences'", probed directly) — shrinking + to a no-op mutation plus bare `occurrences` with the exit-clause + diagnosis (seed 271828183).] - [ ] FP-088 — Implement P-12: `at` ≡ view-derived resolution over every offset; occurrence order/totality equivalence. [R2 #15; TEST-SPEC §16 diff --git a/test/suite/registry/index.ts b/test/suite/registry/index.ts index bc95ff1..9872e06 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -81,6 +81,7 @@ import { section16P7Tests } from "./section-16-p7.js"; import { section16P8Tests } from "./section-16-p8.js"; import { section16P9Tests } from "./section-16-p9.js"; import { section16P10Tests } from "./section-16-p10.js"; +import { section16P11Tests } from "./section-16-p11.js"; export const productTestSuite = new ProductTestSuite([ // Section registration modules are spread here as they are implemented. @@ -152,4 +153,5 @@ export const productTestSuite = new ProductTestSuite([ ...section16P8Tests, ...section16P9Tests, ...section16P10Tests, + ...section16P11Tests, ]); diff --git a/test/suite/registry/section-16-p11.ts b/test/suite/registry/section-16-p11.ts new file mode 100644 index 0000000..fb8c571 --- /dev/null +++ b/test/suite/registry/section-16-p11.ts @@ -0,0 +1,610 @@ +// TEST-SPEC §16 P-11 (availability robustness) — PROP-09. +// +// One registered product-facing fuzz test (C-2 "one code path"): fuzzed and +// mutated spec and code sources — P-8's generators over P-8's base workspace +// (section-16-p8.ts: `FUZZ_BASE_FILES`, `drawFuzzMutation`; TEST-SPEC §16 +// P-11 "P-8's generators — the availability contract is precisely an +// imperfect-input surface") — driven through `occurrences`, `view` (with and +// without `--text`), and `at` at random offsets, asserting per invocation +// exactly the robustness contract P-11 states: +// +// * every invocation terminates — operationalized by the subprocess +// driver's hang guard (helpers/subprocess.ts): a run killed by the +// per-invocation timeout or the runaway-output cap is converted into a +// *diagnosed assertion failure* (H-8), because termination is this +// property's assertion, not merely harness hygiene; +// * stdout is one complete JSON document, never partial — the three +// surfaces are JSON-only (SPEC 11: a single JSON document is the only +// output form, with or without `--json`), so the entire stdout must +// parse as exactly one document on every exit, the 12.7 error document +// (`{"error": …}`) on exit 2 (SPEC 12.0, H-5); +// * the exit is 0 or 1 per 11.2 — 2 only for the trial's deliberately +// staged argument errors, which the 11.2 precedence clause pins to +// exactly exit 2 "whatever findings the workspace or the named files +// carry" (argument checks precede answering); +// * every datum is exactly one of plain value, `null`, or +// `{"unavailable": true}` (SPEC 11.4, 12.7) — asserted by decoding the +// whole answer through the form-exact 12.7 document decoders +// (adapters/forms.ts, H-3), whose per-member three-state decodes and +// whole-document unavailability-marker walk reject any fourth state, +// any omitted member, and any non-marker object spelling `unavailable`; +// * any finding or unavailable datum implies exit 1 with the full +// document emitted — the decode enforces the complete document form — +// and exit 0 implies a finding-free document carrying none: with the +// exit pinned to {0, 1}, the two directions close 11.2's iff (a +// complete, finding-free answer exits 0; imperfection exits 1 and +// never withholds the answer). +// +// Staging: each trial writes the base workspace with 1–3 drawn mutations +// applied to the SOURCES ONLY — `specs/A.mdx`, `specs/B.mdx`, `src/app.ts` — +// never to `xspec.config.ts`. P-11's input space is "fuzzed and mutated spec +// and code sources"; the configuration must stay valid by construction, +// because a configuration error is a 14.14 exit-2 outcome that precedes +// every answer (12.0) and would sit outside the staged-argument-error set +// the exit clause admits. No staging `build` runs and no prior derived state +// exists: the availability surfaces answer from current sources whatever the +// workspace's validity and write nothing on a failing one (SPEC 11.2 "never +// stale"), so the answers under test need no build — and mutations are +// frequently benign, exercising the exit-0 clean side too. +// +// The invocation menu (2–4 drawn arms per trial) spans the three surfaces' +// argument grammar. Answer arms (exit 0/1 expected): bare `occurrences`; +// `occurrences --file <glob>` (set restriction; a glob admitting none admits +// the empty set, 11.3); `occurrences --to <well-formed identity>` (syntactic +// acceptance — unknown and unresolving spellings select nothing, 11.3); bare +// `view`; `view` with operand subsets; `view --file <glob>` (a glob +// admitting only code sources admits the empty set, 11.4) — each with and +// without `--text` — and `at <file> <offset>` with the offset drawn over +// [0, staged byte length] (offset = length resolves to the root, 11.5). +// Staged-argument-error arms (exit 2 + the 12.7 error document expected, +// SPEC 11.2/12.0): a `view` or `at` operand of the wrong kind (a discovered +// code source, 11.4/11.5) or outside the discovered set (12.0); `view` +// operands combined with `--file` (11.4); an out-of-range or malformed +// `<offset>` spelling (11.5: only ASCII decimal digits spell one); a +// malformed `--to` spelling (11.3's well-formedness rules); a `--file` +// pattern resolving outside the workspace root (11.3/11.1, the outside-root +// rule of 7); a repeated flag and an unknown flag (12.0). Discovery is +// path-based (SPEC 7), so mutations never change which files are +// discovered, and the trial knows each arm's error/answer expectation at +// generation time. Offsets and error excesses are drawn against the staged +// bytes at generation time, so replay and shrinking re-derive identical +// invocations (H-10). +// +// An implementation-time dry-run over the committed default seeds at the +// registered 12 runs per seed verified that every menu entry — all seven +// answer arms and all ten staged-error arms — and every mutation kind and +// mutation target occurs across the CI-pinned trial set (E-5), so the fixed +// seeds exercise the full surface deterministically. +// +// P-11 is outside every CERTIFICATIONS.md fixture scope (Exclusions: +// "P-11's imperfect-input classes are broad basins under P-8's mutators … +// its datum-form discipline is certified deterministically through the +// CONF-AVAIL datum-form violators"), so this body binds only to the real +// product surface. + +import { Buffer } from "node:buffer"; +import type { Finding } from "../../helpers/adapters/index.js"; +import { + decodeAtReport, + decodeErrorDocument, + decodeOccurrencesReport, + decodeViewReport, +} from "../../helpers/adapters/index.js"; +import { fail, parseJsonStdout } from "../../helpers/assertions.js"; +import type { Choices, Gen } from "../../helpers/property.js"; +import { checkProperty, listOf } from "../../helpers/property.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; +import { + ProductRunOutputOverflowError, + ProductRunTimeoutError, + runProduct, +} from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { drawFuzzMutation, FUZZ_BASE_FILES } from "./section-16-p8.js"; + +// --------------------------------------------------------------------------- +// The mutable surface: the spec and code sources of the shared fuzz base +// workspace — never the configuration (see the module header). + +const SPEC_SOURCES = ["specs/A.mdx", "specs/B.mdx"] as const; +const CODE_SOURCE = "src/app.ts"; +const MUTATION_TARGETS: readonly string[] = [...SPEC_SOURCES, CODE_SOURCE]; + +// --------------------------------------------------------------------------- +// Argument pools. Simplest entries first (pick shrinks toward the first). + +/** `--file` restrictions over the discovered set (SPEC 11.3, glob rules 7). */ +const OCCURRENCES_FILE_GLOBS: readonly string[] = [ + "specs/*.mdx", + "**", + "src/**", + "nomatch/**", // admits the empty set — an empty, finding-free answer +]; + +/** `--file` restrictions over the view domain (SPEC 11.4). */ +const VIEW_FILE_GLOBS: readonly string[] = [ + "specs/*.mdx", + "specs/**", + "src/**", // admits only code sources — the empty set (11.4) + "nomatch/**", +]; + +/** + * Well-formed `--to` spellings (11.3: acceptance is syntactic; unknown or + * unresolving identities select nothing and are never usage errors). + */ +const WELL_FORMED_TO_TARGETS: readonly string[] = [ + "specs/A.mdx#a", + "specs/A.mdx#a.b", + "specs/B.mdx#b", + "specs/A.mdx", // bare path — a root identity (1.5) + "specs/A.mdx#zz", // no such node — empty selection + "other/Z.mdx#q", // undiscovered file — empty selection +]; + +/** Malformed `--to` spellings (11.3's well-formedness rules; 1.4). */ +const MALFORMED_TO_SPELLINGS: readonly string[] = [ + "a#b#c", // more than one `#` + "#x", // empty path part + "specs/A.mdx#", // `#` with no segment + "specs/A.mdx#a..b", // empty segment + "specs/A.mdx#a b", // whitespace inside a segment (1.4) +]; + +/** + * `<offset>` spellings that are not one-or-more ASCII decimal digits (11.5: + * a sign, whitespace, or any other character is not a non-negative + * integer's spelling; leading zeros ARE permitted, so none appears here). + */ +const MALFORMED_OFFSET_SPELLINGS: readonly string[] = [ + "-1", + "+3", + "1.5", + "0x10", + " 7", + "seven", + "", +]; + +/** The established outside-root pattern staging (T11-2's spelling). */ +const OUTSIDE_ROOT_GLOB = "../*.mdx"; + +// --------------------------------------------------------------------------- +// Trial generation + +/** One drawn invocation with its generation-time expectation. */ +export interface AvailabilityArm { + readonly argv: readonly string[]; + /** Which 12.7 document form an answer decodes through. */ + readonly surface: "occurrences" | "view" | "at"; + /** view only: whether `--text` is among the arguments (12.7 text members). */ + readonly text: boolean; + /** + * A deliberately staged argument error: expect exit 2 with the 12.7 error + * document (SPEC 11.2: argument checks precede answering). Answer arms + * expect exit 0 or 1 with the surface's full document. + */ + readonly stagedError: boolean; +} + +/** One generated trial: staged bytes, the mutation log, and drawn arms. */ +export interface AvailabilityTrial { + /** Staged bytes per workspace-relative path (base files + mutations). */ + readonly files: ReadonlyArray<readonly [string, Uint8Array]>; + /** Human-readable description of each applied mutation. */ + readonly mutations: readonly string[]; + /** Drawn invocations, run in order. */ + readonly arms: readonly AvailabilityArm[]; +} + +type ArmBuilder = ( + choices: Choices, + staged: ReadonlyMap<string, Uint8Array>, +) => AvailabilityArm; + +function stagedLength( + staged: ReadonlyMap<string, Uint8Array>, + path: string, +): number { + const bytes = staged.get(path); + if (bytes === undefined) { + throw new Error(`P-11 harness defect: no staged bytes for ${path}`); + } + return bytes.length; +} + +const answerArm = ( + surface: AvailabilityArm["surface"], + argv: readonly string[], + text = false, +): AvailabilityArm => ({ argv, surface, text, stagedError: false }); + +const errorArm = ( + surface: AvailabilityArm["surface"], + argv: readonly string[], +): AvailabilityArm => ({ argv, surface, text: false, stagedError: true }); + +/** + * The invocation menu (see the module header). Weighted toward the answer + * arms — the property's heart is the answer contract; the staged-error arms + * pin the "2 only for staged argument errors" boundary — and ordered + * simplest-first (weightedPick shrinks toward the first entry). + */ +const ARM_MENU: ReadonlyArray<readonly [number, ArmBuilder]> = [ + // --- answer arms (exit 0/1 per 11.2) --- + [4, () => answerArm("occurrences", ["occurrences"])], + [ + 3, + (c) => + answerArm("occurrences", [ + "occurrences", + "--file", + c.pick(OCCURRENCES_FILE_GLOBS), + ]), + ], + [ + 3, + (c) => + answerArm("occurrences", [ + "occurrences", + "--to", + c.pick(WELL_FORMED_TO_TARGETS), + ]), + ], + [ + 4, + (c) => { + const text = c.boolean(); + return answerArm("view", text ? ["view", "--text"] : ["view"], text); + }, + ], + [ + 3, + (c) => { + const operands = c.pick<readonly string[]>([ + [SPEC_SOURCES[0]], + [SPEC_SOURCES[1]], + [...SPEC_SOURCES], + ]); + const text = c.boolean(); + return answerArm( + "view", + text ? ["view", ...operands, "--text"] : ["view", ...operands], + text, + ); + }, + ], + [ + 3, + (c) => { + const glob = c.pick(VIEW_FILE_GLOBS); + const text = c.boolean(); + return answerArm( + "view", + text ? ["view", "--file", glob, "--text"] : ["view", "--file", glob], + text, + ); + }, + ], + [ + 4, + (c, staged) => { + const file = c.pick(SPEC_SOURCES); + // Every within-file offset resolves, and offset = byte length is the + // end-of-file caret resolving to the root (SPEC 11.5). + const offset = c.intInclusive(0, stagedLength(staged, file)); + return answerArm("at", ["at", file, String(offset)]); + }, + ], + // --- staged argument errors (exit 2 per 11.2/12.0) --- + [1, () => errorArm("view", ["view", CODE_SOURCE])], // wrong-kind operand (11.4) + [1, () => errorArm("view", ["view", "specs/None.mdx"])], // unknown file (12.0) + [ + 2, + () => errorArm("view", ["view", SPEC_SOURCES[0], "--file", "specs/*.mdx"]), // operands + --file (11.4) + ], + [1, () => errorArm("at", ["at", CODE_SOURCE, "0"])], // wrong-kind operand (11.5) + [ + 2, + (c, staged) => { + const file = c.pick(SPEC_SOURCES); + const excess = 1 + c.intInclusive(0, 8); + return errorArm("at", [ + "at", + file, + String(stagedLength(staged, file) + excess), // out of range (11.5) + ]); + }, + ], + [ + 1, + (c) => + errorArm("at", [ + "at", + SPEC_SOURCES[0], + c.pick(MALFORMED_OFFSET_SPELLINGS), // not a non-negative integer's spelling (11.5) + ]), + ], + [ + 1, + (c) => + errorArm("occurrences", [ + "occurrences", + "--to", + c.pick(MALFORMED_TO_SPELLINGS), // malformed identity spelling (11.3) + ]), + ], + [ + 1, + (c) => { + const surface = c.pick(["occurrences", "view"] as const); + return errorArm(surface, [surface, "--file", OUTSIDE_ROOT_GLOB]); // outside root (11.3/11.1, 7) + }, + ], + [ + 1, + () => + errorArm("occurrences", [ + "occurrences", + "--file", + "specs/*.mdx", + "--file", + "src/**", // repeated flag (12.0) + ]), + ], + [1, () => errorArm("view", ["view", "--frobnicate"])], // unknown flag (12.0) +]; + +/** The P-11 trial generator (see the module header). */ +export const genAvailabilityTrial: Gen<AvailabilityTrial> = (choices) => { + const files = new Map<string, Uint8Array>( + FUZZ_BASE_FILES.map(([path, text]) => [ + path, + Uint8Array.from(Buffer.from(text, "utf8")), + ]), + ); + const mutations: string[] = []; + const mutationCount = 1 + choices.intInclusive(0, 2); + for (let i = 0; i < mutationCount; i += 1) { + const path = choices.pick(MUTATION_TARGETS); + const current = files.get(path); + if (current === undefined) { + throw new Error(`P-11 harness defect: no staged bytes for ${path}`); + } + const result = drawFuzzMutation(choices, current, path); + files.set(path, result.bytes); + mutations.push(`${path}: ${result.description}`); + } + const arms = listOf((c: Choices) => c.weightedPick(ARM_MENU)(c, files), { + min: 2, + max: 4, + })(choices); + return { files: [...files.entries()], mutations, arms }; +}; + +/** Counterexample rendering: the mutation log and the drawn invocations. */ +export function renderAvailabilityTrial(trial: AvailabilityTrial): string { + return JSON.stringify({ + mutations: trial.mutations, + arms: trial.arms.map( + (arm) => + `${arm.argv.join(" ")}${arm.stagedError ? " [staged argument error]" : ""}`, + ), + }); +} + +// --------------------------------------------------------------------------- +// Assertions + +/** + * Per-invocation hang guard. Purely the H-8 guard bounding the observation + * "the invocation terminates" — never an assertion input beyond that (H-10); + * generously above any plausible answer time for these staged inputs and + * small enough that a falsified termination clause shrinks in budget. + */ +const FUZZ_COMMAND_TIMEOUT_MS = 10_000; + +/** + * Run one availability invocation, converting the hang-guard and + * runaway-output kills — exactly those — into diagnosed assertion failures: + * P-11's first clause is that every invocation terminates. Anything else + * thrown by the driver stays a harness error (H-8). + */ +async function runAvailabilityCommand( + product: ProductBinding, + workspace: TestWorkspace, + argv: readonly string[], +): Promise<RunResult> { + try { + return await runProduct(product, { + cwd: workspace.root, + argv, + timeoutMs: FUZZ_COMMAND_TIMEOUT_MS, + }); + } catch (error) { + if (error instanceof ProductRunTimeoutError) { + fail( + `P-11: every invocation of the availability surfaces must terminate ` + + `on fuzzed sources (TEST-SPEC §16 P-11; SPEC 11.2, 12.0), but the ` + + `invocation was still running when the harness's hang guard killed ` + + `it — ${error.message}`, + ); + } + if (error instanceof ProductRunOutputOverflowError) { + fail( + `P-11: every invocation must terminate with bounded output — one ` + + `complete JSON document (TEST-SPEC §16 P-11; SPEC 11, 12.0) — but ` + + `the invocation emitted unbounded output until the harness's ` + + `runaway-output guard killed it — ${error.message}`, + ); + } + throw error; + } +} + +/** + * Does the raw parsed document carry any explicitly-unavailable datum? The + * form decode has already run `assertUnavailabilityMarkerForms` over the + * whole document (adapters/forms.ts), so every object spelling a member + * named `unavailable` is exactly the marker `{"unavailable": true}` + * (SPEC 12.7) — presence of the member is presence of the marker. + */ +function documentCarriesUnavailability(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some((element) => documentCarriesUnavailability(element)); + } + if (typeof value !== "object" || value === null) return false; + const obj = value as Record<string, unknown>; + if (Object.hasOwn(obj, "unavailable")) return true; + return Object.values(obj).some((member) => + documentCarriesUnavailability(member), + ); +} + +/** Decode an answer through its surface's form-exact 12.7 decoder (H-3). */ +function decodeAnswer( + doc: unknown, + arm: AvailabilityArm, + context: string, +): readonly Finding[] { + switch (arm.surface) { + case "occurrences": + return decodeOccurrencesReport(doc, context).findings; + case "view": + return decodeViewReport(doc, { text: arm.text }, context).findings; + case "at": + return decodeAtReport(doc, context).findings; + } +} + +/** + * Run one drawn invocation with the P-11 assertions: termination (via + * `runAvailabilityCommand`), no signal death, the exit clause, one complete + * JSON document as the entire stdout, the form-exact three-state decode, + * and the finding/unavailability ⟷ exit correspondence of 11.2. + */ +async function runAvailabilityArm( + product: ProductBinding, + workspace: TestWorkspace, + arm: AvailabilityArm, + trial: AvailabilityTrial, +): Promise<void> { + const context = + `P-11 \`xspec ${arm.argv.join(" ")}\` over the fuzzed workspace ` + + `(mutations: ${JSON.stringify(trial.mutations)})`; + const result = await runAvailabilityCommand(product, workspace, arm.argv); + if (result.signal !== null) { + fail( + `${context}: ${result.commandLine} died by signal ` + + `${String(result.signal)} instead of exiting — SPEC 12.0 partitions ` + + `all outcomes into exit codes 0, 1, and 2 (P-11)`, + ); + } + if (arm.stagedError) { + if (result.exitCode !== 2) { + fail( + `${context}: this staged argument error must exit 2 — the argument ` + + `checks of 11.3–11.5 precede answering, "whatever findings the ` + + `workspace or the named files carry" (SPEC 11.2, 12.0) — got exit ` + + `${String(result.exitCode)}`, + ); + } + // JSON output is in effect (a JSON-only surface, SPEC 11/12.0): the + // entire stdout is the single 12.7 error document, decoded form-exactly. + decodeErrorDocument( + parseJsonStdout( + result, + `${context} — an exit-2 invocation of a JSON-only surface emits the ` + + `12.7 error document as its entire stdout (SPEC 12.0, H-5)`, + ), + context, + ); + return; + } + if (result.exitCode !== 0 && result.exitCode !== 1) { + fail( + `${context}: exit ${String(result.exitCode)} — an availability answer ` + + `exits 0 or 1; exit 2 arises only from usage and configuration ` + + `errors, none of which this invocation stages (SPEC 11.2, 12.0; ` + + `P-11: "2 only for staged argument errors")`, + ); + } + // One complete JSON document as the entire stdout (SPEC 11, 12.0; a + // partial or concatenated document fails its own parse), then the + // form-exact 12.7 decode: member names literal, every datum exactly one + // of plain value / null / {"unavailable": true} (SPEC 11.4, 12.7; H-3) — + // the full document, so exit 1 demonstrably never withholds the answer. + const doc = parseJsonStdout(result, context); + const findings = decodeAnswer(doc, arm, context); + const carriesUnavailability = documentCarriesUnavailability(doc); + if (findings.length > 0 || carriesUnavailability) { + if (result.exitCode !== 1) { + fail( + `${context}: the answer carries ${String(findings.length)} ` + + `finding(s)${carriesUnavailability ? " and explicitly-unavailable data" : ""} ` + + `yet exited ${String(result.exitCode)} — any finding or ` + + `unavailable datum implies exit 1, with the full document still ` + + `emitted (SPEC 11.2; P-11)`, + ); + } + return; + } + if (result.exitCode !== 0) { + fail( + `${context}: the answer is complete and finding-free — no finding, no ` + + `explicitly-unavailable datum — yet exited ` + + `${String(result.exitCode)}; a complete, finding-free answer exits 0 ` + + `(SPEC 11.2; P-11)`, + ); + } +} + +/** The P-11 property body for one trial (see the module header). */ +async function runAvailabilityTrial( + product: ProductBinding, + trial: AvailabilityTrial, +): Promise<void> { + const workspace = await TestWorkspace.create({ + files: Object.fromEntries(trial.files), + }); + try { + for (const arm of trial.arms) { + await runAvailabilityArm(product, workspace, arm, trial); + } + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// The registered fuzz test + +const P_11 = defineProductTest({ + id: "P-11", + title: + "fuzz: over byte-mutated spec and code sources, `occurrences`, `view` " + + "(with and without --text), and `at` at random offsets always terminate, " + + "emit one complete JSON document, exit 0 or 1 (2 only for staged " + + "argument errors), answer in the three-state 12.7 datum forms, and exit " + + "1 exactly when the answer carries a finding or an unavailable datum " + + "(SPEC 11.2, 11.4, 12.7; TEST-SPEC §16 P-11)", + // Wall-clock hang guard only (H-10): three fixed seeds (E-5), a 2–4 + // invocation sweep per trial with no staging build, plus the shrink budget + // on falsification. + timeoutMs: 420_000, + run: async (product) => { + await checkProperty( + "P-11 availability robustness", + genAvailabilityTrial, + async (trial) => { + await runAvailabilityTrial(product, trial); + }, + { runs: 12, maxShrinkExecutions: 100, render: renderAvailabilityTrial }, + ); + }, +}); + +/** TEST-SPEC §16 P-11 (PROP-09). */ +export const section16P11Tests: readonly ProductTestEntry[] = [P_11]; diff --git a/test/suite/registry/section-16-p8.ts b/test/suite/registry/section-16-p8.ts index d4c7b1c..13662d1 100644 --- a/test/suite/registry/section-16-p8.ts +++ b/test/suite/registry/section-16-p8.ts @@ -79,6 +79,12 @@ // P-8 is outside every CERTIFICATIONS.md fixture scope (its preamble: "P-8 // sweeps every command, exceeding any narrow conformer scope"), so this body // binds only to the real product surface. +// +// Shared machinery: P-11 (availability robustness, section-16-p11.ts) is +// specified over "P-8's generators" (TEST-SPEC §16 P-11), so the base +// workspace (`FUZZ_BASE_FILES`) and the mutation menu (`drawFuzzMutation`) +// are exported and drawn by both properties — one input-space definition, +// two command surfaces. import { Buffer } from "node:buffer"; import { assertJsonOutputConvention, fail } from "../../helpers/assertions.js"; @@ -153,15 +159,22 @@ const BASE_CODE = [ "", ].join("\n"); -/** The mutable surface: exactly the files whose bytes trials fuzz. */ -const BASE_FILES: ReadonlyArray<readonly [string, string]> = [ +/** + * The fuzz base workspace: exactly the files whose bytes P-8's trials fuzz + * (P-11 mutates the three sources only, never the configuration — see + * section-16-p11.ts). SPEC-valid by construction; shared per TEST-SPEC §16 + * P-11 ("P-8's generators"). + */ +export const FUZZ_BASE_FILES: ReadonlyArray<readonly [string, string]> = [ ["xspec.config.ts", BASE_CONFIG], ["specs/A.mdx", BASE_SPEC_A], ["specs/B.mdx", BASE_SPEC_B], ["src/app.ts", BASE_CODE], ]; -const MUTATION_TARGETS: readonly string[] = BASE_FILES.map(([path]) => path); +const MUTATION_TARGETS: readonly string[] = FUZZ_BASE_FILES.map( + ([path]) => path, +); // --------------------------------------------------------------------------- // The command menu (SPEC 12 surface). Every entry is drawn by trials; the @@ -292,7 +305,7 @@ function renderBytes(sequence: readonly number[]): string { } /** One mutation: new bytes plus a human-readable description for the log. */ -interface MutationResult { +export interface MutationResult { readonly bytes: Uint8Array; readonly description: string; } @@ -463,6 +476,23 @@ const MUTATION_KINDS: ReadonlyArray<readonly [number, Mutator]> = [ [2, (c, b) => mutateGarbage(c, b)], ]; +/** + * Draw one mutation from the weighted menu (the module header's full input + * classes of P-8) and apply it to the given bytes: one weightedPick for the + * kind, then the kind's own parameter draws — all through `choices`, so + * identical tapes re-derive identical staged bytes on replay and during + * shrinking (H-10). The one mutation-drawing entry point shared with P-11 + * (TEST-SPEC §16 P-11: "P-8's generators"). + */ +export function drawFuzzMutation( + choices: Choices, + bytes: Uint8Array, + path: string, +): MutationResult { + const mutate = choices.weightedPick(MUTATION_KINDS); + return mutate(choices, bytes, path); +} + // --------------------------------------------------------------------------- // Trial generation @@ -479,7 +509,7 @@ export interface FuzzTrial { /** The P-8 trial generator (see the module header). */ export const genFuzzTrial: Gen<FuzzTrial> = (choices) => { const files = new Map<string, Uint8Array>( - BASE_FILES.map(([path, text]) => [ + FUZZ_BASE_FILES.map(([path, text]) => [ path, Uint8Array.from(Buffer.from(text, "utf8")), ]), @@ -488,12 +518,11 @@ export const genFuzzTrial: Gen<FuzzTrial> = (choices) => { const mutationCount = 1 + choices.intInclusive(0, 2); for (let i = 0; i < mutationCount; i += 1) { const path = choices.pick(MUTATION_TARGETS); - const mutate = choices.weightedPick(MUTATION_KINDS); const current = files.get(path); if (current === undefined) { throw new Error(`P-8 harness defect: no staged bytes for ${path}`); } - const result = mutate(choices, current, path); + const result = drawFuzzMutation(choices, current, path); files.set(path, result.bytes); mutations.push(`${path}: ${result.description}`); } @@ -628,7 +657,7 @@ async function runFuzzTrial( trial: FuzzTrial, ): Promise<void> { const workspace = await TestWorkspace.create({ - files: Object.fromEntries(BASE_FILES), + files: Object.fromEntries(FUZZ_BASE_FILES), }); try { // Staging: the base workspace is SPEC-valid; a successful build leaves diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 3850709..c7a4168 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -438,4 +438,5 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "P-8": ["12.0", "12.1"], "P-9": ["10.1", "10.4", "10.7"], "P-10": ["6.1", "13.5"], + "P-11": ["11.2", "11.4", "12.7"], }; diff --git a/test/suite/section-16-p11.test.ts b/test/suite/section-16-p11.test.ts new file mode 100644 index 0000000..16e73ce --- /dev/null +++ b/test/suite/section-16-p11.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §16 P-11 (PROP-09): thin Vitest wrapper over the registered +// fuzz test — the identical body the certification runner executes against +// fixture products (C-2 "one code path"). Expected to fail as a diagnosed +// assertion failure until the product exists (H-8). + +import { declareProductTests } from "./declare.js"; +import { section16P11Tests } from "./registry/section-16-p11.js"; + +declareProductTests(section16P11Tests); From dba5be99ffb90a4580dbd8d7cf52102692ac59c8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 16:56:26 +0000 Subject: [PATCH 133/226] sdg(phase-9): implement P-12, the at-view/occurrence-order equivalences (FP-088) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 36 +- test/suite/registry/index.ts | 2 + test/suite/registry/section-16-p12.ts | 596 ++++++++++++++++++++++++++ test/suite/registry/traceability.ts | 1 + test/suite/section-16-p12.test.ts | 9 + 5 files changed, 643 insertions(+), 1 deletion(-) create mode 100644 test/suite/registry/section-16-p12.ts create mode 100644 test/suite/section-16-p12.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index e5d9fc1..4d15c6c 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3907,9 +3907,43 @@ certify against FP-091's fixtures once those land. to a no-op mutation plus bare `occurrences` with the exit-clause diagnosis (seed 271828183).] -- [ ] FP-088 — Implement P-12: `at` ≡ view-derived resolution over every +- [x] FP-088 — Implement P-12: `at` ≡ view-derived resolution over every offset; occurrence order/totality equivalence. [R2 #15; TEST-SPEC §16 P-12] + [Done 2026-08-29: test/suite/registry/section-16-p12.ts (+ wrapper, + manifest spread, H-7 entry "P-12": ["5.7", "11.5"]). Product-vs-product + equivalences, no independent oracle (per CERTIFICATIONS.md's P-12 + exclusion note): the comparator is `resolveAtFromView`, imported from + section-11.5.ts where T11.5-1 proves it against precomputed constants. + Input space: compact spec-only workspaces (1–3 files, a constant anchor + section `t` per file so resolving references always have a target, + nested sections to depth 2, multi-byte prose, imports blank-line + separated per the FP-094 lesson, `d` refs and embeddings in + resolving/maybe/never spellings), plus one optional twist — duplicate-id + (both bearers' identities unavailable; a resolving `d={"t"}` occurrence + with source explicitly unavailable, carried identically by view, + enumeration, and at) or break-parse (masked file: no view entry, at must + answer the unavailability marker at every offset). Per trial: one bare + `view`; `occurrences` twice (stdout byte-identical + one exit, 5.7/12.0 + determinism); duplicate-span-freedom on both sides (5.7 "identical + ranges do not occur", which also makes the sort key total); enumeration + == view-collected records sorted by file path bytes, start, end (both + sides through the same form-exact 12.7 record decode); then `at` for + EVERY file at EVERY offset 0..byte length vs the view-derived + resolution; every answer exits 0/1 (no staged usage error exists). + 9 CI-pinned trials (runs 3 × 3 default seeds, ~1470 at invocations); + an implementation-time dry-run over the committed seeds verified every + twist kind, multi-file workspaces, imports, embeddings, d props, + external refs, and multi-byte prose all occur, and every staged source + parses under remark-mdx except exactly the break-parse files (which + fail to parse). Verified: typecheck/format clean; `npm run test:self` + 303 passed with the unchanged 2 planned mid-loop reds + (certification-document ×2 → FP-091; S-1 green with the new mapping, + S-7 sweep green so P-12 fails-as-diagnosed against the empty stub). + Against the built product P-12 falsifies immediately as a genuine + phase-10 red: bare `view` exits 2 ("unknown command 'view'" — the + product implements none of the §11 surfaces), shrinking in 5 steps to + the minimal one-anchor-file workspace (seed 271828183).] - [ ] FP-089 — Implement the P-13 coverage-reachability oracle (independent SPEC 8.1/8 reachability) + its S-6 vetted suite (vectors per SPEC 15). diff --git a/test/suite/registry/index.ts b/test/suite/registry/index.ts index 9872e06..91ee536 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -82,6 +82,7 @@ import { section16P8Tests } from "./section-16-p8.js"; import { section16P9Tests } from "./section-16-p9.js"; import { section16P10Tests } from "./section-16-p10.js"; import { section16P11Tests } from "./section-16-p11.js"; +import { section16P12Tests } from "./section-16-p12.js"; export const productTestSuite = new ProductTestSuite([ // Section registration modules are spread here as they are implemented. @@ -154,4 +155,5 @@ export const productTestSuite = new ProductTestSuite([ ...section16P9Tests, ...section16P10Tests, ...section16P11Tests, + ...section16P12Tests, ]); diff --git a/test/suite/registry/section-16-p12.ts b/test/suite/registry/section-16-p12.ts new file mode 100644 index 0000000..86b4ded --- /dev/null +++ b/test/suite/registry/section-16-p12.ts @@ -0,0 +1,596 @@ +// TEST-SPEC §16 P-12 (at ≡ view; occurrence order) — PROP-10. +// +// One registered product-facing property test (C-2 "one code path"): a +// seeded, reproducible generator (helpers/property.ts, H-10; fixed seed set +// in CI, E-5) produces small random spec-only workspaces — 1–3 `.mdx` spec +// sources with nested sections, prose (multi-byte spellings included, so +// byte offsets diverge from code-point and UTF-16 counts, SPEC 1.7), MDX +// comments, blank lines, an optional import of the first file, `d` +// references, and `{text(...)}` embeddings, in resolving, maybe-resolving, +// and never-resolving spellings — and asserts, per trial, exactly the two +// equivalences P-12 states: +// +// * **at ≡ view.** For EVERY file and EVERY offset 0…byte length, `at`'s +// resolution — section identity, construct range, containing occurrence +// — equals the resolution computed from that file's per-file entry of +// one bare `view` answer alone (SPEC 11.5: "the same resolution is +// derivable from the view's data alone … `at` adds convenience, not +// information"): the innermost containing section construct by range +// containment over the view's positional tree — the root where none +// contains the offset, the EOF caret included — and the containing +// occurrence record, via `resolveAtFromView`, imported from +// registry/section-11.5.ts (T11.5-1), where the comparator is proven +// against T11.5-1's precomputed fixture tree and pointwise constants +// before any product invocation — P-12's anchor (TEST-SPEC §16 +// preamble; CERTIFICATIONS.md's P-12 exclusion note: "its comparator is +// computed from the product's own `view` answers, anchored by T11.5-1's +// precomputed fixture, so there is no independent oracle to mis-trust"). +// A requested file the view answer carries no entry for (the masked +// case, 14.20: an unparseable requested file contributes no view) must +// resolve to exactly the unavailability marker at every offset (SPEC +// 11.5, 11.2, 12.7; T11.5-3's deterministic arm generalized). +// * **Occurrence order.** The workspace-wide bare `occurrences` +// enumeration equals the view-collected occurrence records — the +// concatenation of every per-file view's `occurrences` member — sorted +// by referencing file path bytes, then range start, then range end +// (SPEC 5.7: occurrence order is total and deterministic): totality and +// order in one array equality, over records decoded through the same +// form-exact 12.7 record decode on both sides (H-3). Duplicate-freedom +// is asserted first-class on both sides: distinct occurrences are +// distinct spellings occupying distinct spans, so identical +// (file, range) spans do not occur (5.7) — which also makes the sort +// key total, no further tiebreak existing. And the enumeration is +// byte-identical across runs: a second identical invocation's entire +// stdout equals the first's byte-for-byte (5.7, SPEC 12.0 +// byte-determinism for identical input). +// +// Both equivalences compare the product with itself (H-4): no harness +// oracle predicts identities, ranges, occurrences, or resolution — the +// deterministic §11 tests pin pointwise correctness; P-12 searches the +// input space for inconsistency between the three surfaces. +// +// Input space. Workspaces are valid-leaning but not validity-bound: the +// configuration is constant and valid by construction (a configuration +// error is a 14.14 exit-2 outcome preceding every answer, outside P-12's +// subject), file paths are fixed valid spellings, and every staged argument +// is well-formed with offsets in 0…byte length — so no invocation stages a +// usage error and every answer exits 0 or 1 (SPEC 11.2: these surfaces +// answer per file whatever findings the workspace carries; argument checks +// alone exit 2). Reference spellings may resolve (`"t"` — every file's +// constant anchor section; `M0.t` through the drawn import), maybe-resolve +// (`"s1"`), or never resolve (`"zz"`), so answers are exercised on both +// exit sides with and without findings. One optional per-trial twist +// appends imperfection to one file: +// +// * `duplicate-id` — two appended sections both spelling `dd`, the first +// carrying `d={"t"}`: both bearers' identities are undefined (11.2, +// uniqueness), so the view reports their `identity` as the +// unavailability marker and `at` must agree at every offset inside +// them; the `d` reference still resolves and records an occurrence +// whose source datum is explicitly unavailable as one datum (5.7, +// 11.2) — carried identically by the view, the enumeration, and the +// containing-occurrence side of `at`. +// * `break-parse` — an appended unclosed section tag: the file is +// unparseable (14.20), contributes no view entry, and `at` must report +// the unavailability marker at every offset (11.2, 11.5). +// +// Rendering discipline (parseable by construction outside `break-parse`): +// section tags, comments, and prose are own-line constructs joined by +// single newlines (the T11.5-1/P-4 style — MDX flow JSX interrupts a +// paragraph, so glued tags stay flow constructs), while the import is +// followed by a mandatory blank line (an MDX ESM block extends to the next +// blank line and cannot interrupt a paragraph — the FP-094 hazard); +// embeddings are glued mid-line behind non-empty prose; prose draws from a +// fixed MDX-safe pool (alphanumeric line starts; no `<`, `>`, `{`, `}`, +// backtick, `~`, `&`, `\`), with multi-byte entries (é, à, —) shifting +// every later offset (SPEC 1.7). +// +// Cost shape: the at ≡ view clause is exhaustive per trial (sum of file +// byte lengths + one EOF caret per file `at` invocations — "reachability is +// total by construction", CERTIFICATIONS.md), so the generator keeps files +// small and the trial count low (`runs: 3` × the 3 default seeds = 9 +// CI-pinned trials), with the shrink budget sized against whole-trial +// re-execution cost. An implementation-time dry-run over the committed +// default seeds at these 9 trials verified: every twist kind occurs (none +// ×4, duplicate-id ×3, break-parse ×2), multi-file workspaces, imports, +// embeddings, `d` props, external references, and multi-byte prose all +// occur, ~1470 `at` invocations total across the set, and every staged +// source parses under remark-mdx exactly except the break-parse files, +// which fail to parse (E-5: the fixed seeds exercise the full surface +// deterministically). The `view` invocation runs first, so a product +// without the §11 surfaces (the stub, S-7) fails immediately and cheaply, +// and shrinking stays fast in the red phase (H-8). +// +// P-12 is expressly outside every CERTIFICATIONS.md fixture scope (its +// Exclusions name P-12 directly), so this body binds only to the real +// product surface. + +import { Buffer } from "node:buffer"; +import type { + FileView, + OccurrenceRecord, + PathValue, +} from "../../helpers/adapters/index.js"; +import { + decodeAtReport, + decodeOccurrencesReport, + decodeViewReport, +} from "../../helpers/adapters/index.js"; +import { fail, parseJsonStdout } from "../../helpers/assertions.js"; +import type { Choices, Gen } from "../../helpers/property.js"; +import { checkProperty } from "../../helpers/property.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; +import { runProduct } from "../../helpers/subprocess.js"; +import type { TestWorkspace as Workspace } from "../../helpers/workspace.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { SPECS_ONLY_CONFIG } from "./section-11.2.js"; +import type { ResolutionData } from "./section-11.5.js"; +import { resolveAtFromView } from "./section-11.5.js"; +import { assertSameJson } from "./support.js"; + +const UNAVAILABLE = { unavailable: true } as const; + +// --------------------------------------------------------------------------- +// Generation: file pool, content pools, per-file builder, twists. + +/** Fixed valid paths in byte order (the 5.7 file-order sort is exercised). */ +const FILE_POOL = ["specs/A.mdx", "specs/B.mdx", "specs/C.mdx"] as const; + +/** The drawn import (files after the first only): binds the first file. */ +const IMPORT_LINE = 'import M0 from "./A.xspec"'; + +/** + * MDX-safe prose lines (module header): each starts alphanumeric and spells + * no structural character; the multi-byte entries (é 2 bytes, à 2 bytes, + * — 3 bytes) shift every later byte offset (SPEC 1.7). Simplest first + * (pick shrinks toward the first entry). + */ +const PROSE_POOL = [ + "mot.", + "fin brève.", + "ligne bàsique 7.", + "texte — étendu.", +] as const; + +/** Mid-line tails glued after an embedding (safe interior characters). */ +const TAIL_POOL = [" fin.", " — suite."] as const; + +/** Own-line MDX comment interiors (no slash, no star). */ +const COMMENT_POOL = ["note", "à voir"] as const; + +/** + * Embedding argument spellings (SPEC 2.3, 2.4 static forms). `"t"` always + * resolves (the constant anchor section below); `'t'` is a spelling variant + * of the same target; `"s1"` resolves exactly when the file drew a + * top-level extra section (maybe); `"zz"` never resolves — an unresolved + * spelling records no occurrence and reports its own finding (5.7, 11.2). + * `M0.t` (external, resolving) joins the menu where the import was drawn. + */ +function embedArgumentMenu(hasImport: boolean): readonly string[] { + const local = ['"t"', "'t'", '"s1"', '"zz"'] as const; + return hasImport ? [...local, "M0.t"] : local; +} + +/** + * Opening-tag `d` prop spellings (SPEC 2.2), `""` = prop omitted. Entries + * of a `d` array record occurrences separately (5.7); the mixed arrays + * exercise resolving and non-resolving entries side by side. + */ +function dPropMenu( + hasImport: boolean, +): ReadonlyArray<readonly [number, string]> { + const entries: (readonly [number, string])[] = [ + [5, ""], + [2, ' d={"t"}'], + [1, ' d={["t", "s1"]}'], + [1, ' d={["t", "zz"]}'], + ]; + if (hasImport) entries.push([1, " d={M0.t}"]); + return entries; +} + +/** One generated workspace and the twist applied to it. */ +export interface P12Trial { + /** Staged content per workspace-relative path, in FILE_POOL order. */ + readonly files: ReadonlyArray<readonly [string, string]>; + /** Human-readable twist description (`"none"` when none applied). */ + readonly twist: string; +} + +/** + * One file's lines (joined by single newlines; module header discipline). + * The constant anchor section `t` opens every file, so the resolving + * reference spellings above always have a target, in-file and cross-file. + */ +function genFileLines(choices: Choices, hasImport: boolean): string[] { + const lines: string[] = []; + if (hasImport) { + lines.push(IMPORT_LINE); + lines.push(""); // mandatory blank line: the ESM block must end (FP-094) + } + lines.push('<S id="t">'); + lines.push(choices.pick(PROSE_POOL)); + lines.push("</S>"); + + let seg = 1; + const nextSeg = (): string => { + const name = `s${String(seg)}`; + seg += 1; + return name; + }; + const emitProse = (): void => { + let line: string = choices.pick(PROSE_POOL); + if (choices.boolean(0.4)) { + line += `{text(${choices.pick(embedArgumentMenu(hasImport))})}`; + if (choices.boolean(0.5)) line += choices.pick(TAIL_POOL); + } + lines.push(line); + }; + const emitSection = (parentDotted: string, depth: number): void => { + const segName = nextSeg(); + const dotted = parentDotted === "" ? segName : `${parentDotted}.${segName}`; + lines.push( + `<S id="${dotted}"${choices.weightedPick(dPropMenu(hasImport))}>`, + ); + const innerCount = choices.intInclusive(0, 2); + for (let k = 0; k < innerCount; k += 1) { + const menu: (readonly [ + number, + "prose" | "blank" | "comment" | "section", + ])[] = [ + [3, "prose"], + [1, "blank"], + [1, "comment"], + ]; + if (depth < 2) menu.push([2, "section"]); + const shape = choices.weightedPick(menu); + if (shape === "prose") emitProse(); + else if (shape === "blank") lines.push(""); + else if (shape === "comment") { + lines.push(`{/* ${choices.pick(COMMENT_POOL)} */}`); + } else emitSection(dotted, depth + 1); + } + lines.push("</S>"); + }; + + const extraCount = choices.intInclusive(0, 2); + for (let i = 0; i < extraCount; i += 1) { + const shape = choices.weightedPick< + "prose" | "blank" | "comment" | "section" + >([ + [3, "prose"], + [1, "blank"], + [1, "comment"], + [4, "section"], + ]); + if (shape === "prose") emitProse(); + else if (shape === "blank") lines.push(""); + else if (shape === "comment") { + lines.push(`{/* ${choices.pick(COMMENT_POOL)} */}`); + } else emitSection("", 0); + } + return lines; +} + +/** + * The duplicate-id twist appendix (module header): both bearers of `dd` + * undefined (11.2), the first's resolving `d={"t"}` reference recording an + * occurrence whose source datum is explicitly unavailable (5.7). + */ +const DUPLICATE_ID_APPENDIX = + '<S id="dd" d={"t"}>\nd un.\n</S>\n<S id="dd">\nd deux.\n</S>\n'; + +/** The break-parse twist appendix: an unclosed flow tag — 14.20, masked. */ +const BREAK_PARSE_APPENDIX = '<S id="ka">\n'; + +/** The P-12 trial generator (see the module header). */ +export const genP12Trial: Gen<P12Trial> = (choices) => { + const fileCount = choices.weightedPick<number>([ + [2, 1], + [3, 2], + [2, 3], + ]); + const files: (readonly [string, string])[] = []; + for (let i = 0; i < fileCount; i += 1) { + const hasImport = i > 0 && choices.boolean(0.5); + files.push([ + FILE_POOL[i], + `${genFileLines(choices, hasImport).join("\n")}\n`, + ]); + } + const twistKind = choices.weightedPick< + "none" | "duplicate-id" | "break-parse" + >([ + [4, "none"], + [3, "duplicate-id"], + [2, "break-parse"], + ]); + if (twistKind === "none") return { files, twist: "none" }; + const target = choices.intInclusive(0, fileCount - 1); + const [path, content] = files[target]; + const appendix = + twistKind === "duplicate-id" ? DUPLICATE_ID_APPENDIX : BREAK_PARSE_APPENDIX; + files[target] = [path, content + appendix]; + return { files, twist: `${twistKind} on ${path}` }; +}; + +/** Counterexample rendering: the twist and the staged sources, in full. */ +export function renderP12Trial(trial: P12Trial): string { + return JSON.stringify({ + twist: trial.twist, + files: Object.fromEntries(trial.files), + }); +} + +// --------------------------------------------------------------------------- +// The 5.7 occurrence-order key and the duplicate-span assertion. + +/** A path value's bytes (12.7: marked byte form or UTF-8 string; 12.0). */ +function pathBytes(path: PathValue): Buffer { + return typeof path === "string" + ? Buffer.from(path, "utf8") + : Buffer.from(path.bytes, "hex"); +} + +/** + * Occurrence order (SPEC 5.7): referencing file path bytes, then range + * start, then range end — a total key once duplicate spans are excluded + * ("identical ranges do not occur and no further tiebreak exists"). + */ +function occurrenceOrder(a: OccurrenceRecord, b: OccurrenceRecord): number { + const files = Buffer.compare(pathBytes(a.file), pathBytes(b.file)); + if (files !== 0) return files; + if (a.range.start !== b.range.start) return a.range.start - b.range.start; + return a.range.end - b.range.end; +} + +/** + * No two records occupy one (file, range) span — distinct occurrences are + * distinct spellings occupying distinct spans, so identical ranges do not + * occur (SPEC 5.7); this also makes `occurrenceOrder` total, so the sorted + * comparison below needs no further tiebreak. + */ +function assertDistinctSpans( + records: readonly OccurrenceRecord[], + context: string, +): void { + const seen = new Map<string, number>(); + records.forEach((record, index) => { + const key = `${pathBytes(record.file).toString("hex")}:${String( + record.range.start, + )}:${String(record.range.end)}`; + const prior = seen.get(key); + if (prior !== undefined) { + fail( + `${context}: records ${String(prior)} and ${String(index)} both ` + + `occupy the span [${String(record.range.start)}, ` + + `${String(record.range.end)}) of the same file — distinct ` + + `occurrences are distinct spellings occupying distinct spans, so ` + + `identical ranges do not occur (SPEC 5.7)`, + ); + } + seen.set(key, index); + }); +} + +// --------------------------------------------------------------------------- +// The property body. + +/** + * Run one invocation of the availability surfaces. Every argument staged by + * P-12 is well-formed with the named file discovered and the offset in + * 0…byte length, so no usage error exists and the answer exits 0 or 1 + * (SPEC 11.2: findings ride the answer at exit 1, never exit 2). + */ +async function runAnswer( + product: ProductBinding, + workspace: Workspace, + argv: readonly string[], + context: string, +): Promise<RunResult> { + const result = await runProduct(product, { + cwd: workspace.root, + argv, + }); + if (result.signal !== null) { + fail( + `${context}: ${result.commandLine} died by signal ` + + `${String(result.signal)} instead of exiting — SPEC 12.0 partitions ` + + `all outcomes into exit codes 0, 1, and 2`, + ); + } + if (result.exitCode !== 0 && result.exitCode !== 1) { + fail( + `${context}: exit ${String(result.exitCode)} — every P-12 invocation ` + + `is well-formed over discovered files (offsets within 0…byte ` + + `length), so no usage error exists and the answer exits 0 or 1, ` + + `whatever findings the workspace carries (SPEC 11.2, 12.0)`, + ); + } + return result; +} + +/** The P-12 property body for one generated trial (module header). */ +async function runP12Trial( + product: ProductBinding, + trial: P12Trial, +): Promise<void> { + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + ...Object.fromEntries(trial.files), + }, + }); + try { + // --- the derivability ground: one bare `view` over the whole domain ---- + const viewContext = `P-12 \`xspec view\` (twist: ${trial.twist})`; + const viewReport = decodeViewReport( + parseJsonStdout( + await runAnswer(product, workspace, ["view"], viewContext), + viewContext, + ), + { text: false }, + viewContext, + ); + const stagedPaths = new Set(trial.files.map(([path]) => path)); + const viewByPath = new Map<string, FileView>(); + for (const entry of viewReport.views) { + if (typeof entry.file !== "string" || !stagedPaths.has(entry.file)) { + fail( + `${viewContext}: the answer carries a view for ` + + `${JSON.stringify(entry.file)}, which is no staged spec source — ` + + `a bare \`view\` covers exactly the discovered spec sources, ` + + `each a valid-UTF-8 path string here (SPEC 11.4, 12.0)`, + ); + } + if (viewByPath.has(entry.file)) { + fail( + `${viewContext}: two views for ${JSON.stringify(entry.file)} — ` + + `the requested files form a set, one per-file view per ` + + `parseable requested file (SPEC 11.4, 12.7)`, + ); + } + viewByPath.set(entry.file, entry); + } + + // --- occurrence order: enumeration ≡ view-collected, sorted (5.7) ------ + const occContext = `P-12 \`xspec occurrences\` (twist: ${trial.twist})`; + const first = await runAnswer( + product, + workspace, + ["occurrences"], + occContext, + ); + const second = await runAnswer( + product, + workspace, + ["occurrences"], + `${occContext} — second identical invocation`, + ); + if ( + Buffer.compare( + Buffer.from(first.stdoutBytes), + Buffer.from(second.stdoutBytes), + ) !== 0 || + first.exitCode !== second.exitCode + ) { + fail( + `${occContext}: two identical invocations over unchanged sources ` + + `must answer byte-identically with one exit code — occurrence ` + + `order is total and deterministic, and output is ` + + `byte-deterministic for identical input (SPEC 5.7, 12.0); first ` + + `exit ${String(first.exitCode)}, second exit ` + + `${String(second.exitCode)}`, + ); + } + const enumeration = decodeOccurrencesReport( + parseJsonStdout(first, occContext), + occContext, + ).occurrences; + assertDistinctSpans(enumeration, `${occContext} — the enumeration`); + for (const [path, entry] of viewByPath) { + assertDistinctSpans( + entry.occurrences, + `${viewContext} — the ${path} view's occurrence records`, + ); + } + const collected = [...viewByPath.values()] + .flatMap((entry) => entry.occurrences) + .sort(occurrenceOrder); + assertSameJson( + enumeration, + collected, + `${occContext}: the workspace-wide enumeration must equal the ` + + `view-collected occurrence records sorted by referencing file path ` + + `bytes, then range start, then range end — total (every view ` + + `record enumerated, nothing else) and in occurrence order, over ` + + `one spec-only domain (SPEC 5.7, 11.3, 11.4)`, + ); + + // --- at ≡ view: every file, every offset 0…byte length ----------------- + for (const [path, content] of trial.files) { + const byteLength = Buffer.byteLength(content, "utf8"); + const entry = viewByPath.get(path); + const data: ResolutionData | null = + entry === undefined + ? null + : { root: entry.root, occurrences: entry.occurrences }; + for (let offset = 0; offset <= byteLength; offset += 1) { + const context = `P-12 \`at ${path} ${String(offset)}\` (twist: ${trial.twist})`; + const report = decodeAtReport( + parseJsonStdout( + await runAnswer( + product, + workspace, + ["at", path, String(offset)], + context, + ), + context, + ), + context, + ); + const expected = + data === null ? UNAVAILABLE : resolveAtFromView(data, offset); + assertSameJson( + report.resolution, + expected, + data === null + ? `${context}: the requested file contributed no view — the ` + + `masked case — so its position data is gone with the rest of ` + + `it and every offset's resolution is exactly the ` + + `unavailability marker (SPEC 11.2, 11.5, 12.7)` + : `${context}: for every offset of the file, \`at\`'s ` + + `resolution must equal the resolution computed from the ` + + `file's own \`view\` entry alone — the innermost containing ` + + `section construct by range containment (the root where ` + + `none contains it, the EOF caret included) with its ` + + `identity datum verbatim, and the containing occurrence ` + + `record (\`null\` where the offset lies in none) — \`at\` ` + + `adds convenience, not information (SPEC 11.5, 11.4, 1.7)`, + ); + } + } + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// The registered property test. + +const P_12 = defineProductTest({ + id: "P-12", + title: + "property: on random spec-only workspaces (nested sections, imports, " + + "comments, d references and {text(...)} embeddings behind multi-byte " + + "prose; optionally one duplicate-id file or one unparseable file), for " + + "EVERY file and EVERY offset 0…byte length `at`'s resolution — section " + + "identity, construct range, containing occurrence — equals the " + + "resolution computed from that file's entry of one bare `view` answer " + + "alone (no entry — the masked file — resolving to exactly the " + + "unavailability marker), and the workspace-wide bare `occurrences` " + + "enumeration equals the view-collected occurrence records sorted by " + + "file path bytes, range start, range end — total, duplicate-free " + + "(identical spans never occur), and byte-identical across repeated " + + "runs (SPEC 11.5, 11.4, 11.3, 11.2, 5.7, 12.0; TEST-SPEC §16 P-12)", + // Wall-clock hang guard only (H-10): the per-trial at sweep is exhaustive + // over every staged byte offset, so trials are few (3 per seed × 3 fixed + // seeds, E-5) and small by generator construction, and the shrink budget + // is sized against whole-trial re-execution cost. + timeoutMs: 600_000, + run: async (product) => { + await checkProperty( + "P-12 at ≡ view; occurrence order", + genP12Trial, + async (trial) => { + await runP12Trial(product, trial); + }, + { runs: 3, maxShrinkExecutions: 25, render: renderP12Trial }, + ); + }, +}); + +/** TEST-SPEC §16 P-12 (PROP-10). */ +export const section16P12Tests: readonly ProductTestEntry[] = [P_12]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index c7a4168..10fa9b4 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -439,4 +439,5 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "P-9": ["10.1", "10.4", "10.7"], "P-10": ["6.1", "13.5"], "P-11": ["11.2", "11.4", "12.7"], + "P-12": ["5.7", "11.5"], }; diff --git a/test/suite/section-16-p12.test.ts b/test/suite/section-16-p12.test.ts new file mode 100644 index 0000000..505647d --- /dev/null +++ b/test/suite/section-16-p12.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §16 P-12 (PROP-10): thin Vitest wrapper over the registered +// property test — the identical body the certification runner executes +// against fixture products (C-2 "one code path"). Expected to fail as a +// diagnosed assertion failure until the product exists (H-8). + +import { declareProductTests } from "./declare.js"; +import { section16P12Tests } from "./registry/section-16-p12.js"; + +declareProductTests(section16P12Tests); From eef2736b5b39d0b38d709e7b690476506b6568a6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 17:16:16 +0000 Subject: [PATCH 134/226] sdg(phase-9): implement FP-089, the P-13 coverage-reachability oracle + its S-6 suite Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 43 +- test/helpers/oracles/coverage.ts | 479 ++++++++++++++++++++++ test/self/s6-coverage-oracle.test.ts | 583 +++++++++++++++++++++++++++ 3 files changed, 1104 insertions(+), 1 deletion(-) create mode 100644 test/helpers/oracles/coverage.ts create mode 100644 test/self/s6-coverage-oracle.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 4d15c6c..cc33f30 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3945,9 +3945,50 @@ certify against FP-091's fixtures once those land. product implements none of the §11 surfaces), shrinking in 5 steps to the minimal one-anchor-file workspace (seed 271828183).] -- [ ] FP-089 — Implement the P-13 coverage-reachability oracle (independent +- [x] FP-089 — Implement the P-13 coverage-reachability oracle (independent SPEC 8.1/8 reachability) + its S-6 vetted suite (vectors per SPEC 15). [R2 #42; TEST-SPEC §16 P-13, §17 S-6] + [Done 2026-08-29: `test/helpers/oracles/coverage.ts` (`computeCoverage`) + + `test/self/s6-coverage-oracle.test.ts` (21 vectors). Interface FP-090 + wires to: input is the caller-staged graph — every node with root flag, + contains-children (leaf judgment only; the reachability walk never + consults them), spelled coverage attribute, tags; dependency edges with + kinds, root-adjacent ones included — plus the target and boundary + groups' FULL memberships (roots included; the coverage-scoped root + exclusions are the oracle's job) and the profile (mode; targets default + "leaves"; targetTags null/omitted = absent; edgeKinds default all + three). Output: the four 8.2 counts plus required / covered (each with + the unique shortest covering path, boundary node first, 12.0 + element-wise byte tie-break via reverse-BFS distance levels + greedy + byte-least descent) / uncovered / ignored (all applicable reasons in + the fixed 8.2 order, spelled as the harness IGNORED_REASON_KINDS tokens + "root"/"coverage-none"/"non-leaf"/"lacking-tags" so FP-090 can compare + classifyIgnoredReasons output directly), every array identity-byte + sorted. Misuse guards throw plain errors: incomplete graphs, duplicate + group members, self-edges and combined contains/depends/embeds cycles + (5.3 — outside the valid-workspace input space), roots carrying + tags/coverage (5.5), empty edgeKinds/targetTags (14.14). Vectors: SPEC + 15's exact worked workspace under T15-1's grouping — the worked + transitive statement's full 8.2 result, direct contrast, one-edge + direct over the depends edge, edgeKinds breaking the worked path at + each step, targets "all" + contains-never-grants, one-or-more-edges + (boundary membership alone covers nothing), targetTags + carried/lacking/any-of with the fixed reason order pinned on root and + print, coverage="none" attribute variants (exclusion, + order-beside-lacking-tags, 2.5's descendants-retain sentence, and the + T8.2-1-shape coverage-none/non-leaf/lacking-tags triple), root-marker + + root-sourced-embeds variants (4.5: never intermediate; a boundary + root's byte-least one-edge route loses to the non-root path), three + 12.0 tie-break vectors (boundary element, interior element, + length-before-bytes), 6 misuse-guard tests. Teeth proven by 7 oracle + mutation probes (each reverted), all falsified: contains-into-adjacency + 1 vector, root-filters-removed 2, direct-as-transitive 1, + tie-break-reversed 2, root-exclusion-dropped 9, reason-order-swap 1 + (that probe initially passed — the reason-order vector was added to + close the gap), coverage-none-not-excluded 3. Verified: + typecheck/format clean; suite 21/21; `npm run test:self` 324 passed + (+21), unchanged 2 planned mid-loop reds (certification-document ×2 → + FP-091). section-16 registry untouched — wiring is FP-090's task.] - [ ] FP-090 — Implement P-13: coverage oracle property — random workspaces/profiles vs the FP-089 oracle. [R2 #16; TEST-SPEC §16 P-13] diff --git a/test/helpers/oracles/coverage.ts b/test/helpers/oracles/coverage.ts new file mode 100644 index 0000000..6497da1 --- /dev/null +++ b/test/helpers/oracles/coverage.ts @@ -0,0 +1,479 @@ +// In-harness coverage-reachability oracle (TEST-SPEC 16 P-13, 17 S-6): an +// independent implementation of SPEC.md 8.1's required set and SPEC.md 8's +// reachability, used to compute the expected `xspec coverage` result — the +// required, covered, uncovered, and ignored sets, exclusion reasons and one +// shortest covering path per covered node included (8.2, 12.0) — for the +// P-13 property tests. Per S-6, the oracle passes its fixed vector suite +// (test/self/s6-coverage-oracle.test.ts) — derived from SPEC.md 15's worked +// workspace and its transitive-coverage statement — before any property test +// trusts it. Harness machinery only: pure functions, no product imports, no +// I/O, no test-framework dependence. +// +// The oracle parses nothing and resolves no configuration. Its callers — the +// P-13 workspace/profile generator, the S-6 vectors — constructed the +// workspace, so they know its graph and its group memberships: the input is +// the graph (every node with its root flag, contains-children, coverage +// attribute, and tags; the dependency edges with their kinds) plus the +// resolved profile ingredients — the target group's nodes, the boundary +// group's nodes (each group's full membership, roots included: the group +// lists mirror 7.1/7.2 discovery, and the coverage-scoped root exclusions +// below are the oracle's own job), and the profile's `mode`, `targets`, +// `targetTags`, and `edgeKinds`. Feeding the oracle the caller's own +// structure rather than the product's graph output is what keeps it +// independent (P-13: "an independent oracle"). +// +// SPEC.md 8/8.1/8.2 (with 7.4's vocabulary and 12.0's tie-break), as +// implemented here: +// +// * Required set (8.1): the nodes of the target group, restricted to nodes +// carrying at least one `targetTags` tag when `targetTags` is present and +// to childless nodes when `targets` is `"leaves"` (7.4), excluding nodes +// marked `coverage="none"` (2.5 — per node: descendants retain their own +// behavior) and always excluding root nodes. +// * Ignored set (8.2): the target group's nodes excluded from the required +// set, each with all applicable exclusion reasons in the fixed order — +// root node, `coverage="none"`, non-leaf under `targets: "leaves"`, +// lacking every `targetTags` tag. A root carries no coverage attribute and +// no tags (5.5, guarded), so beside `root` it can carry `non-leaf` (when +// it has children under `targets: "leaves"`) and `lacking-tags` (whenever +// `targetTags` is present), never `coverage-none`. +// * Coverage (8): a required node is covered when a permitted path exists +// from a boundary node to it — a single edge in `direct` mode, a path of +// one or more edges in `transitive` mode (boundary membership alone is no +// such path), using only the profile's `edgeKinds`. `contains` edges never +// grant coverage and never appear in paths (children are input, and the +// reachability walk never consults them). Root nodes never appear in +// coverage paths — not as boundary node (the boundary group contributes +// only its non-root members), intermediate, or target: an edge whose +// source or target is a root never extends a covering path. +// * Reported path (8.2, 12.0): per covered node one shortest covering path, +// boundary node first, target last; among equal-length shortest paths the +// least by element-wise comparison of the node-identity sequences, each +// element compared byte-wise as UTF-8 (12.0). The minimum is computed +// greedily over dist-to-target levels: fixing a least prefix that extends +// to a shortest path never forfeits a smaller completion, because the +// element-wise comparison is decided at the first differing position. +// * Counts (8.2): the sizes of the four sets; required = covered ∪ +// uncovered by construction. +// +// Result arrays are sorted by identity bytes (SPEC 8.2 fixes membership and +// per-node information, not row order; callers comparing against a product +// report sort its rows the same way). The ignored-reason tokens are the +// harness's canonical `IGNORED_REASON_KINDS` spellings +// (test/helpers/adapters/reports.ts) — structurally identical literals, kept +// local so the oracle stays free of the adapter layer. +// +// Misuse guards (H-8) — each throws a plain error, a harness defect, never a +// diagnosed product failure: an identity without a node entry (as a child, +// an edge endpoint, or a group member); a duplicate group member (groups are +// sets); a self-edge or a cycle in the combined contains/depends/embeds +// graph (5.3 — such a workspace fails `build`, so it is outside P-13's input +// space; `references` edges cannot cycle: only code locations source them +// and no edge targets a code location); a root carrying tags or a coverage +// attribute (5.5: roots have neither); an empty `edgeKinds` or `targetTags` +// list (a configuration error, 14.14 — coverage never evaluates it). + +import { Buffer } from "node:buffer"; + +// --------------------------------------------------------------------------- +// Input and output model + +/** The dependency edge kinds (SPEC 5.2; 7.4's `edgeKinds` universe). */ +export const COVERAGE_ORACLE_EDGE_KINDS = [ + "depends", + "embeds", + "references", +] as const; +export type CoverageOracleEdgeKind = + (typeof COVERAGE_ORACLE_EDGE_KINDS)[number]; + +/** One graph node (requirement node or code location) the oracle sees. */ +export interface CoverageOracleNode { + /** A file's implicit root requirement node (SPEC 1.2)? Code: never. */ + readonly root: boolean; + /** + * Direct child identities in document order (`contains`, SPEC 5.2) — the + * leaf judgment of 7.4 (`"leaves"` = no children) and never anything + * else: the reachability walk does not consult children (8: `contains` + * never grants coverage). Code locations carry none. + */ + readonly children: readonly string[]; + /** + * The node's spelled coverage attribute (SPEC 2.5), `null` where none is + * spelled (the default is coverage-required). Roots carry `null` (5.5). + */ + readonly coverage: "required" | "none" | null; + /** The node's tags (SPEC 2.6, deduplicated). Roots carry none (5.5). */ + readonly tags: readonly string[]; +} + +/** One dependency edge (SPEC 5.2). Duplicates collapse (edges are sets). */ +export interface CoverageOracleEdge { + readonly source: string; + readonly target: string; + readonly kind: CoverageOracleEdgeKind; +} + +/** + * The resolved profile ingredients (SPEC 7.4) the required-set and + * reachability rules consume. Optional members take 7.4's documented + * defaults; group membership arrives as the separate input lists. + */ +export interface CoverageOracleProfile { + /** `"direct"` or `"transitive"` (7.4, 8). */ + readonly mode: "direct" | "transitive"; + /** `"leaves"` (the 7.4 default when omitted) or `"all"`. */ + readonly targets?: "leaves" | "all"; + /** + * The `targetTags` restriction; omitted or `null` = absent. An empty list + * is a configuration error (14.14) and a misuse here. + */ + readonly targetTags?: readonly string[] | null; + /** + * The permitted edge kinds; omitted = all three (the 7.4 default). An + * empty list is a configuration error (14.14) and a misuse here. + */ + readonly edgeKinds?: readonly CoverageOracleEdgeKind[]; +} + +/** The oracle's whole input (module header). */ +export interface CoverageOracleInput { + /** Every graph node, keyed by identity. */ + readonly nodes: ReadonlyMap<string, CoverageOracleNode>; + /** Every dependency edge (root-sourced and root-targeted ones included). */ + readonly edges: readonly CoverageOracleEdge[]; + /** The target group's full membership, roots included (7.1, 8.2). */ + readonly targetGroup: readonly string[]; + /** The boundary group's full membership, roots included (7.1/7.2, 8). */ + readonly boundaryGroup: readonly string[]; + readonly profile: CoverageOracleProfile; +} + +/** + * SPEC 8.2's exclusion-reason identities, in the fixed reporting order — + * the harness's canonical tokens (module header). + */ +export const COVERAGE_IGNORED_REASONS = [ + "root", + "coverage-none", + "non-leaf", + "lacking-tags", +] as const; +export type CoverageIgnoredReason = (typeof COVERAGE_IGNORED_REASONS)[number]; + +/** One covered node: its identity and its one shortest covering path. */ +export interface CoverageOracleCoveredRow { + readonly identity: string; + /** Boundary node first, target last (8.2, 12.0 tie-break). */ + readonly path: readonly string[]; +} + +/** One ignored node: all applicable reasons in the fixed order (8.2). */ +export interface CoverageOracleIgnoredRow { + readonly identity: string; + readonly reasons: readonly CoverageIgnoredReason[]; +} + +/** The expected result of one profile's coverage run (8.2). */ +export interface CoverageOracleResult { + readonly counts: { + readonly required: number; + readonly covered: number; + readonly uncovered: number; + readonly ignored: number; + }; + /** The required set (8.1), identity-byte order. */ + readonly required: readonly string[]; + /** The covered rows, identity-byte order. */ + readonly covered: readonly CoverageOracleCoveredRow[]; + /** The uncovered identities (required minus covered), byte order. */ + readonly uncovered: readonly string[]; + /** The ignored rows (target group minus required), identity-byte order. */ + readonly ignored: readonly CoverageOracleIgnoredRow[]; +} + +// --------------------------------------------------------------------------- +// Internals + +function misuse(message: string): never { + throw new Error(`coverage oracle misuse: ${message}`); +} + +/** Byte-wise UTF-8 comparison (SPEC 12.0). */ +function compareBytes(a: string, b: string): number { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + +function byteLeast(values: Iterable<string>): string | undefined { + let least: string | undefined; + for (const value of values) { + if (least === undefined || compareBytes(value, least) < 0) least = value; + } + return least; +} + +/** Resolve an identity to its node, or throw the incomplete-graph misuse. */ +function nodeAt( + nodes: ReadonlyMap<string, CoverageOracleNode>, + identity: string, + role: string, +): CoverageOracleNode { + const node = nodes.get(identity); + if (node === undefined) { + misuse( + `no node for ${identity} (${role}) — every child identity, edge ` + + `endpoint, and group member must have a node entry`, + ); + } + return node; +} + +/** Validate and deduplicate one group's membership (groups are sets). */ +function groupSet( + nodes: ReadonlyMap<string, CoverageOracleNode>, + members: readonly string[], + label: string, +): Set<string> { + const set = new Set<string>(); + for (const identity of members) { + nodeAt(nodes, identity, `a member of the ${label} group`); + if (set.has(identity)) { + misuse( + `duplicate ${label}-group member ${identity} — a group's nodes ` + + `form a set`, + ); + } + set.add(identity); + } + return set; +} + +/** + * Misuse-guard the combined contains/depends/embeds graph against cycles + * (SPEC 5.3): such a workspace fails validation and is outside the oracle's + * input space. `references` edges are excluded per 5.3 (they cannot cycle: + * only code locations source them and no edge targets a code location). + */ +function guardAcyclic( + nodes: ReadonlyMap<string, CoverageOracleNode>, + edges: readonly CoverageOracleEdge[], +): void { + const successors = new Map<string, Set<string>>(); + for (const identity of nodes.keys()) successors.set(identity, new Set()); + for (const [identity, node] of nodes) { + for (const child of node.children) { + nodeAt(nodes, child, `a child of ${identity}`); + successors.get(identity)?.add(child); + } + } + for (const edge of edges) { + if (edge.kind === "references") continue; + successors.get(edge.source)?.add(edge.target); + } + const done = new Set<string>(); + const visiting = new Set<string>(); + const visit = (identity: string): void => { + if (done.has(identity)) return; + if (visiting.has(identity)) { + misuse( + `contains/depends/embeds cycle through ${identity} — workspace ` + + `graphs are acyclic (SPEC 5.3)`, + ); + } + visiting.add(identity); + for (const next of successors.get(identity) ?? []) visit(next); + visiting.delete(identity); + done.add(identity); + }; + for (const identity of nodes.keys()) visit(identity); +} + +// --------------------------------------------------------------------------- +// The oracle + +/** + * Compute one profile's expected `xspec coverage` result per SPEC 8, 8.1, + * and 8.2 with the 12.0 shortest-path tie-break (module header): the + * required, covered (with one shortest covering path each), uncovered, and + * ignored (with all applicable exclusion reasons in the fixed order) sets, + * plus the four counts. + */ +export function computeCoverage( + input: CoverageOracleInput, +): CoverageOracleResult { + const { nodes, edges, profile } = input; + + // --- input contract (module header) -------------------------------------- + for (const [identity, node] of nodes) { + if (node.root && (node.tags.length > 0 || node.coverage !== null)) { + misuse( + `root node ${identity} carries tags or a coverage attribute — a ` + + `root has neither (SPEC 5.5)`, + ); + } + } + for (const edge of edges) { + nodeAt(nodes, edge.source, `the source of a ${edge.kind} edge`); + nodeAt(nodes, edge.target, `the target of a ${edge.kind} edge`); + if (edge.source === edge.target) { + misuse( + `self-edge on ${edge.source} — a node that depends on or embeds ` + + `itself is a dependency cycle of length one (SPEC 5.3)`, + ); + } + } + guardAcyclic(nodes, edges); + const targetMembers = groupSet(nodes, input.targetGroup, "target"); + const boundaryMembers = groupSet(nodes, input.boundaryGroup, "boundary"); + + const targets = profile.targets ?? "leaves"; + const edgeKinds = profile.edgeKinds ?? COVERAGE_ORACLE_EDGE_KINDS; + if (edgeKinds.length === 0) { + misuse( + `empty edgeKinds — a configuration error (SPEC 7.4, 14.14) coverage ` + + `never evaluates`, + ); + } + const targetTags = + profile.targetTags === undefined || profile.targetTags === null + ? null + : profile.targetTags; + if (targetTags !== null && targetTags.length === 0) { + misuse( + `empty targetTags — a configuration error (SPEC 7.4, 14.14) coverage ` + + `never evaluates`, + ); + } + + // --- required and ignored sets (8.1, 8.2) -------------------------------- + const tagSet = targetTags === null ? null : new Set(targetTags); + const reasonsFor = (identity: string): CoverageIgnoredReason[] => { + const node = nodeAt(nodes, identity, "a target-group member"); + const reasons: CoverageIgnoredReason[] = []; + if (node.root) reasons.push("root"); + if (node.coverage === "none") reasons.push("coverage-none"); + if (targets === "leaves" && node.children.length > 0) { + reasons.push("non-leaf"); + } + if (tagSet !== null && !node.tags.some((tag) => tagSet.has(tag))) { + reasons.push("lacking-tags"); + } + return reasons; + }; + const required: string[] = []; + const ignored: CoverageOracleIgnoredRow[] = []; + for (const identity of targetMembers) { + const reasons = reasonsFor(identity); + if (reasons.length === 0) required.push(identity); + else ignored.push({ identity, reasons }); + } + required.sort(compareBytes); + ignored.sort((a, b) => compareBytes(a.identity, b.identity)); + + // --- permitted reachability structure (8) -------------------------------- + // Boundary nodes: the boundary group's non-root members (8). Permitted + // steps: dependency edges of the profile's kinds with no root endpoint — + // a root is never boundary node, intermediate, or target of a path. + const boundary = new Set( + [...boundaryMembers].filter( + (identity) => !nodeAt(nodes, identity, "a boundary-group member").root, + ), + ); + const kindSet = new Set<CoverageOracleEdgeKind>(edgeKinds); + const forward = new Map<string, Set<string>>(); + const backward = new Map<string, Set<string>>(); + for (const edge of edges) { + if (!kindSet.has(edge.kind)) continue; + if (nodes.get(edge.source)?.root === true) continue; + if (nodes.get(edge.target)?.root === true) continue; + let out = forward.get(edge.source); + if (out === undefined) forward.set(edge.source, (out = new Set())); + out.add(edge.target); + let into = backward.get(edge.target); + if (into === undefined) backward.set(edge.target, (into = new Set())); + into.add(edge.source); + } + + /** + * The unique reported covering path for one required node, or `null` + * where none exists: shortest from any boundary node (one edge in + * `direct` mode, one or more in `transitive`), ties by element-wise + * byte comparison (8, 8.2, 12.0 — module header). + */ + const coveringPath = (target: string): string[] | null => { + if (profile.mode === "direct") { + const sources = backward.get(target); + if (sources === undefined) return null; + const least = byteLeast( + [...sources].filter((source) => boundary.has(source)), + ); + return least === undefined ? null : [least, target]; + } + // Transitive: distance-to-target levels by reverse BFS, then a greedy + // byte-least descent along strictly decreasing distances. + const dist = new Map<string, number>([[target, 0]]); + let frontier = [target]; + while (frontier.length > 0) { + const next: string[] = []; + for (const identity of frontier) { + const level = dist.get(identity) ?? 0; + for (const source of backward.get(identity) ?? []) { + if (dist.has(source)) continue; + dist.set(source, level + 1); + next.push(source); + } + } + frontier = next; + } + const starts = [...boundary].filter( + (identity) => identity !== target && dist.has(identity), + ); + if (starts.length === 0) return null; + const startDistance = Math.min( + ...starts.map((identity) => dist.get(identity) ?? Number.NaN), + ); + const path = [ + byteLeast( + starts.filter((identity) => dist.get(identity) === startDistance), + ) as string, + ]; + for (let remaining = startDistance - 1; remaining >= 0; remaining -= 1) { + const current = path[path.length - 1] as string; + const next = byteLeast( + [...(forward.get(current) ?? [])].filter( + (identity) => dist.get(identity) === remaining, + ), + ); + if (next === undefined) { + throw new Error( + `coverage oracle internal error: no distance-${String(remaining)} ` + + `successor of ${current} on a shortest path to ${target}`, + ); + } + path.push(next); + } + return path; + }; + + // --- covered and uncovered (8, 8.2) -------------------------------------- + const covered: CoverageOracleCoveredRow[] = []; + const uncovered: string[] = []; + for (const identity of required) { + const path = coveringPath(identity); + if (path === null) uncovered.push(identity); + else covered.push({ identity, path }); + } + + return { + counts: { + required: required.length, + covered: covered.length, + uncovered: uncovered.length, + ignored: ignored.length, + }, + required, + covered, + uncovered, + ignored, + }; +} diff --git a/test/self/s6-coverage-oracle.test.ts b/test/self/s6-coverage-oracle.test.ts new file mode 100644 index 0000000..181a1a9 --- /dev/null +++ b/test/self/s6-coverage-oracle.test.ts @@ -0,0 +1,583 @@ +// S-6 coverage-reachability-oracle vectors (TEST-SPEC 17 S-6): the +// in-harness coverage oracle for P-13 (test/helpers/oracles/coverage.ts) +// passes this fixed vector suite, derived from SPEC.md 15's worked material, +// before any property test trusts it. Every vector's result table is +// hand-computed; no product is involved (the product's own SPEC 8 behavior +// is asserted by the suite's T8-*/T8.2-1/T15-1 tests against fixtures, not +// against this oracle). +// +// The vectors run profiles over SPEC.md 15's exact worked workspace — the +// graph its "Graph:" listing spells out (specs/SPEC.mdx with print > +// print.hello tags="critical"; specs/DERIVED.mdx with derived > +// derived.hello; src/hello.ts#hello; the depends and references edges) — +// grouped as T15-1 stages it (spec group `spec`, spec group `derived`, code +// group `src`). Coverage, by the worked material and the rules each vector +// derives from (the sibling S-6 suites' practice: the named section's +// examples and rules): +// * the worked statement itself — "The path hello → derived.hello → +// print.hello satisfies a transitive coverage profile targeting +// print.hello" (15; T15-1's profile) — with the full 8.2 result; +// * `direct` vs `transitive` (8: a single edge vs one or more) on the +// same worked path, and a one-edge direct profile over the depends edge; +// * `edgeKinds` restrictions (7.4, 8: only the profile's kinds) breaking +// the worked path at its references step, at its depends step, and +// keeping it whole; +// * `targets: "all"` vs the `"leaves"` default (7.4, 8.1) and `contains` +// never granting (8): `print`, connected only by containment, stays +// uncovered while its child is covered; +// * `targetTags` (7.4, 8.1: at least one listed tag) carried, lacking, +// and any-of, with the ignored reasons in the fixed 8.2 order — root +// node, coverage="none", non-leaf, lacking-tags — pinned on the root, +// on `print`, and on `print.hello`; +// * `coverage="none"` (2.5, 8.1) as minimal attribute variants of the +// same workspace: exclusion, reason order beside lacking-tags, and 2.5's +// descendants-retain-their-own-behavior sentence; +// * root exclusions (8, 4.5): a root marker plus a root-sourced embeds +// edge — 4.5's "a root marker grants no coverage in any profile" — never +// extend a path (root never boundary node, intermediate, or target), +// and a boundary root with a one-edge route loses to a non-root +// boundary node's path; +// * the 12.0 tie-break (8.2): equal-length paths tie-broken at the +// boundary element and at an interior element, and shortest-first +// dominating byte order; +// plus misuse guards: incomplete graphs, duplicate group members, +// self-edges, contains/depends/embeds cycles, roots carrying tags or a +// coverage attribute, and empty edgeKinds/targetTags lists throw plain +// errors (harness defects), never diagnosed product failures. + +import { expect, test } from "vitest"; +import { computeCoverage } from "../helpers/oracles/coverage.js"; +import type { + CoverageOracleEdge, + CoverageOracleInput, + CoverageOracleNode, + CoverageOracleProfile, + CoverageOracleResult, +} from "../helpers/oracles/coverage.js"; + +// --- SPEC.md 15's worked workspace ------------------------------------------ + +const SPEC_ROOT = "specs/SPEC.mdx"; +const PRINT = "specs/SPEC.mdx#print"; +const PRINT_HELLO = "specs/SPEC.mdx#print.hello"; +const DERIVED_ROOT = "specs/DERIVED.mdx"; +const DERIVED = "specs/DERIVED.mdx#derived"; +const DERIVED_HELLO = "specs/DERIVED.mdx#derived.hello"; +const HELLO = "src/hello.ts#hello"; + +/** SPEC 15's two dependency edges (its `contains` rows are the children). */ +const SPEC15_EDGES: readonly CoverageOracleEdge[] = [ + { source: DERIVED_HELLO, target: PRINT_HELLO, kind: "depends" }, + { source: HELLO, target: DERIVED_HELLO, kind: "references" }, +]; + +/** T15-1's grouping of the worked workspace. */ +const SPEC_GROUP = [SPEC_ROOT, PRINT, PRINT_HELLO] as const; +const DERIVED_GROUP = [DERIVED_ROOT, DERIVED, DERIVED_HELLO] as const; +const SRC_GROUP = [HELLO] as const; + +interface ModelOptions { + /** Attribute variants of the worked workspace (SPEC 2.5). */ + readonly printCoverage?: "none"; + readonly printHelloCoverage?: "none"; + /** Replacement dependency edges (default: SPEC 15's two). */ + readonly edges?: readonly CoverageOracleEdge[]; +} + +function node(spec: Partial<CoverageOracleNode> = {}): CoverageOracleNode { + return { + root: spec.root ?? false, + children: spec.children ?? [], + coverage: spec.coverage ?? null, + tags: spec.tags ?? [], + }; +} + +/** SPEC 15's graph (nodes and dependency edges), with minimal variants. */ +function spec15Model(options: ModelOptions = {}): { + nodes: Map<string, CoverageOracleNode>; + edges: readonly CoverageOracleEdge[]; +} { + return { + nodes: new Map<string, CoverageOracleNode>([ + [SPEC_ROOT, node({ root: true, children: [PRINT] })], + [ + PRINT, + node({ children: [PRINT_HELLO], coverage: options.printCoverage }), + ], + [ + PRINT_HELLO, + node({ tags: ["critical"], coverage: options.printHelloCoverage }), + ], + [DERIVED_ROOT, node({ root: true, children: [DERIVED] })], + [DERIVED, node({ children: [DERIVED_HELLO] })], + [DERIVED_HELLO, node()], + [HELLO, node()], + ]), + edges: options.edges ?? SPEC15_EDGES, + }; +} + +/** Run one profile over the (possibly variant) worked workspace. */ +function run( + profile: CoverageOracleProfile & { + readonly target: readonly string[]; + readonly boundary: readonly string[]; + }, + options: ModelOptions = {}, +): CoverageOracleResult { + const { target, boundary, ...rest } = profile; + const { nodes, edges } = spec15Model(options); + const input: CoverageOracleInput = { + nodes, + edges, + targetGroup: target, + boundaryGroup: boundary, + profile: rest, + }; + return computeCoverage(input); +} + +/** SPEC 15's worked covering path, boundary node first (8.2). */ +const WORKED_PATH = [HELLO, DERIVED_HELLO, PRINT_HELLO] as const; + +// ============================================================================= +// The worked statement (15, T15-1's profile) and its direct-mode contrast +// ============================================================================= + +test("S-6 (15 walkthrough): the transitive profile targeting print.hello with src as code boundary is satisfied via hello → derived.hello → print.hello, with the root and print ignored as 8.2 spells", () => { + expect( + run({ target: SPEC_GROUP, boundary: SRC_GROUP, mode: "transitive" }), + ).toEqual({ + counts: { required: 1, covered: 1, uncovered: 0, ignored: 2 }, + required: [PRINT_HELLO], + covered: [{ identity: PRINT_HELLO, path: [...WORKED_PATH] }], + uncovered: [], + ignored: [ + { identity: SPEC_ROOT, reasons: ["root", "non-leaf"] }, + { identity: PRINT, reasons: ["non-leaf"] }, + ], + }); +}); + +test("S-6 (8 direct vs transitive): the worked two-edge path does not cover in direct mode — a single edge is required", () => { + expect( + run({ target: SPEC_GROUP, boundary: SRC_GROUP, mode: "direct" }), + ).toEqual({ + counts: { required: 1, covered: 0, uncovered: 1, ignored: 2 }, + required: [PRINT_HELLO], + covered: [], + uncovered: [PRINT_HELLO], + ignored: [ + { identity: SPEC_ROOT, reasons: ["root", "non-leaf"] }, + { identity: PRINT, reasons: ["non-leaf"] }, + ], + }); +}); + +test("S-6 (8 direct): the single depends edge from the derived spec boundary covers print.hello over exactly [boundary node, target]", () => { + const result = run({ + target: SPEC_GROUP, + boundary: DERIVED_GROUP, + mode: "direct", + }); + expect(result.covered).toEqual([ + { identity: PRINT_HELLO, path: [DERIVED_HELLO, PRINT_HELLO] }, + ]); + expect(result.uncovered).toEqual([]); + expect(result.counts).toEqual({ + required: 1, + covered: 1, + uncovered: 0, + ignored: 2, + }); +}); + +// ============================================================================= +// edgeKinds restrictions (7.4, 8) over the worked path +// ============================================================================= + +test("S-6 (7.4 edgeKinds): the worked path covers only under kinds admitting both its references and its depends step", () => { + const profile = { + target: SPEC_GROUP, + boundary: SRC_GROUP, + mode: "transitive", + } as const; + for (const edgeKinds of [["depends"], ["references"], ["embeds"]] as const) { + const result = run({ ...profile, edgeKinds: [...edgeKinds] }); + expect(result.covered).toEqual([]); + expect(result.uncovered).toEqual([PRINT_HELLO]); + } + expect( + run({ ...profile, edgeKinds: ["depends", "references"] }).covered, + ).toEqual([{ identity: PRINT_HELLO, path: [...WORKED_PATH] }]); +}); + +// ============================================================================= +// targets "all" vs "leaves"; contains never grants (7.4, 8, 8.1) +// ============================================================================= + +test('S-6 (8 contains, 7.4 targets "all"): print joins the required set yet stays uncovered — its only connection is containment — and the root\'s ignored reasons drop non-leaf', () => { + expect( + run({ + target: SPEC_GROUP, + boundary: SRC_GROUP, + mode: "transitive", + targets: "all", + }), + ).toEqual({ + counts: { required: 2, covered: 1, uncovered: 1, ignored: 1 }, + required: [PRINT, PRINT_HELLO], + covered: [{ identity: PRINT_HELLO, path: [...WORKED_PATH] }], + uncovered: [PRINT], + ignored: [{ identity: SPEC_ROOT, reasons: ["root"] }], + }); +}); + +test("S-6 (8 one-or-more edges): boundary membership alone covers nothing — with the derived group as its own boundary, derived.hello is a boundary node yet uncovered — while the code boundary covers it and leaves its containment-only parent uncovered", () => { + expect( + run({ + target: DERIVED_GROUP, + boundary: DERIVED_GROUP, + mode: "transitive", + targets: "all", + }), + ).toEqual({ + counts: { required: 2, covered: 0, uncovered: 2, ignored: 1 }, + required: [DERIVED, DERIVED_HELLO], + covered: [], + uncovered: [DERIVED, DERIVED_HELLO], + ignored: [{ identity: DERIVED_ROOT, reasons: ["root"] }], + }); + expect( + run({ + target: DERIVED_GROUP, + boundary: SRC_GROUP, + mode: "transitive", + targets: "all", + }), + ).toEqual({ + counts: { required: 2, covered: 1, uncovered: 1, ignored: 1 }, + required: [DERIVED, DERIVED_HELLO], + covered: [{ identity: DERIVED_HELLO, path: [HELLO, DERIVED_HELLO] }], + uncovered: [DERIVED], + ignored: [{ identity: DERIVED_ROOT, reasons: ["root"] }], + }); +}); + +// ============================================================================= +// targetTags (7.4, 8.1) and the fixed 8.2 reason order +// ============================================================================= + +test('S-6 (8.1 targetTags carried): targetTags ["critical"] keeps print.hello required and covered, and the tag reason joins the fixed reason order on the root and on print', () => { + expect( + run({ + target: SPEC_GROUP, + boundary: SRC_GROUP, + mode: "transitive", + targetTags: ["critical"], + }), + ).toEqual({ + counts: { required: 1, covered: 1, uncovered: 0, ignored: 2 }, + required: [PRINT_HELLO], + covered: [{ identity: PRINT_HELLO, path: [...WORKED_PATH] }], + uncovered: [], + ignored: [ + { identity: SPEC_ROOT, reasons: ["root", "non-leaf", "lacking-tags"] }, + { identity: PRINT, reasons: ["non-leaf", "lacking-tags"] }, + ], + }); +}); + +test("S-6 (8.1 targetTags lacking, and any-of): a tag list print.hello lacks empties the required set and ignores it as lacking-tags; a list carrying any of its tags keeps it required", () => { + expect( + run({ + target: SPEC_GROUP, + boundary: SRC_GROUP, + mode: "transitive", + targetTags: ["missing"], + }), + ).toEqual({ + counts: { required: 0, covered: 0, uncovered: 0, ignored: 3 }, + required: [], + covered: [], + uncovered: [], + ignored: [ + { identity: SPEC_ROOT, reasons: ["root", "non-leaf", "lacking-tags"] }, + { identity: PRINT, reasons: ["non-leaf", "lacking-tags"] }, + { identity: PRINT_HELLO, reasons: ["lacking-tags"] }, + ], + }); + expect( + run({ + target: SPEC_GROUP, + boundary: SRC_GROUP, + mode: "transitive", + targetTags: ["missing", "critical"], + }).covered, + ).toEqual([{ identity: PRINT_HELLO, path: [...WORKED_PATH] }]); +}); + +// ============================================================================= +// coverage="none" (2.5, 8.1) as minimal attribute variants +// ============================================================================= + +test('S-6 (8.1 coverage="none"): marking print.hello excludes it — ignored as coverage-none, its tag sparing it the lacking-tags reason exactly when carried', () => { + expect( + run( + { target: SPEC_GROUP, boundary: SRC_GROUP, mode: "transitive" }, + { printHelloCoverage: "none" }, + ), + ).toEqual({ + counts: { required: 0, covered: 0, uncovered: 0, ignored: 3 }, + required: [], + covered: [], + uncovered: [], + ignored: [ + { identity: SPEC_ROOT, reasons: ["root", "non-leaf"] }, + { identity: PRINT, reasons: ["non-leaf"] }, + { identity: PRINT_HELLO, reasons: ["coverage-none"] }, + ], + }); + const tagged = (tags: readonly string[]): readonly string[] | undefined => + run( + { + target: SPEC_GROUP, + boundary: SRC_GROUP, + mode: "transitive", + targetTags: [...tags], + }, + { printHelloCoverage: "none" }, + ).ignored.find((row) => row.identity === PRINT_HELLO)?.reasons; + // The fixed order places coverage-none ahead of lacking-tags (8.2), and + // only applicable reasons appear (print.hello carries "critical"). + expect(tagged(["missing"])).toEqual(["coverage-none", "lacking-tags"]); + expect(tagged(["critical"])).toEqual(["coverage-none"]); +}); + +test('S-6 (8.2 reason order): print marked coverage="none" — simultaneously coverage-excluded, a parent, and lacking the listed tag — carries the fixed-order triple coverage-none, non-leaf, lacking-tags', () => { + expect( + run( + { + target: SPEC_GROUP, + boundary: SRC_GROUP, + mode: "transitive", + targetTags: ["missing"], + }, + { printCoverage: "none" }, + ).ignored, + ).toEqual([ + { identity: SPEC_ROOT, reasons: ["root", "non-leaf", "lacking-tags"] }, + { + identity: PRINT, + reasons: ["coverage-none", "non-leaf", "lacking-tags"], + }, + { identity: PRINT_HELLO, reasons: ["lacking-tags"] }, + ]); + expect( + run( + { target: SPEC_GROUP, boundary: SRC_GROUP, mode: "transitive" }, + { printCoverage: "none" }, + ).ignored, + ).toEqual([ + { identity: SPEC_ROOT, reasons: ["root", "non-leaf"] }, + { identity: PRINT, reasons: ["coverage-none", "non-leaf"] }, + ]); +}); + +test('S-6 (2.5 descendants retain behavior): marking print coverage="none" leaves print.hello required and covered under targets "all", print ignored as coverage-none alone', () => { + expect( + run( + { + target: SPEC_GROUP, + boundary: SRC_GROUP, + mode: "transitive", + targets: "all", + }, + { printCoverage: "none" }, + ), + ).toEqual({ + counts: { required: 1, covered: 1, uncovered: 0, ignored: 2 }, + required: [PRINT_HELLO], + covered: [{ identity: PRINT_HELLO, path: [...WORKED_PATH] }], + uncovered: [], + ignored: [ + { identity: SPEC_ROOT, reasons: ["root"] }, + { identity: PRINT, reasons: ["coverage-none"] }, + ], + }); +}); + +// ============================================================================= +// Root exclusions (8, 4.5): marker on the DERIVED root plus a root-sourced +// embeds edge — the 4.5 sentence: a root marker grants no coverage +// ============================================================================= + +/** The worked graph with hello's marker retargeted to the DERIVED root and + * a top-level embedding in DERIVED.mdx (root-sourced, SPEC 2.3). */ +const ROOT_ADJACENT_EDGES: readonly CoverageOracleEdge[] = [ + { source: HELLO, target: DERIVED_ROOT, kind: "references" }, + { source: DERIVED_ROOT, target: PRINT_HELLO, kind: "embeds" }, + { source: DERIVED_HELLO, target: PRINT_HELLO, kind: "depends" }, +]; + +test("S-6 (8, 4.5 root exclusions): the hello → DERIVED-root → print.hello chain never covers — a root is never an intermediate, and neither the root-targeted nor the root-sourced edge extends a path", () => { + expect( + run( + { target: SPEC_GROUP, boundary: SRC_GROUP, mode: "transitive" }, + { edges: ROOT_ADJACENT_EDGES }, + ), + ).toEqual({ + counts: { required: 1, covered: 0, uncovered: 1, ignored: 2 }, + required: [PRINT_HELLO], + covered: [], + uncovered: [PRINT_HELLO], + ignored: [ + { identity: SPEC_ROOT, reasons: ["root", "non-leaf"] }, + { identity: PRINT, reasons: ["non-leaf"] }, + ], + }); +}); + +test("S-6 (8 boundary roots): the derived boundary group contributes only its non-root nodes — the root's own one-edge embeds route (byte-least were roots admitted) loses to derived.hello's depends edge", () => { + for (const mode of ["direct", "transitive"] as const) { + const result = run( + { target: SPEC_GROUP, boundary: DERIVED_GROUP, mode }, + { edges: ROOT_ADJACENT_EDGES }, + ); + expect(result.covered).toEqual([ + { identity: PRINT_HELLO, path: [DERIVED_HELLO, PRINT_HELLO] }, + ]); + expect(result.uncovered).toEqual([]); + } +}); + +// ============================================================================= +// The 12.0 tie-break (8.2): equal-length paths, boundary and interior +// elements, and shortest-first before byte order +// ============================================================================= + +test("S-6 (12.0 tie-break, boundary element): two equal-length covering edges tie-break to the byte-least boundary node", () => { + const edges: readonly CoverageOracleEdge[] = [ + ...SPEC15_EDGES, + { source: DERIVED, target: PRINT_HELLO, kind: "depends" }, + ]; + for (const mode of ["direct", "transitive"] as const) { + expect( + run({ target: SPEC_GROUP, boundary: DERIVED_GROUP, mode }, { edges }) + .covered, + ).toEqual([{ identity: PRINT_HELLO, path: [DERIVED, PRINT_HELLO] }]); + } +}); + +test("S-6 (12.0 tie-break, interior element): equal-length paths sharing their boundary node tie-break at the first differing interior identity", () => { + const edges: readonly CoverageOracleEdge[] = [ + ...SPEC15_EDGES, + { source: DERIVED, target: PRINT_HELLO, kind: "depends" }, + { source: HELLO, target: DERIVED, kind: "references" }, + ]; + expect( + run( + { target: SPEC_GROUP, boundary: SRC_GROUP, mode: "transitive" }, + { edges }, + ).covered, + ).toEqual([{ identity: PRINT_HELLO, path: [HELLO, DERIVED, PRINT_HELLO] }]); +}); + +test("S-6 (12.0 tie-break, shortest first): a one-edge path beats a two-edge path from a byte-lesser boundary node — length dominates the byte comparison", () => { + const edges: readonly CoverageOracleEdge[] = [ + ...SPEC15_EDGES, + { source: DERIVED, target: DERIVED_HELLO, kind: "depends" }, + ]; + expect( + run( + { target: SPEC_GROUP, boundary: DERIVED_GROUP, mode: "transitive" }, + { edges }, + ).covered, + ).toEqual([{ identity: PRINT_HELLO, path: [DERIVED_HELLO, PRINT_HELLO] }]); +}); + +// ============================================================================= +// Misuse guards +// ============================================================================= + +function inputOf( + overrides: Partial<CoverageOracleInput> = {}, + options: ModelOptions = {}, +): CoverageOracleInput { + const { nodes, edges } = spec15Model(options); + return { + nodes, + edges, + targetGroup: SPEC_GROUP, + boundaryGroup: SRC_GROUP, + profile: { mode: "transitive" }, + ...overrides, + }; +} + +test("S-6: a group member or edge endpoint without a node entry throws — the graph must be complete", () => { + expect(() => + computeCoverage( + inputOf({ targetGroup: [...SPEC_GROUP, "specs/GHOST.mdx#g"] }), + ), + ).toThrow(/oracle misuse:.*no node for specs\/GHOST\.mdx#g/); + expect(() => + computeCoverage( + inputOf({ + edges: [ + { source: HELLO, target: "specs/GHOST.mdx#g", kind: "references" }, + ], + }), + ), + ).toThrow(/oracle misuse:.*no node for specs\/GHOST\.mdx#g/); +}); + +test("S-6: a duplicate group member throws — a group's nodes form a set", () => { + expect(() => + computeCoverage(inputOf({ boundaryGroup: [HELLO, HELLO] })), + ).toThrow(/oracle misuse:.*duplicate boundary-group member/); +}); + +test("S-6: a self-edge and a dependency cycle each throw — such workspaces fail validation (SPEC 5.3)", () => { + expect(() => + computeCoverage( + inputOf({ + edges: [{ source: PRINT_HELLO, target: PRINT_HELLO, kind: "depends" }], + }), + ), + ).toThrow(/oracle misuse:.*self-edge/); + expect(() => + computeCoverage( + inputOf({ + edges: [ + ...SPEC15_EDGES, + { source: PRINT_HELLO, target: DERIVED_HELLO, kind: "embeds" }, + ], + }), + ), + ).toThrow(/oracle misuse:.*cycle/); +}); + +test("S-6: a root carrying tags or a coverage attribute throws (SPEC 5.5)", () => { + const { edges } = spec15Model(); + const nodes = new Map(spec15Model().nodes); + nodes.set(SPEC_ROOT, { + root: true, + children: [PRINT], + coverage: null, + tags: ["critical"], + }); + expect(() => computeCoverage(inputOf({ nodes, edges }))).toThrow( + /oracle misuse:.*root node .* carries tags or a coverage attribute/, + ); +}); + +test("S-6: an empty edgeKinds or targetTags list throws — a configuration error (SPEC 14.14) coverage never evaluates", () => { + expect(() => + computeCoverage(inputOf({ profile: { mode: "direct", edgeKinds: [] } })), + ).toThrow(/oracle misuse:.*empty edgeKinds/); + expect(() => + computeCoverage(inputOf({ profile: { mode: "direct", targetTags: [] } })), + ).toThrow(/oracle misuse:.*empty targetTags/); +}); From bb5cf75940e33a48384650aa9aff696958651521 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 17:45:21 +0000 Subject: [PATCH 135/226] sdg(phase-9): implement P-13, the coverage oracle property (FP-090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the P-13 property test: seeded random workspaces (spec and code groups; depends/embeds/references edges; tags; coverage="none"; root-sourced and root-targeted edges) under random 7.4 profiles, each coverage --json run compared in full — counts, covered rows with exact shortest tie-broken paths, uncovered, ignored with classified reasons — against the FP-089 oracle fed the generator's own graph model. Rank discipline keeps every generated workspace valid by construction; six reverted teeth probes all falsified against the built product; the S-7 sweep keeps P-13 red-as-diagnosed against the empty stub. Adds the H-7 map entry and the manifest/wrapper wiring; marks FP-090 done. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 41 +- test/suite/registry/index.ts | 2 + test/suite/registry/section-16-p13.ts | 929 ++++++++++++++++++++++++++ test/suite/registry/traceability.ts | 1 + test/suite/section-16-p13.test.ts | 9 + 5 files changed, 981 insertions(+), 1 deletion(-) create mode 100644 test/suite/registry/section-16-p13.ts create mode 100644 test/suite/section-16-p13.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index cc33f30..3a5c50e 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -3990,9 +3990,48 @@ certify against FP-091's fixtures once those land. (+21), unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091). section-16 registry untouched — wiring is FP-090's task.] -- [ ] FP-090 — Implement P-13: coverage oracle property — random +- [x] FP-090 — Implement P-13: coverage oracle property — random workspaces/profiles vs the FP-089 oracle. [R2 #16; TEST-SPEC §16 P-13] After FP-089. + [Done 2026-08-29: test/suite/registry/section-16-p13.ts (+ wrapper + section-16-p13.test.ts, manifest spread, H-7 entry "P-13": ["7.4", "8", + "8.1", "8.2"]). Generator: valid-by-construction workspaces under a rank + discipline (file index, then per-file post-order; every reference targets + a strictly lower rank in-file or any node of an earlier file, so the + combined contains/depends/embeds graph and the import graph are acyclic + and build must exit 0) — 1–3 spec files with nested tagged / + coverage-attributed sections (segment pools deliberately non-sorted so + identity byte order decouples from structure), d refs and embeddings in + local, external-chain, and module-form (root-targeted) spellings, + top-level embeddings (root-sourced), 0–2 TS files with whole-file and + named-unit markers/text calls, overlapping spec/code groups with + discovered-membership repair, 1–3 profiles over every 7.4 knob + (mode/targets/targetTags incl. a no-node tag/edgeKinds/spec+code + boundaries). Per trial: `build` (exit 0) then one `coverage --json`; + profile-name set pinned (8.2 all-profiles); per profile the four counts, + covered rows with exact paths, uncovered, and ignored rows (reasons via + classifyIgnoredReasons, fixed order) compared against computeCoverage fed + the generator's own model — required observed as covered ∪ uncovered + + count; the unique shortest+tie-break oracle path makes exact equality + P-13's permitted-path clause. 24 CI-pinned trials (runs 8 × 3 default + seeds); a dry-run over the committed seeds verified every MDX parses with + imports as real ESM blocks (FP-094 lesson), every TS parses, every oracle + input passes the misuse guards, and every input class occurs (all three + edge kinds, root-sourced ×28 / root-targeted ×51 edges, code boundaries + ×18, boundary=target ×10, all four ignored reasons + multi-reason rows, + multi-edge covered paths ×11, tie-broken paths ×16, all targetTags and + most edgeKinds subsets, targets leaves/all/omitted). Teeth proven by 6 + probes (each reverted), all falsified against the built product: + transitive-as-direct, coverage-none dropped, tags dropped, children + dropped, code-sourced edges dropped, reported paths reversed (that one + failing the covered-path assertion specifically). Verified: + typecheck/format clean; unlike P-11/P-12, P-13 is GREEN against the built + product — its coverage engine predates this patch and agrees with the + oracle on all 24 workspaces (which also proves the staged workspaces + valid: build accepts every one); `npm run test:self` 324 passed with the + unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091); + S-1 green with the new mapping; S-7 sweep green, so P-13 fails as + diagnosed against the empty stub product (H-8).] ## Stage I — CONF-AVAIL certification family diff --git a/test/suite/registry/index.ts b/test/suite/registry/index.ts index 91ee536..c39e1a8 100644 --- a/test/suite/registry/index.ts +++ b/test/suite/registry/index.ts @@ -83,6 +83,7 @@ import { section16P9Tests } from "./section-16-p9.js"; import { section16P10Tests } from "./section-16-p10.js"; import { section16P11Tests } from "./section-16-p11.js"; import { section16P12Tests } from "./section-16-p12.js"; +import { section16P13Tests } from "./section-16-p13.js"; export const productTestSuite = new ProductTestSuite([ // Section registration modules are spread here as they are implemented. @@ -156,4 +157,5 @@ export const productTestSuite = new ProductTestSuite([ ...section16P10Tests, ...section16P11Tests, ...section16P12Tests, + ...section16P13Tests, ]); diff --git a/test/suite/registry/section-16-p13.ts b/test/suite/registry/section-16-p13.ts new file mode 100644 index 0000000..4405221 --- /dev/null +++ b/test/suite/registry/section-16-p13.ts @@ -0,0 +1,929 @@ +// TEST-SPEC §16 P-13 (coverage oracle) — PROP-11. +// +// One registered product-facing property test (C-2 "one code path"): a +// seeded, reproducible generator (helpers/property.ts, H-10; fixed seed set +// in CI, E-5) produces small random workspaces spanning P-13's stated input +// space — spec and code groups; `depends`, `embeds`, and `references` edges; +// tags; `coverage="none"`; root-sourced and root-targeted edges — plus 1–3 +// random coverage profiles over every 7.4 knob (`mode`, `targets` omitted / +// `"leaves"` / `"all"`, `targetTags` omitted or drawn — a tag no node +// carries included — `edgeKinds` omitted or any non-empty subset, spec and +// code boundaries, boundary∩target overlap included), builds the workspace, +// and asserts one `coverage --json` run against the independent +// SPEC 8/8.1/8.2 reachability oracle (helpers/oracles/coverage.ts, +// `computeCoverage` — S-6-vetted on SPEC 15's worked material before any +// trial trusts it, TEST-SPEC §17 S-6): per profile the four 8.2 counts, the +// covered set with one shortest covering path per node (boundary node +// first, permitted kinds only, `contains`-free and root-free, equal-length +// ties by the element-wise 12.0 byte-least sequence — the tie-break's +// minimum is unique, so exact path equality is exactly P-13's "every +// reported covering path is a permitted path … shortest with the 12.0 +// tie-break"), the uncovered set, and the ignored set with all applicable +// exclusion reasons in the fixed 8.2 order. The required set is observed +// through covered ∪ uncovered plus the required count (SPEC 8.2 reports +// counts and the covered/uncovered/ignored identities; 8.1: required = +// covered ∪ uncovered). Oracle independence holds by construction: the +// oracle is fed the generator's own graph model — nodes, children, tags, +// coverage attributes, edges, group memberships — never anything read back +// from the product. +// +// Conservative operationalizations (H-3, the §8 suite's discipline): +// SPEC 8.2 fixes membership, per-node information, and counts — no row or +// profile order — so rows compare identity-byte sorted while covering paths +// compare as exact sequences; ignored-reason spellings are output shape, +// mapped onto the four 8.2 reason identities order-preservingly by +// `classifyIgnoredReasons` (fail-loud, never defaulting); profiles are +// matched by name after asserting the report carries exactly the configured +// profile names (8.2: all profiles run by default). +// +// Validity by construction (every trial's `build` must exit 0 — a valid +// workspace is P-13's input space; SPEC 5.3, 2.1): every node gets a rank — +// file index, then post-order position within the file (children before +// parents, the root last) — and every drawn reference targets a strictly +// lower rank in the same file or any node of an earlier file. All edges +// then strictly decrease the (file, post-order) key — `contains` edges +// parent→child included — so the combined contains/depends/embeds graph is +// acyclic, no section depends on or embeds an ancestor or itself, and spec +// imports (each file imports exactly the earlier files) cannot cycle; code +// locations source edges to arbitrary spec nodes (roots included) and are +// never edge targets, so they cannot cycle either. IDs are structural +// dotted paths unique per file (1.3); every reference targets a staged node +// of a discovered file (every spec and code file belongs to at least one +// group — membership repair appends uncovered files to the first group); +// spec and code directories are disjoint (7.2) and group names distinct, so +// `boundaryKind` is always inferable (7.4). Rendering follows the proven +// fixture discipline: import lines form one ESM block followed by a +// mandatory blank line (the FP-094 lesson), root-sourced embeddings are +// top-level `{text(…)}` flow-expression blocks (T8-5's staging), in-section +// embeddings sit blank-line-separated in the body (T8-2's staging), and +// nested sections spell full dotted IDs (T8-2). Root-targeted edges are the +// module-form `d={M<j>}` / `{text(M<j>)}` spellings (2.2, 2.3) and code +// markers/`text` calls naming a module binding alone (4.5); root-sourced +// edges are the top-level embeddings. Section segments are drawn from +// deliberately non-sorted pools (document order k,d,t vs byte order d,k,t) +// so identity byte order and graph structure decouple and the 12.0 +// tie-break is exercised on real ties. +// +// An implementation-time dry-run over the committed default seeds at the +// registered 8 runs per seed (24 CI-pinned trials, E-5) verified that every +// staged MDX source parses under remark-mdx with its imports as real ESM +// blocks, every staged TypeScript source parses cleanly, every oracle input +// passes the oracle's misuse guards (acyclicity included), and every input +// class occurs: all three edge kinds, tags, coverage="none", +// root-sourced and root-targeted edges, code files and code boundaries, +// spec boundaries, boundary∩target overlap, both modes, targets +// "leaves"/"all"/omitted, targetTags present (a no-node tag included) and +// omitted, edgeKinds restricted and omitted, all four ignored reasons +// (multi-reason rows included), non-empty covered/uncovered/ignored sets, +// multi-edge transitive paths (11 covered rows), and covered nodes whose +// shortest covering path is tie-broken among several equal-length +// candidates (16 rows). The previous iteration's built product (whose +// coverage engine predates this patch) accepts all 24 workspaces (`build` +// exit 0 — the validity-by-construction proof) and agrees with the oracle +// on all their profile runs, while six implementation-time teeth probes +// (each reverted) all falsified the property against that product: +// transitive-run-as-direct, coverage="none" dropped, tags dropped, children +// (leaf judgment) dropped, code-sourced edges dropped, and reported paths +// reversed — the last failing the covered-path assertion specifically. +// +// P-13 is expressly outside every CERTIFICATIONS.md fixture scope (its +// Exclusions name P-13 directly: the anchors are loud positive fixtures and +// the oracle is S-6-vetted), so this body binds only to the real product +// surface. + +import { Buffer } from "node:buffer"; +import type { CoverageProfileReport } from "../../helpers/adapters/index.js"; +import { + classifyIgnoredReasons, + decodeCoverageReport, +} from "../../helpers/adapters/index.js"; +import { fail } from "../../helpers/assertions.js"; +import type { + CoverageOracleEdge, + CoverageOracleEdgeKind, + CoverageOracleInput, + CoverageOracleNode, + CoverageOracleResult, +} from "../../helpers/oracles/coverage.js"; +import { computeCoverage } from "../../helpers/oracles/coverage.js"; +import type { Choices, Gen } from "../../helpers/property.js"; +import { checkProperty } from "../../helpers/property.js"; +import { defineProductTest } from "../../helpers/registry.js"; +import type { ProductTestEntry } from "../../helpers/registry.js"; +import type { ProductBinding } from "../../helpers/subprocess.js"; +import { TestWorkspace } from "../../helpers/workspace.js"; +import { assertSameJson, buildOk, runJson } from "./support.js"; + +// --------------------------------------------------------------------------- +// Fixed naming pools (module header: segment pools deliberately non-sorted). + +/** Spec source paths by file index (each file in its own directory, 7.1). */ +const SPEC_PATHS = ["s0/A.mdx", "s1/B.mdx", "s2/C.mdx"] as const; +/** The corresponding import specifier stems (`DIR/NAME.xspec`, SPEC 2.1). */ +const SPEC_XSPEC = ["s0/A.xspec", "s1/B.xspec", "s2/C.xspec"] as const; +/** Code source paths by file index (disjoint directories, SPEC 7.2). */ +const CODE_PATHS = ["c0/U.ts", "c1/V.ts"] as const; + +/** Top-level ID segments: document order k, d, t — byte order d, k, t. */ +const TOP_SEGMENTS = ["k", "d", "t"] as const; +/** Child segments: document order m, b — byte order b, m. */ +const CHILD_SEGMENTS = ["m", "b"] as const; +/** Grandchild segment (depth cap 2). */ +const GRAND_SEGMENT = "x"; +/** Named-unit (function) names per code file (unique — no `@N`, 4.6). */ +const UNIT_NAMES = ["f", "g"] as const; + +/** Section tag sets (SPEC 2.6); the empty (omitted-prop) set first. */ +const TAG_SETS: ReadonlyArray<readonly string[]> = [ + [], + ["red"], + ["blu"], + ["red", "blu"], +]; +/** Profile targetTags menus (7.4) — `zz` is a tag no node ever carries. */ +const TARGET_TAG_SETS: ReadonlyArray<readonly string[]> = [ + ["red"], + ["blu"], + ["red", "blu"], + ["zz"], + ["blu", "zz"], +]; +/** Non-empty edgeKinds subsets (7.4), singletons first. */ +const KIND_SETS: ReadonlyArray<readonly CoverageOracleEdgeKind[]> = [ + ["depends"], + ["embeds"], + ["references"], + ["depends", "embeds"], + ["depends", "references"], + ["embeds", "references"], + ["depends", "embeds", "references"], +]; + +/** Spec group names by group index; disjoint from code group names (7.4). */ +const SPEC_GROUP_NAMES = ["sa", "sb", "sc"] as const; +const CODE_GROUP_NAMES = ["ka", "kb"] as const; +/** Non-empty index subsets of {0..n-1}, singletons (simplest) first. */ +const NONEMPTY_SUBSETS: ReadonlyArray<ReadonlyArray<readonly number[]>> = [ + [[0]], + [[0], [1], [0, 1]], + [[0], [1], [2], [0, 1], [0, 2], [1, 2], [0, 1, 2]], +]; + +// --------------------------------------------------------------------------- +// The trial model. + +/** One requirement section (SPEC 1.1/1.3): full dotted ID and identity. */ +export interface P13Section { + /** The node identity `path#id` (SPEC 1.5). */ + readonly identity: string; + /** The full dotted ID (structural path, SPEC 1.3). */ + readonly id: string; + readonly tags: readonly string[]; + /** The spelled coverage attribute; `null` = none spelled (SPEC 2.5). */ + readonly coverage: "required" | "none" | null; + /** `d`-prop target identities (depends edges, SPEC 2.2), deduplicated. */ + readonly dRefs: readonly string[]; + /** In-body `{text(…)}` target identities (embeds edges, SPEC 2.3). */ + readonly embeds: readonly string[]; + readonly children: readonly P13Section[]; +} + +/** One spec source file. */ +export interface P13SpecFile { + readonly index: number; + readonly path: string; + /** Top-level `{text(…)}` targets — root-sourced embeds edges (2.3, 8). */ + readonly rootEmbeds: readonly string[]; + readonly sections: readonly P13Section[]; +} + +/** One TypeScript statement recording an edge (SPEC 4.3, 4.5). */ +export interface P13CodeStatement { + /** `marker` → references edge; `text` → embeds edge. */ + readonly kind: "marker" | "text"; + /** The target node identity (a root identity = module-form spelling). */ + readonly target: string; +} + +/** One code source file (SPEC 4.6: file location + named units). */ +export interface P13CodeFile { + readonly index: number; + readonly path: string; + /** Top-level statements, attributed to the whole-file location (4.6). */ + readonly topLevel: readonly P13CodeStatement[]; + readonly units: ReadonlyArray<{ + readonly name: string; + readonly statements: readonly P13CodeStatement[]; + }>; +} + +/** One coverage profile (SPEC 7.4); `null` members are omitted from config. */ +export interface P13Profile { + readonly name: string; + /** A spec group name. */ + readonly target: string; + /** A spec or code group name (names are disjoint — kind inferable, 7.4). */ + readonly boundary: string; + readonly mode: "direct" | "transitive"; + readonly targets: "leaves" | "all" | null; + readonly targetTags: readonly string[] | null; + readonly edgeKinds: readonly CoverageOracleEdgeKind[] | null; +} + +/** One generated trial: the whole workspace and profile model. */ +export interface P13Trial { + readonly specFiles: readonly P13SpecFile[]; + readonly codeFiles: readonly P13CodeFile[]; + /** Spec groups: name → member spec-file indices (deduplicated). */ + readonly specGroups: ReadonlyArray<readonly [string, readonly number[]]>; + /** Code groups: name → member code-file indices (deduplicated). */ + readonly codeGroups: ReadonlyArray<readonly [string, readonly number[]]>; + readonly profiles: readonly P13Profile[]; +} + +// --------------------------------------------------------------------------- +// Generation (module header: structure pass, then rank-disciplined refs). + +interface MutableSection { + identity: string; + id: string; + tags: readonly string[]; + coverage: "required" | "none" | null; + dRefs: string[]; + embeds: string[]; + children: MutableSection[]; +} + +/** Draw one file's section tree (structure only; refs come later). */ +function genSectionTree( + choices: Choices, + path: string, +): readonly MutableSection[] { + const section = (id: string): MutableSection => ({ + identity: `${path}#${id}`, + id, + tags: choices.pick(TAG_SETS), + coverage: choices.weightedPick<"required" | "none" | null>([ + [5, null], + [2, "none"], + [1, "required"], + ]), + dRefs: [], + embeds: [], + children: [], + }); + const topCount = choices.weightedPick<number>([ + [1, 1], + [3, 2], + [3, 3], + ]); + const tops: MutableSection[] = []; + for (let t = 0; t < topCount; t += 1) { + const top = section(TOP_SEGMENTS[t]); + const childCount = choices.weightedPick<number>([ + [4, 0], + [3, 1], + [2, 2], + ]); + for (let c = 0; c < childCount; c += 1) { + const child = section(`${top.id}.${CHILD_SEGMENTS[c]}`); + if (choices.boolean(0.3)) { + child.children.push(section(`${child.id}.${GRAND_SEGMENT}`)); + } + top.children.push(child); + } + tops.push(top); + } + return tops; +} + +/** Post-order section list (children before parents; module header rank). */ +function postOrder(sections: readonly MutableSection[]): MutableSection[] { + const out: MutableSection[] = []; + const visit = (section: MutableSection): void => { + for (const child of section.children) visit(child); + out.push(section); + }; + for (const section of sections) visit(section); + return out; +} + +/** Document-order section list (parents before children). */ +function docOrder<T extends { readonly children: readonly T[] }>( + sections: readonly T[], +): T[] { + const out: T[] = []; + const visit = (section: T): void => { + out.push(section); + for (const child of section.children) visit(child); + }; + for (const section of sections) visit(section); + return out; +} + +/** Draw up to `max` distinct targets from a non-empty menu. */ +function drawTargets( + choices: Choices, + menu: readonly string[], + countEntries: ReadonlyArray<readonly [number, number]>, +): string[] { + const count = choices.weightedPick(countEntries); + const targets: string[] = []; + for (let i = 0; i < count; i += 1) { + const target = choices.pick(menu); + if (!targets.includes(target)) targets.push(target); + } + return targets; +} + +/** The P-13 trial generator (module header). */ +export const genP13Trial: Gen<P13Trial> = (choices) => { + // --- spec structure pass ------------------------------------------------- + const specFileCount = choices.weightedPick<number>([ + [2, 1], + [4, 2], + [3, 3], + ]); + const trees: (readonly MutableSection[])[] = []; + for (let i = 0; i < specFileCount; i += 1) { + trees.push(genSectionTree(choices, SPEC_PATHS[i])); + } + + // --- rank-disciplined reference pass (module header) --------------------- + const externalMenu: string[] = []; // all nodes of files before the current + const specFiles: P13SpecFile[] = []; + for (let i = 0; i < specFileCount; i += 1) { + const ordered = postOrder(trees[i]); + const seen: string[] = []; // same-file lower-rank identities + for (const section of ordered) { + const menu = [...seen, ...externalMenu]; + if (menu.length > 0) { + section.dRefs = drawTargets(choices, menu, [ + [3, 0], + [5, 1], + [2, 2], + ]); + section.embeds = drawTargets(choices, menu, [ + [4, 0], + [3, 1], + ]); + } + seen.push(section.identity); + } + const rootMenu = [...seen, ...externalMenu]; + const rootEmbeds = drawTargets(choices, rootMenu, [ + [4, 0], + [2, 1], + ]); + specFiles.push({ + index: i, + path: SPEC_PATHS[i], + rootEmbeds, + sections: trees[i], + }); + externalMenu.push(SPEC_PATHS[i], ...seen); // root + sections, now earlier + } + const allSpecNodes = [...externalMenu]; // every spec identity, root first + + // --- code files (targets unrestricted: code is never a target, 5.2) ------ + const codeFileCount = choices.weightedPick<number>([ + [2, 0], + [3, 1], + [2, 2], + ]); + const codeFiles: P13CodeFile[] = []; + const statement = (): P13CodeStatement => ({ + kind: choices.pick(["marker", "text"] as const), + target: choices.pick(allSpecNodes), + }); + for (let i = 0; i < codeFileCount; i += 1) { + const topLevel: P13CodeStatement[] = []; + if (choices.boolean(0.4)) topLevel.push(statement()); + const unitCount = choices.intInclusive(1, 2); + const units: { name: string; statements: P13CodeStatement[] }[] = []; + for (let u = 0; u < unitCount; u += 1) { + const statementCount = choices.intInclusive(1, 2); + const statements: P13CodeStatement[] = []; + for (let s = 0; s < statementCount; s += 1) statements.push(statement()); + units.push({ name: UNIT_NAMES[u], statements }); + } + codeFiles.push({ index: i, path: CODE_PATHS[i], topLevel, units }); + } + + // --- groups (every file discovered: membership repair, module header) ---- + const drawGroups = ( + names: readonly string[], + fileCount: number, + countEntries: ReadonlyArray<readonly [number, number]>, + ): (readonly [string, readonly number[]])[] => { + const groupCount = choices.weightedPick(countEntries); + const subsets = NONEMPTY_SUBSETS[fileCount - 1]; + const members: number[][] = []; + for (let g = 0; g < groupCount; g += 1) { + members.push([...choices.pick(subsets)]); + } + for (let file = 0; file < fileCount; file += 1) { + if (!members.some((group) => group.includes(file))) { + members[0].push(file); // repair: keep every file discovered + } + } + return members.map((group, g) => [names[g], group.sort((a, b) => a - b)]); + }; + const specGroups = drawGroups(SPEC_GROUP_NAMES, specFileCount, [ + [3, 1], + [3, 2], + [1, 3], + ]); + const codeGroups = + codeFileCount === 0 + ? [] + : drawGroups(CODE_GROUP_NAMES, codeFileCount, [ + [3, 1], + [1, 2], + ]); + + // --- profiles ------------------------------------------------------------ + const specGroupNames = specGroups.map(([name]) => name); + const allGroupNames = [ + ...specGroupNames, + ...codeGroups.map(([name]) => name), + ]; + const profileCount = choices.weightedPick<number>([ + [3, 1], + [3, 2], + [1, 3], + ]); + const profiles: P13Profile[] = []; + for (let p = 0; p < profileCount; p += 1) { + profiles.push({ + name: `p${String(p + 1)}`, + target: choices.pick(specGroupNames), + boundary: choices.pick(allGroupNames), + mode: choices.weightedPick<"direct" | "transitive">([ + [2, "direct"], + [3, "transitive"], + ]), + targets: choices.weightedPick<"leaves" | "all" | null>([ + [4, null], + [1, "leaves"], + [3, "all"], + ]), + targetTags: choices.boolean(0.35) ? choices.pick(TARGET_TAG_SETS) : null, + edgeKinds: choices.boolean(0.35) ? choices.pick(KIND_SETS) : null, + }); + } + + return { specFiles, codeFiles, specGroups, codeGroups, profiles }; +}; + +// --------------------------------------------------------------------------- +// Rendering (module header: proven fixture staging discipline). + +/** Module index of a target identity's file, or a plain modeling error. */ +function specFileIndexOf(target: string): number { + const hash = target.indexOf("#"); + const path = hash === -1 ? target : target.slice(0, hash); + const index = SPEC_PATHS.indexOf(path as (typeof SPEC_PATHS)[number]); + if (index === -1) { + throw new Error(`P-13 model error: no spec file for target ${target}`); + } + return index; +} + +/** The dotted ID of a target identity, or `null` for a root identity. */ +function idOf(target: string): string | null { + const hash = target.indexOf("#"); + return hash === -1 ? null : target.slice(hash + 1); +} + +/** An MDX reference spelling (SPEC 2.2/2.3/2.4) for one target identity. */ +function mdxRef(fileIndex: number, target: string): string { + const id = idOf(target); + if (specFileIndexOf(target) === fileIndex) { + if (id === null) { + throw new Error( + `P-13 model error: a same-file reference cannot target the root ` + + `(rank discipline forbids it): ${target}`, + ); + } + return JSON.stringify(id); // local string form + } + const binding = `M${String(specFileIndexOf(target))}`; + return id === null ? binding : `${binding}.${id}`; // external chain form +} + +/** A TypeScript chain spelling rooted at the module binding (SPEC 4.5). */ +function tsChain(target: string): string { + const binding = `M${String(specFileIndexOf(target))}`; + const id = idOf(target); + return id === null ? binding : `${binding}.${id}`; +} + +function renderSectionLines(section: P13Section, fileIndex: number): string[] { + const attrs = [`id="${section.id}"`]; + if (section.tags.length > 0) attrs.push(`tags="${section.tags.join(" ")}"`); + if (section.coverage !== null) attrs.push(`coverage="${section.coverage}"`); + if (section.dRefs.length === 1) { + attrs.push(`d={${mdxRef(fileIndex, section.dRefs[0])}}`); + } else if (section.dRefs.length > 1) { + const refs = section.dRefs.map((target) => mdxRef(fileIndex, target)); + attrs.push(`d={[${refs.join(", ")}]}`); + } + const lines = [`<S ${attrs.join(" ")}>`, "body."]; + for (const target of section.embeds) { + lines.push("", `{text(${mdxRef(fileIndex, target)})}`); + } + for (const child of section.children) { + lines.push("", ...renderSectionLines(child, fileIndex)); + } + lines.push("</S>"); + return lines; +} + +function renderSpecFile(file: P13SpecFile): string { + const blocks: string[][] = []; + if (file.index > 0) { + const imports: string[] = []; + for (let j = 0; j < file.index; j += 1) { + imports.push(`import M${String(j)} from "../${SPEC_XSPEC[j]}"`); + } + blocks.push(imports); // one ESM block; the join adds its blank line + } + for (const target of file.rootEmbeds) { + blocks.push([`{text(${mdxRef(file.index, target)})}`]); + } + for (const section of file.sections) { + blocks.push(renderSectionLines(section, file.index)); + } + return `${blocks.map((block) => block.join("\n")).join("\n\n")}\n`; +} + +function renderStatement(statement: P13CodeStatement): string { + const chain = tsChain(statement.target); + if (statement.kind === "marker") return `${chain};`; + return `t${String(specFileIndexOf(statement.target))}(${chain});`; +} + +function renderCodeFile(file: P13CodeFile, specFileCount: number): string { + const lines: string[] = []; + for (let j = 0; j < specFileCount; j += 1) { + lines.push( + `import M${String(j)}, { text as t${String(j)} } from "../${SPEC_XSPEC[j]}";`, + ); + } + lines.push(""); + for (const statement of file.topLevel) lines.push(renderStatement(statement)); + for (const unit of file.units) { + lines.push("", `function ${unit.name}() {`); + for (const statement of unit.statements) { + lines.push(` ${renderStatement(statement)}`); + } + lines.push("}"); + } + return `${lines.join("\n")}\n`; +} + +function renderConfig(trial: P13Trial): string { + const groupLines = ( + groups: ReadonlyArray<readonly [string, readonly number[]]>, + glob: (index: number) => string, + ): string => + groups + .map( + ([name, members]) => + ` ${name}: [${members.map((index) => JSON.stringify(glob(index))).join(", ")}]`, + ) + .join(",\n"); + const profileLines = trial.profiles + .map((profile) => { + const members = [ + ` name: ${JSON.stringify(profile.name)}`, + ` target: ${JSON.stringify(profile.target)}`, + ` boundary: ${JSON.stringify(profile.boundary)}`, + ` mode: ${JSON.stringify(profile.mode)}`, + ]; + if (profile.targets !== null) { + members.push(` targets: ${JSON.stringify(profile.targets)}`); + } + if (profile.targetTags !== null) { + members.push(` targetTags: ${JSON.stringify(profile.targetTags)}`); + } + if (profile.edgeKinds !== null) { + members.push(` edgeKinds: ${JSON.stringify(profile.edgeKinds)}`); + } + return ` {\n${members.join(",\n")}\n }`; + }) + .join(",\n"); + const codeBlock = + trial.codeGroups.length === 0 + ? "" + : `,\n code: {\n${groupLines(trial.codeGroups, (index) => `c${String(index)}/**/*.ts`)}\n }`; + return `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { +${groupLines(trial.specGroups, (index) => `s${String(index)}/**/*.mdx`)} + }${codeBlock}, + coverage: [ +${profileLines} + ] +}) +`; +} + +/** Render the trial's whole staged file map (config + sources). */ +export function renderP13Files(trial: P13Trial): Record<string, string> { + const files: Record<string, string> = { + "xspec.config.ts": renderConfig(trial), + }; + for (const file of trial.specFiles) files[file.path] = renderSpecFile(file); + for (const file of trial.codeFiles) { + files[file.path] = renderCodeFile(file, trial.specFiles.length); + } + return files; +} + +/** Counterexample rendering: profiles plus the staged sources, in full. */ +export function renderP13Trial(trial: P13Trial): string { + return JSON.stringify({ + profiles: trial.profiles, + files: renderP13Files(trial), + }); +} + +// --------------------------------------------------------------------------- +// The oracle bridge (module header: fed the generator's own model only). + +interface TrialGraph { + readonly nodes: ReadonlyMap<string, CoverageOracleNode>; + readonly edges: readonly CoverageOracleEdge[]; + /** Group name → full node membership (roots included, SPEC 7.1/8.2). */ + readonly groupMembers: ReadonlyMap<string, readonly string[]>; +} + +function trialGraph(trial: P13Trial): TrialGraph { + const nodes = new Map<string, CoverageOracleNode>(); + const edges: CoverageOracleEdge[] = []; + const specFileNodes: string[][] = []; + for (const file of trial.specFiles) { + const sections = docOrder(file.sections); + nodes.set(file.path, { + root: true, + children: file.sections.map((section) => section.identity), + coverage: null, + tags: [], + }); + for (const section of sections) { + nodes.set(section.identity, { + root: false, + children: section.children.map((child) => child.identity), + coverage: section.coverage, + tags: section.tags, + }); + for (const target of section.dRefs) { + edges.push({ source: section.identity, target, kind: "depends" }); + } + for (const target of section.embeds) { + edges.push({ source: section.identity, target, kind: "embeds" }); + } + } + for (const target of file.rootEmbeds) { + edges.push({ source: file.path, target, kind: "embeds" }); + } + specFileNodes.push([ + file.path, + ...sections.map((section) => section.identity), + ]); + } + const codeFileNodes: string[][] = []; + for (const file of trial.codeFiles) { + const locations: string[] = []; + const location = (identity: string): void => { + locations.push(identity); + nodes.set(identity, { + root: false, + children: [], + coverage: null, + tags: [], + }); + }; + const record = (source: string, statement: P13CodeStatement): void => { + edges.push({ + source, + target: statement.target, + kind: statement.kind === "marker" ? "references" : "embeds", + }); + }; + if (file.topLevel.length > 0) { + location(file.path); // the whole-file location sources edges (4.6) + for (const statement of file.topLevel) record(file.path, statement); + } + for (const unit of file.units) { + const identity = `${file.path}#${unit.name}`; + location(identity); + for (const statement of unit.statements) record(identity, statement); + } + codeFileNodes.push(locations); + } + const groupMembers = new Map<string, readonly string[]>(); + for (const [name, members] of trial.specGroups) { + groupMembers.set( + name, + members.flatMap((index) => specFileNodes[index]), + ); + } + for (const [name, members] of trial.codeGroups) { + groupMembers.set( + name, + members.flatMap((index) => codeFileNodes[index]), + ); + } + return { nodes, edges, groupMembers }; +} + +/** Per profile, the oracle input mirroring the staged configuration. */ +export function p13OracleInputs(trial: P13Trial): ReadonlyArray<{ + readonly profile: P13Profile; + readonly input: CoverageOracleInput; +}> { + const graph = trialGraph(trial); + const membersOf = (name: string): readonly string[] => { + const members = graph.groupMembers.get(name); + if (members === undefined) { + throw new Error(`P-13 model error: profile names unknown group ${name}`); + } + return members; + }; + return trial.profiles.map((profile) => ({ + profile, + input: { + nodes: graph.nodes, + edges: graph.edges, + targetGroup: membersOf(profile.target), + boundaryGroup: membersOf(profile.boundary), + profile: { + mode: profile.mode, + ...(profile.targets !== null ? { targets: profile.targets } : {}), + ...(profile.targetTags !== null + ? { targetTags: profile.targetTags } + : {}), + ...(profile.edgeKinds !== null ? { edgeKinds: profile.edgeKinds } : {}), + }, + }, + })); +} + +// --------------------------------------------------------------------------- +// The property body. + +/** Byte-wise UTF-8 identity comparison (SPEC 12.0; oracle row order). */ +function compareIdentityBytes(a: string, b: string): number { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + +function describeProfile(profile: P13Profile): string { + const parts = [ + `target=${profile.target}`, + `boundary=${profile.boundary}`, + `mode=${profile.mode}`, + ]; + if (profile.targets !== null) parts.push(`targets=${profile.targets}`); + if (profile.targetTags !== null) { + parts.push(`targetTags=${profile.targetTags.join("|")}`); + } + if (profile.edgeKinds !== null) { + parts.push(`edgeKinds=${profile.edgeKinds.join("|")}`); + } + return `${profile.name} (${parts.join(", ")})`; +} + +/** One profile's decoded report must equal the oracle's result (8, 8.1, 8.2). */ +function assertProfileMatchesOracle( + actual: CoverageProfileReport, + expected: CoverageOracleResult, + context: string, +): void { + assertSameJson( + actual.counts, + expected.counts, + `${context}: the counts of required, covered, uncovered, and ignored ` + + `nodes must equal the oracle's — required = the target group ` + + `restricted per 8.1, covered/uncovered = its reachability split per ` + + `8, ignored = the excluded target-group nodes (SPEC 8.1, 8.2)`, + ); + assertSameJson( + actual.covered + .map((row) => ({ identity: row.identity, path: [...row.path] })) + .sort((a, b) => compareIdentityBytes(a.identity, b.identity)), + expected.covered, + `${context}: the covered set with one shortest covering path per node — ` + + `boundary node first, target last, one edge in direct mode and one or ` + + `more in transitive, only the profile's edgeKinds, contains edges and ` + + `root nodes never appearing, equal-length ties resolved to the least ` + + `element-wise byte sequence (SPEC 8, 8.2, 12.0)`, + ); + assertSameJson( + [...actual.uncovered].sort(compareIdentityBytes), + expected.uncovered, + `${context}: the uncovered set — required nodes with no permitted path ` + + `from a boundary node (boundary membership alone covers nothing) ` + + `(SPEC 8, 8.1, 8.2)`, + ); + assertSameJson( + actual.ignored + .map((row) => ({ + identity: row.identity, + reasons: classifyIgnoredReasons( + row.reasons, + `${context} ignored ${row.identity}`, + ), + })) + .sort((a, b) => compareIdentityBytes(a.identity, b.identity)), + expected.ignored, + `${context}: the ignored set — the target group's nodes excluded from ` + + `the required set, each with all applicable exclusion reasons in the ` + + `fixed order root node, coverage="none", non-leaf under targets: ` + + `"leaves", lacking every targetTags tag (SPEC 8.1, 8.2)`, + ); +} + +/** The P-13 property body for one generated trial (module header). */ +async function runP13Trial( + product: ProductBinding, + trial: P13Trial, +): Promise<void> { + const workspace = await TestWorkspace.create({ + files: renderP13Files(trial), + }); + try { + await buildOk( + product, + workspace, + `P-13 \`xspec build\` — the generated workspace is valid by ` + + `construction (rank-disciplined references, resolving targets, ` + + `structural IDs, acyclic imports), so build must succeed`, + ); + const label = "P-13 `xspec coverage --json`"; + const report = decodeCoverageReport( + await runJson(product, workspace, ["coverage", "--json"], label), + label, + ); + assertSameJson( + report.profiles.map((profile) => profile.name).sort(), + trial.profiles.map((profile) => profile.name).sort(), + `${label}: \`coverage\` runs all configured profiles by default, so ` + + `the report carries exactly the configured profile names (SPEC 8.2)`, + ); + for (const { profile, input } of p13OracleInputs(trial)) { + const reported = report.profiles.find( + (candidate) => candidate.name === profile.name, + ); + if (reported === undefined) { + // Unreachable after the name-set assertion; guard for diagnosis. + fail(`${label}: profile ${profile.name} missing from the report`); + } + assertProfileMatchesOracle( + reported, + computeCoverage(input), + `${label} profile ${describeProfile(profile)}`, + ); + } + } finally { + await workspace.dispose(); + } +} + +// --------------------------------------------------------------------------- +// The registered property test. + +const P_13 = defineProductTest({ + id: "P-13", + title: + "property: on random workspaces (spec and code groups; depends, embeds, " + + 'and references edges; tags; coverage="none"; root-sourced and ' + + "root-targeted edges) under random profiles (mode, targets, targetTags, " + + "edgeKinds, spec and code boundaries), `coverage --json`'s required, " + + "covered, uncovered, and ignored sets — the four counts, all applicable " + + "exclusion reasons in the fixed order, and one shortest covering path " + + "per covered node with the 12.0 element-wise byte tie-break — equal an " + + "independent oracle implementing 8.1's required set and 8's " + + "reachability over the generator's own graph model (SPEC 8, 8.1, 8.2, " + + "7.4, 12.0; TEST-SPEC §16 P-13)", + // Wall-clock hang guard only (H-10): 8 trials per seed over the 3 fixed + // seeds (E-5), two product invocations per trial (build + coverage), with + // the shrink budget sized against whole-trial re-execution cost. + timeoutMs: 300_000, + run: async (product) => { + await checkProperty( + "P-13 coverage oracle", + genP13Trial, + async (trial) => { + await runP13Trial(product, trial); + }, + { runs: 8, maxShrinkExecutions: 30, render: renderP13Trial }, + ); + }, +}); + +/** TEST-SPEC §16 P-13 (PROP-11). */ +export const section16P13Tests: readonly ProductTestEntry[] = [P_13]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 10fa9b4..2281fd8 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -440,4 +440,5 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "P-10": ["6.1", "13.5"], "P-11": ["11.2", "11.4", "12.7"], "P-12": ["5.7", "11.5"], + "P-13": ["7.4", "8", "8.1", "8.2"], }; diff --git a/test/suite/section-16-p13.test.ts b/test/suite/section-16-p13.test.ts new file mode 100644 index 0000000..30e4cd3 --- /dev/null +++ b/test/suite/section-16-p13.test.ts @@ -0,0 +1,9 @@ +// TEST-SPEC §16 P-13 (PROP-11): thin Vitest wrapper over the registered +// property test — the identical body the certification runner executes +// against fixture products (C-2 "one code path"). Expected to fail as a +// diagnosed assertion failure until the product exists (H-8). + +import { declareProductTests } from "./declare.js"; +import { section16P13Tests } from "./registry/section-16-p13.js"; + +declareProductTests(section16P13Tests); From 3b81b121f5dae48b08185dcb28fb3bbe1dcacc02 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 18:16:58 +0000 Subject: [PATCH 136/226] sdg(phase-9): build the CONF-AVAIL certification family (FP-091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test/fixtures/conf-avail/: plain Node ESM conformer (bin.mjs + product.mjs) implementing CERTIFICATIONS.md CONF-AVAIL's scope — `view` (bare, operand, --file, --text) and `occurrences` (bare, --file, --to) in the form-exact 12.7 document forms, the 11.2 availability rules (spelled-identity definedness with chain conditions and own-only uniqueness, interpreted tags/coverage, resolution through defined identities with 5.7 occurrence records whose source is withheld as one datum, expansion poisoning over the attributed SPEC 3 compile, removal by syntactic form), findings with stable codes in the pinned order, and the any-finding-or-marker exit discipline — plus the three violators, each one deviation through the options seam: bin-nullmarker.mjs (markers carried as null), bin-omit.mjs (null members omitted), bin-nofile.mjs (--file restriction dropped on occurrences). Wire the manifest (certification-fixtures.ts: fifth conformer, IDs verbatim) and flip the whole-document pins (certification-document.test.ts: 4/13 -> 5/16; certification.test.ts header prose). Verified: conformer passes all six in-scope tests; each violator fails exactly its certified set as diagnosed assertion failures (no errors, no hangs); `npm run test:self` fully green — 330 passed — clearing the two remaining planned mid-loop reds. Typecheck and format:check clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 30 +- test/fixtures/conf-avail/bin-nofile.mjs | 18 + test/fixtures/conf-avail/bin-nullmarker.mjs | 17 + test/fixtures/conf-avail/bin-omit.mjs | 18 + test/fixtures/conf-avail/bin.mjs | 10 + test/fixtures/conf-avail/product.mjs | 2641 +++++++++++++++++++ test/self/certification-document.test.ts | 6 +- test/self/certification-fixtures.ts | 47 + test/self/certification.test.ts | 4 +- 9 files changed, 2785 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/conf-avail/bin-nofile.mjs create mode 100644 test/fixtures/conf-avail/bin-nullmarker.mjs create mode 100644 test/fixtures/conf-avail/bin-omit.mjs create mode 100644 test/fixtures/conf-avail/bin.mjs create mode 100644 test/fixtures/conf-avail/product.mjs diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 3a5c50e..f147d59 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -4035,7 +4035,7 @@ certify against FP-091's fixtures once those land. ## Stage I — CONF-AVAIL certification family -- [ ] FP-091 — Build the CONF-AVAIL fixture family, wire the manifest, and +- [x] FP-091 — Build the CONF-AVAIL fixture family, wire the manifest, and flip the whole-document pins. [R3 gaps 1–2, R2 #40, VERIFY; CERTIFICATIONS.md CONF-AVAIL; TEST-SPEC §17 C-1/C-2] After the six in-scope tests are registered (FP-051, FP-053, FP-059, @@ -4056,6 +4056,34 @@ certify against FP-091's fixtures once those land. certification (conformer passes every in-scope test; each violator fails at least one certified test) — this task clears the three VERIFY certification-document failures. + [Done 2026-08-29: test/fixtures/conf-avail/ — product.mjs (~2k lines, + ports of the CONF-MD/CONF-DISC config+glob+discovery and attributed + line-model compile plus CONF-VALID's 1.4 predicate) with bin.mjs and the + three violator bins (bin-nullmarker.mjs / bin-omit.mjs / bin-nofile.mjs), + each threading exactly one deviation switch through runXspec's options + seam: `nullMarkers` and `omitNullMembers` hook the single serialization + point (exit codes computed pre-transform, so only bytes deviate); + `ignoreFileRestriction` hooks commandOccurrences' domain computation. + Conformer serves `view` (bare/operand/--file/--text) and `occurrences` + (bare/--file/--to) per 11.2-11.4: full positional trees with tag + decompositions and every spelled attribute (quoted/braced/valueless/ + spread) as {name, range, text}, spelled-identity definedness (exactly one + quoted static id; chain spelling+well-formedness+conformance; own-only + uniqueness), interpreted tags/coverage three-state datums, invalid + elements as 14.16 content with innermost-SECTION parenting, imports of + all four binding forms at MDX ESM block positions (target from specifier + form + discovery alone), resolution through defined identities recording + 5.7 occurrences (source withheld as ONE datum where undefined), SCC-based + 14.9, expansion poisoning via tri-state subtree/own definedness memos over + the ownership-attributed compile, findings in the pinned 12.7 order with + stable tokens, and the any-finding-or-marker exit-1 discipline. + certification-fixtures.ts appended verbatim; pins 4/13 → 5/16 (plus the + certification.test.ts header prose). Verified: conformer 6/6 pass; + NULLMARKER fails exactly its 4, OMIT exactly its 5, NOFILE exactly + T11.3-4 (its restricted arm), all as diagnosed assertion failures, no + errors/hangs; `npm run test:self` FULLY GREEN — 330 passed, 0 failed — + clearing the plan's 2 remaining mid-loop reds; typecheck + format:check + clean.] ## Stage J — E-6 cross-platform legs diff --git a/test/fixtures/conf-avail/bin-nofile.mjs b/test/fixtures/conf-avail/bin-nofile.mjs new file mode 100644 index 0000000..c7f1c41 --- /dev/null +++ b/test/fixtures/conf-avail/bin-nofile.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +// VIOL-AVAIL-NOFILE violator executable (CERTIFICATIONS.md +// §VIOL-AVAIL-NOFILE). The CONF-AVAIL conformer with exactly one +// behavioral deviation: `occurrences` does not apply the `--file` +// restriction — the flag and its argument checks behave as specified +// (SPEC 11.3), but the consulted domain is the entire discovered set, +// exactly as with the flag absent; the enumeration and the findings +// accompanying it follow that widened domain. `--to` selection, `view`, +// and every other behavior are unchanged. Certifies exactly T11.3-4 (C-1): +// its restricted arm enumerates the occurrence `--file` excludes and fails +// the exact-empty compare; every other §CONF-AVAIL in-scope test passes +// (none drives `occurrences` with `--file`). +import { runXspec } from "./product.mjs"; + +const code = await runXspec(process.argv.slice(2), process.cwd(), { + ignoreFileRestriction: true, +}); +process.exit(code); diff --git a/test/fixtures/conf-avail/bin-nullmarker.mjs b/test/fixtures/conf-avail/bin-nullmarker.mjs new file mode 100644 index 0000000..714d9e9 --- /dev/null +++ b/test/fixtures/conf-avail/bin-nullmarker.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +// VIOL-AVAIL-NULLMARKER violator executable (CERTIFICATIONS.md +// §VIOL-AVAIL-NULLMARKER). The CONF-AVAIL conformer with exactly one +// behavioral deviation: the unavailability marker is never emitted — every +// datum the rules of SPEC 11.2 leave undefined is carried as `null` in +// place of {"unavailable": true} (12.7). Which data are undefined, all +// defined values, findings, exit codes, and every other document member are +// unchanged. Certifies T11.2-2, T11.2-4, T11.4-3, and T11.4-4 (C-1): +// exactly they fail against this fixture; every other §CONF-AVAIL in-scope +// test passes (T11.4-1's fixtures stage no undefined datum; T11.3-4's +// answers are empty enumerations). +import { runXspec } from "./product.mjs"; + +const code = await runXspec(process.argv.slice(2), process.cwd(), { + nullMarkers: true, +}); +process.exit(code); diff --git a/test/fixtures/conf-avail/bin-omit.mjs b/test/fixtures/conf-avail/bin-omit.mjs new file mode 100644 index 0000000..d7f1b89 --- /dev/null +++ b/test/fixtures/conf-avail/bin-omit.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +// VIOL-AVAIL-OMIT violator executable (CERTIFICATIONS.md §VIOL-AVAIL-OMIT). +// The CONF-AVAIL conformer with exactly one behavioral deviation: +// `null`-valued members are omitted — every member whose value an answer +// would carry as the stated `null` (SPEC 12.7) is absent from the emitted +// document (a viewed root's `tags` and `coverage` and a located finding's +// `path` among them). Members with plain, marker, or list values, which +// findings exist, and exit codes are unchanged. Certifies T11.2-2, +// T11.2-4, T11.4-1, T11.4-3, and T11.4-4 (C-1): exactly they fail against +// this fixture — every in-scope test that decodes a `view` answer — and +// T11.3-4 passes (its two answers are empty enumerations carrying no +// `null`-valued member to omit). +import { runXspec } from "./product.mjs"; + +const code = await runXspec(process.argv.slice(2), process.cwd(), { + omitNullMembers: true, +}); +process.exit(code); diff --git a/test/fixtures/conf-avail/bin.mjs b/test/fixtures/conf-avail/bin.mjs new file mode 100644 index 0000000..fcc59d7 --- /dev/null +++ b/test/fixtures/conf-avail/bin.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node +// CONF-AVAIL conformer executable (CERTIFICATIONS.md §CONF-AVAIL). The +// certification runner drives this file exactly as it drives the built +// product — an executable/workspace binding and nothing else (TEST-SPEC C-2). +// Violator fixtures (VIOL-AVAIL-*) reuse product.mjs with exactly one +// behavioral deviation each; this entry runs the conformer, deviation-free. +import { runXspec } from "./product.mjs"; + +const code = await runXspec(process.argv.slice(2), process.cwd(), {}); +process.exit(code); diff --git a/test/fixtures/conf-avail/product.mjs b/test/fixtures/conf-avail/product.mjs new file mode 100644 index 0000000..76f8830 --- /dev/null +++ b/test/fixtures/conf-avail/product.mjs @@ -0,0 +1,2641 @@ +// CONF-AVAIL conformer fixture (CERTIFICATIONS.md §CONF-AVAIL; TEST-SPEC 17 +// C-1/C-2). A harness-owned executable product implementing §CONF-AVAIL's +// Scope with the simplest conforming behavior — driven only through the C-2 +// executable/workspace binding, never importing product code (the product and +// the harness are distinct programs; this fixture is part of the harness). +// +// Scope implemented (see CERTIFICATIONS.md §CONF-AVAIL): +// - Workspaces of configured spec groups of `.mdx` sources at valid-UTF-8, +// `#`-free workspace-relative paths — imports (2.1), `d` props, and +// `{text(...)}` embeddings as the in-scope fixtures stage them; no code +// groups, `markdown`, `coverage`, `policy`, or git. +// - Command surface: `view`, with and without `--text` — the bare +// whole-domain form (neither operands nor `--file`: every discovered spec +// source viewed, 11.4) and the operand and `--file` forms — and +// `occurrences` — the bare unrestricted form (the entire discovered set, +// 11.3) and `--file`/`--to` — each answering in the form-exact 12.7 +// document forms. `at` (the 11.2 preamble's third surface) is NOT served: +// no in-scope staging drives it (the scope's stated staging constraint). +// - Contracts under certification: the availability rules of 11.2 — +// parse-local structure and positional trees (a section inside an invalid +// non-section element parenting to the innermost enclosing SECTION +// construct, 11.4), spelled-identity definedness (exactly one quoted +// static `id`), the chain conditions (spelling, well-formedness, +// structural conformance inherited through the positional section +// enclosure; uniqueness constraining the section's OWN spelled identity +// alone), interpreted tags and coverage, resolution through defined +// identities (a reference resolves exactly when it names exactly one +// target whose own node identity is defined — never a picked bearer, +// never an unavailable target), whole-value expansion poisoning with own +// and subtree text per the rules of 3 (1.6; emission out of scope), and +// removal classification by syntactic form; occurrence records per +// 5.7/11.3 with `source` withheld as ONE datum where undefined; the +// `--file` domain restriction and `--to` selection of 11.3; the raw +// attribute and import data of 11.4; findings per 11.2/14 with stable +// codes and located ranges for the staged conditions (14.1, 14.3, 14.4, +// 14.5, 14.6, 14.9, 14.15, 14.16, 14.17); and the exit discipline of 11.2 +// (any finding or explicitly-unavailable datum in the emitted answer → +// exit 1 with the full answer still emitted; complete and finding-free → +// exit 0). Graph data and refresh behavior are out of scope: the two +// commands read sources and write NOTHING. +// +// Key mechanisms: +// - Configuration, glob matching, and discovery are ports of the CONF-MD / +// CONF-DISC fixtures' machinery (SPEC 7): patterns resolve relative to the +// configuration file's directory; `*`, `?`, `**`, the dot-segment rule, +// byte-wise case-sensitive matching, every other character a literal; +// discovery walks plain files (symbolic links never discovered, never +// traversed) and applies the 13.4 derived-path exclusion. +// - Sources are scanned by an MDX-lite parser for exactly the scope's +// constructs: spec-module import declarations at MDX ESM block positions +// (file start, after a blank line, or continuing a run of import lines — +// an `import` line inside a paragraph is prose, never a declaration), +// `<S>`/`<Spec>` sections (paired and self-closing) with every spelled +// attribute recorded `{name, range, text}` in tag order (quoted, braced, +// valueless, and spread forms alike), invalid non-section elements +// (`<div>`, `<em>`, …: 14.16 — no view node, content preserved +// byte-for-byte, sections inside them parenting to the innermost +// enclosing SECTION construct), MDX comments `{/* … */}`, and +// `{text(...)}` embeddings (local string and external property-chain +// forms). Unbalanced or malformed construct syntax is 14.20 (masking the +// file's other conditions; the file contributes no view). +// - Identity (11.2): a section SPELLS an identity exactly when exactly one +// `id` attribute occurs on its tag with a quoted static-string value — +// repeated (agreeing or not), braced, and valueless forms spell none +// (14.17; absence alone is 14.1). A node identity is DEFINED exactly when +// the file's path is valid and every section of its positional chain +// (itself and each enclosing section) spells a well-formed (1.4), +// structurally conformant (1.3; masked where the parent spells none) +// identity, and the section's OWN spelled identity is spelled by no other +// section of the file (uniqueness contests spelled identities only — +// duplication is not a chain condition, and an invalid `id` form contests +// nothing). Roots: identity is the workspace-relative path. +// - Resolution (11.2): a local spelling names the sections of its own file +// spelling exactly that identity; an external spelling names them through +// a valid default-binding import's resolved target (an empty chain names +// the target's root). The reference resolves exactly when it names +// exactly one target whose own node identity is defined; it then records +// an occurrence (5.7) — `file`, its own `range` (the string literal +// quotes included for a local `d` entry, the property chain's characters +// for an external one, the whole braced container for an embedding), +// `kind`, `source` (the enclosing section's `{identity, range}` or the +// unavailability marker where 11.2 leaves that identity undefined — one +// datum, never null, never a picked bearer), and `target`. A +// non-resolving spelling records nothing and is reported by its finding +// (14.5 for `d`, 14.6 for `text(...)`, located at the reference). +// - Cycles (5.3, 14.9): strongly connected components over the recorded +// reference edges (self-loops included); one finding per cycle, locating +// every participating reference spelling. +// - Text (11.2, 1.6, 3): own and subtree text ride the CONF-MD fixture's +// attributed line-model compile — removals (import declarations by FORM, +// section tags, MDX comments) deleted in place, embedding containers +// replaced by their targets' subtree texts, and a line that contained +// non-whitespace in the source but is left empty or whitespace-only +// purely by removals dropped with its terminator. A value is defined +// exactly when every embedding its expansion transitively reaches +// records an occurrence and the recursion re-enters no node already +// being expanded — one unresolved spelling or one cycle on the expansion +// path poisons the WHOLE value (the unavailability marker; partial +// expansion never occurs). Same-file embedding targets close before +// their embeddings in every staged fixture; a self, enclosing, or +// forward same-file target is always poisoned (cycle or staging outside +// the scope), so its fabricated empty expansion is never read. +// - Emission (12.0, 12.7): both commands are JSON-only — one JSON document +// is the entire stdout, with or without `--json`, serialized with +// byte-sorted keys; findings carry exactly {"code", "message", +// "locations", "path", "identities"} with SPEC 14's stable tokens, in the +// pinned 12.7 order with identical findings collapsed; the exit code is +// computed from the pre-serialization document (findings present, or any +// unavailability marker in the answer → 1; else 0) so the datum-form +// deviations below change bytes, never exits. +// +// Determinism (SPEC 12.0): no wall clock, no randomness, no absolute paths +// in any output; files in byte order of workspace-relative path; all JSON +// serialized with byte-sorted keys. +// +// Deviation seam: runXspec(argv, cwd, options) assigns `options` onto the +// module-level `deviations` switches (all off = this conformer). Each +// VIOL-AVAIL-* violator entry is a bin-<name>.mjs passing exactly one +// switch, consumed at the hook points pinned below: +// - §VIOL-AVAIL-NULLMARKER (bin-nullmarker.mjs): `nullMarkers`, consumed +// in `materializeValue` — the single serialization point every emitted +// document passes through — carrying every undefined datum as `null` in +// place of {"unavailable": true}. Which data are undefined, all defined +// values, findings, exit codes, and every other member are unchanged +// (the exit scan reads the pre-serialization document). +// - §VIOL-AVAIL-OMIT (bin-omit.mjs): `omitNullMembers`, consumed in +// `materializeValue` — every object member whose value would be the +// stated `null` is absent from the emitted document. Members with +// plain, marker, or list values, which findings exist, and exit codes +// are unchanged. +// - §VIOL-AVAIL-NOFILE (bin-nofile.mjs): `ignoreFileRestriction`, +// consumed in `commandOccurrences`' domain computation — the `--file` +// flag and its argument are still accepted as specified, but the +// consulted domain is the entire discovered set, exactly as with the +// flag absent; `--to` selection, `view`, and every other behavior are +// unchanged. + +import { Buffer } from "node:buffer"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; + +// --------------------------------------------------------------------------- +// Outcome carriers and deviation switches +// --------------------------------------------------------------------------- + +/** + * Usage or configuration error (SPEC 12.0 exit 2): message on stderr; the + * served surfaces are JSON-only, so the single 12.7 error document is the + * entire stdout whenever one of them errs (12.0). `code`/`path` are the + * error finding's stable code and concerned path — set for configuration + * errors (14.14), `null` for plain usage errors (SPEC 12.7). + */ +class UsageError extends Error { + /** @param {string} message + * @param {{ code?: string | null, path?: string | null }} [finding] */ + constructor(message, { code = null, path = null } = {}) { + super(message); + this.code = code; + this.path = path; + } +} + +/** See the module header for the three switches and their hook points. */ +let deviations = {}; + +// --------------------------------------------------------------------------- +// The unavailability marker (SPEC 12.7) as an in-memory sentinel +// --------------------------------------------------------------------------- + +/** + * The one in-memory sentinel every undefined datum is carried as until + * serialization. Reference-compared (`value === UNAVAILABLE`), so no data + * value can collide with it; `materializeValue` renders it as the literal + * 12.7 marker — or as `null` under §VIOL-AVAIL-NULLMARKER's switch. + */ +const UNAVAILABLE = Object.freeze({ unavailableSentinel: true }); + +/** Whether the pre-serialization document carries any unavailable datum. */ +function containsUnavailable(value) { + if (value === UNAVAILABLE) return true; + if (Array.isArray(value)) return value.some(containsUnavailable); + if (value !== null && typeof value === "object") { + return Object.values(value).some(containsUnavailable); + } + return false; +} + +/** + * Render a document value for emission: byte-sorted keys (SPEC 12.0 + * determinism), the marker sentinel as the literal 12.7 form. The two + * datum-form deviation switches hook exactly here (module header): + * `nullMarkers` (§VIOL-AVAIL-NULLMARKER) carries the sentinel as `null`; + * `omitNullMembers` (§VIOL-AVAIL-OMIT) drops every object member whose + * rendered value is `null` (list elements are never members and stay). + */ +function materializeValue(value) { + if (value === UNAVAILABLE) { + return deviations.nullMarkers ? null : { unavailable: true }; + } + if (Array.isArray(value)) return value.map(materializeValue); + if (value !== null && typeof value === "object") { + /** @type {Record<string, unknown>} */ + const out = {}; + for (const key of Object.keys(value).sort()) { + const rendered = materializeValue(value[key]); + if (rendered === null && deviations.omitNullMembers) continue; + out[key] = rendered; + } + return out; + } + return value; +} + +/** Serialize one emitted document (the entire stdout, SPEC 12.0). */ +function renderDocument(doc) { + return JSON.stringify(materializeValue(doc)) + "\n"; +} + +// --------------------------------------------------------------------------- +// Configuration (SPEC 7): upward search + declarative literal parse +// --------------------------------------------------------------------------- + +const CONFIG_NAME = "xspec.config.ts"; + +/** + * The anchoring form of SPEC 11.6/14 for a path identified relative to the + * invocation working directory (used by configuration-error findings). + */ +function anchoringPath(cwd, absPath) { + const rel = path.relative(path.resolve(cwd), absPath); + if (rel === "") return "."; + return rel.split(path.sep).join("/"); +} + +async function pathOccupied(absPath) { + try { + await fsp.lstat(absPath); + return true; + } catch (error) { + if (error.code === "ENOENT") return false; + throw error; + } +} + +async function findConfigPath(cwd, configFlag) { + if (configFlag !== undefined) { + const abs = path.resolve(cwd, configFlag); + if (!(await pathOccupied(abs))) { + throw new UsageError( + `configuration file not found: --config ${configFlag}`, + { code: "configuration-error", path: anchoringPath(cwd, abs) }, + ); + } + return abs; + } + let dir = path.resolve(cwd); + for (;;) { + const candidate = path.join(dir, CONFIG_NAME); + if (await pathOccupied(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) { + throw new UsageError( + `configuration error: no ${CONFIG_NAME} found by upward search from the working directory`, + { code: "configuration-error", path: "." }, + ); + } + dir = parent; + } +} + +/** + * Parse the declarative configuration (SPEC 7): exactly an import of + * `defineConfig` from "xspec" (optionally aliased) and a default export of + * one call whose sole argument is statically literal. Returns the argument + * as data. Any other form is a configuration error (SPEC 14.14, exit 2). + */ +function parseConfigSource(text) { + const importMatch = + /import\s*\{\s*defineConfig(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*\}\s*from\s*(["'])xspec\2\s*;?/.exec( + text, + ); + if (!importMatch) { + throw new UsageError( + 'configuration error: xspec.config.ts must import { defineConfig } from "xspec" (SPEC 7, 14.14)', + ); + } + const binding = importMatch[1] ?? "defineConfig"; + const callMatch = new RegExp( + `export\\s+default\\s+${binding.replace(/\$/g, "\\$")}\\s*\\(`, + ).exec(text); + if (!callMatch) { + throw new UsageError( + "configuration error: xspec.config.ts must default-export one defineConfig(...) call (SPEC 7, 14.14)", + ); + } + const parser = new LiteralParser(text, callMatch.index + callMatch[0].length); + const value = parser.parseValue(); + parser.skipWs(); + if (parser.text[parser.pos] !== ")") { + throw new UsageError( + "configuration error: the defineConfig argument must be one static literal (SPEC 7, 14.14)", + ); + } + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new UsageError( + "configuration error: defineConfig takes an object literal (SPEC 7)", + ); + } + return value; +} + +/** Recursive-descent parser for the static-literal subset of SPEC 7. */ +class LiteralParser { + constructor(text, pos) { + this.text = text; + this.pos = pos; + } + + fail(what) { + throw new UsageError( + `configuration error: ${what} at offset ${String(this.pos)} (SPEC 7, 14.14)`, + ); + } + + skipWs() { + while (this.pos < this.text.length && /\s/.test(this.text[this.pos])) + this.pos += 1; + } + + parseValue() { + this.skipWs(); + const c = this.text[this.pos]; + if (c === "{") return this.parseObject(); + if (c === "[") return this.parseArray(); + if (c === '"' || c === "'") return this.parseString(); + if (this.text.startsWith("true", this.pos)) { + this.pos += 4; + return true; + } + if (this.text.startsWith("false", this.pos)) { + this.pos += 5; + return false; + } + return this.fail("expected an object, array, string, or boolean literal"); + } + + parseObject() { + this.pos += 1; // "{" + const obj = {}; + this.skipWs(); + if (this.text[this.pos] === "}") { + this.pos += 1; + return obj; + } + for (;;) { + this.skipWs(); + let key; + const c = this.text[this.pos]; + if (c === '"' || c === "'") { + key = this.parseString(); + } else { + const match = /^[A-Za-z_$][\w$]*/.exec(this.text.slice(this.pos)); + if (!match) this.fail("expected an object key"); + key = match[0]; + this.pos += key.length; + } + this.skipWs(); + if (this.text[this.pos] !== ":") + this.fail("expected ':' after an object key"); + this.pos += 1; + obj[key] = this.parseValue(); + this.skipWs(); + if (this.text[this.pos] === ",") { + this.pos += 1; + this.skipWs(); + if (this.text[this.pos] === "}") { + this.pos += 1; + return obj; + } + continue; + } + if (this.text[this.pos] === "}") { + this.pos += 1; + return obj; + } + this.fail("expected ',' or '}' in an object literal"); + } + } + + parseArray() { + this.pos += 1; // "[" + const arr = []; + this.skipWs(); + if (this.text[this.pos] === "]") { + this.pos += 1; + return arr; + } + for (;;) { + arr.push(this.parseValue()); + this.skipWs(); + if (this.text[this.pos] === ",") { + this.pos += 1; + this.skipWs(); + if (this.text[this.pos] === "]") { + this.pos += 1; + return arr; + } + continue; + } + if (this.text[this.pos] === "]") { + this.pos += 1; + return arr; + } + this.fail("expected ',' or ']' in an array literal"); + } + } + + parseString() { + const quote = this.text[this.pos]; + this.pos += 1; + let out = ""; + while (this.pos < this.text.length) { + const c = this.text[this.pos]; + if (c === quote) { + this.pos += 1; + return out; + } + if (c === "\\") { + const next = this.text[this.pos + 1]; + if (next === undefined) break; + if (next === "n") out += "\n"; + else if (next === "t") out += "\t"; + else if (next === "r") out += "\r"; + else out += next; + this.pos += 2; + continue; + } + out += c; + this.pos += 1; + } + return this.fail("unterminated string literal"); + } +} + +/** + * Load and validate the configuration; returns the workspace root and the + * spec groups. The in-scope shape (CERTIFICATIONS.md §CONF-AVAIL) is spec + * groups of glob strings and nothing else — no `code`, `markdown`, + * `coverage`, or `policy` keys; anything else is refused loudly as a + * configuration error rather than half-implemented (SPEC 7, 14.14). + */ +async function loadConfig(cwd, configFlag) { + const configPath = await findConfigPath(cwd, configFlag); + let text; + try { + text = await fsp.readFile(configPath, "utf8"); + } catch (error) { + throw new UsageError( + `configuration error: cannot read ${CONFIG_NAME}: ${error.message}`, + { code: "configuration-error", path: anchoringPath(cwd, configPath) }, + ); + } + const data = parseConfigSource(text); + for (const key of Object.keys(data)) { + if (key !== "specs") { + throw new UsageError( + `configuration error: the key ${JSON.stringify(key)} is unknown or outside this fixture's scope (CERTIFICATIONS.md §CONF-AVAIL; SPEC 7, 14.14)`, + ); + } + } + const specs = data.specs; + if ( + specs === undefined || + specs === null || + typeof specs !== "object" || + Array.isArray(specs) + ) { + throw new UsageError( + "configuration error: `specs` is required and must be a map of groups (SPEC 7)", + ); + } + /** @type {Record<string, string[]>} */ + const groups = {}; + for (const [name, globs] of Object.entries(specs)) { + if (!Array.isArray(globs) || globs.some((g) => typeof g !== "string")) { + throw new UsageError( + `configuration error: spec group ${name} must be a list of glob strings (SPEC 7.1)`, + ); + } + for (const glob of globs) { + if (glob.startsWith("/") || glob.split("/").includes("..")) { + throw new UsageError( + `configuration error: pattern ${glob} resolves outside the workspace root (SPEC 7, 14.14)`, + ); + } + } + groups[name] = globs; + } + return { root: path.dirname(configPath), groups }; +} + +// --------------------------------------------------------------------------- +// Glob matching (SPEC 7): `*`, `?`, `**`, literals, dot rule, case-sensitive +// --------------------------------------------------------------------------- + +const segmentRegexCache = new Map(); + +function globSegmentRegex(patternSegment) { + let regex = segmentRegexCache.get(patternSegment); + if (regex === undefined) { + let source = "^"; + for (const ch of patternSegment) { + if (ch === "*") source += "[^/]*"; + else if (ch === "?") source += "[^/]"; + else source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + } + regex = new RegExp(source + "$"); + segmentRegexCache.set(patternSegment, regex); + } + return regex; +} + +function globSegmentMatches(patternSegment, pathSegment) { + // Dot rule (SPEC 7): a path segment beginning with `.` is matched only by + // a pattern segment written with a leading `.`. + if (pathSegment.startsWith(".") && !patternSegment.startsWith(".")) + return false; + return globSegmentRegex(patternSegment).test(pathSegment); +} + +function globMatches(pattern, relPath) { + const patternSegments = pattern.split("/"); + const pathSegments = relPath.split("/"); + const match = (pi, si) => { + if (pi === patternSegments.length) return si === pathSegments.length; + const ps = patternSegments[pi]; + if (ps === "**") { + if (match(pi + 1, si)) return true; + if (si < pathSegments.length && !pathSegments[si].startsWith(".")) { + return match(pi, si + 1); + } + return false; + } + if (si >= pathSegments.length) return false; + if (!globSegmentMatches(ps, pathSegments[si])) return false; + return match(pi + 1, si + 1); + }; + return match(0, 0); +} + +// --------------------------------------------------------------------------- +// Discovery (SPEC 7, 13.4): walk plain files, never following symlinks +// --------------------------------------------------------------------------- + +async function walkPlainFiles(rootAbs, relPrefix = "") { + /** @type {string[]} */ + const files = []; + let entries; + try { + entries = await fsp.readdir(path.join(rootAbs, relPrefix), { + withFileTypes: true, + }); + } catch { + return files; + } + for (const entry of entries) { + const rel = relPrefix === "" ? entry.name : `${relPrefix}/${entry.name}`; + if (entry.isSymbolicLink()) continue; // never discovered, never traversed + if (entry.isDirectory()) { + files.push(...(await walkPlainFiles(rootAbs, rel))); + } else if (entry.isFile()) { + files.push(rel); + } + } + return files; +} + +/** Derived files are never sources (SPEC 13.4). */ +function isDerivedPath(rel) { + const base = rel.split("/").at(-1) ?? rel; + return ( + base.includes(".xspec.") || rel === ".xspec" || rel.startsWith(".xspec/") + ); +} + +/** Byte-order comparison of workspace-relative paths (SPEC 12.7). */ +function compareRelBytes(a, b) { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + +async function discoverSources(root, groups) { + const all = (await walkPlainFiles(root)).sort(compareRelBytes); + const discovered = []; + for (const rel of all) { + if (isDerivedPath(rel)) continue; + const matched = Object.values(groups).some((globs) => + globs.some((glob) => globMatches(glob, rel)), + ); + if (matched) discovered.push(rel); + } + return discovered; +} + +// --------------------------------------------------------------------------- +// SPEC 1.4 character classes, value validity, and tag splitting (SPEC 2.6) +// --------------------------------------------------------------------------- + +/** SPEC 1.4's whitespace class, exactly: U+0009–U+000D and U+0020. */ +function isValidityWhitespace(codePoint) { + return (codePoint >= 0x0009 && codePoint <= 0x000d) || codePoint === 0x0020; +} + +/** SPEC 1.4's control-character class, exactly: U+0000–U+001F and U+007F. */ +function isValidityControl(codePoint) { + return codePoint <= 0x001f || codePoint === 0x007f; +} + +/** The forbidden segment names of SPEC 1.4, all five (exact strings). */ +const FORBIDDEN_NAMES = new Set([ + "$", + "__proto__", + "prototype", + "constructor", + "then", +]); + +/** + * SPEC 1.4 validity of one segment or tag value: invalid on emptiness, a + * forbidden name, `.` (segments only), `#`, whitespace, or a control + * character. Returns true exactly when valid. + * + * @param {string} value + * @param {"segment" | "tag"} role + */ +function isValidValue(value, role) { + if (value.length === 0) return false; + if (FORBIDDEN_NAMES.has(value)) return false; + for (const character of value) { + const codePoint = character.codePointAt(0); + if (character === "." && role === "segment") return false; + if (character === "#") return false; + if (isValidityWhitespace(codePoint)) return false; + if (isValidityControl(codePoint)) return false; + } + return true; +} + +/** A spelled identity's segments (split on `.`; segments never contain it). */ +function identitySegments(spelling) { + return spelling.split("."); +} + +/** Whether every segment of a spelled identity is 1.4-valid. */ +function isWellFormedIdentity(spelling) { + return identitySegments(spelling).every((segment) => + isValidValue(segment, "segment"), + ); +} + +/** + * SPEC 2.6 tag splitting: tags split on runs of 1.4 whitespace with + * leading/trailing whitespace ignored, then collapse to a sorted set. + */ +function splitTags(value) { + const tokens = []; + let current = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + if (isValidityWhitespace(codePoint)) { + if (current !== "") { + tokens.push(current); + current = ""; + } + } else { + current += character; + } + } + if (current !== "") tokens.push(current); + return tokens; +} + +// --------------------------------------------------------------------------- +// Line model (SPEC 3) and byte offsets (SPEC 1.7) +// --------------------------------------------------------------------------- + +/** The drop rule's whitespace class: exactly SPEC 1.4's (no deviation here). */ +function isDropWhitespaceCode(code) { + return (code >= 0x0009 && code <= 0x000d) || code === 0x0020; +} + +/** True when `text` is empty or consists only of drop-rule whitespace. */ +function isWhitespaceOnlyForDrop(text) { + for (let i = 0; i < text.length; i += 1) { + if (!isDropWhitespaceCode(text.charCodeAt(i))) return false; + } + return true; +} + +/** + * The line terminator starting at `index`, or null: U+000D U+000A is one + * terminator, a lone U+000A one, a lone U+000D one (SPEC 3). + */ +function terminatorAt(text, index) { + const code = text.charCodeAt(index); + if (code === 0x000a) return "\n"; + if (code === 0x000d) { + if (text.charCodeAt(index + 1) === 0x000a) return "\r\n"; + return "\r"; + } + return null; +} + +/** + * Map string (code-unit) indices to UTF-8 byte offsets (SPEC 1.7). ASCII + * sources take the identity fast path; the multi-byte prose prefixes of the + * staged fixtures take the general path. + */ +function byteOffsetMapper(text, byteLength) { + if (byteLength === text.length) return (i) => i; + const offsets = new Array(text.length + 1); + let bytes = 0; + let i = 0; + while (i < text.length) { + offsets[i] = bytes; + const code = text.codePointAt(i); + const units = code > 0xffff ? 2 : 1; + if (units === 2) offsets[i + 1] = bytes; + bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4; + i += units; + } + offsets[text.length] = bytes; + return (index) => offsets[index]; +} + +// --------------------------------------------------------------------------- +// MDX-lite parser: imports, sections with full attribute records, invalid +// elements, comments, `{text(...)}` embeddings +// --------------------------------------------------------------------------- + +/** Inter-attribute whitespace inside a tag (the SPEC 1.4 class, verbatim). */ +const TAG_WHITESPACE = new Set(["\t", "\n", "\v", "\f", "\r", " "]); + +const EMBED_OPEN_RE = /^\{[ \t]*text[ \t]*\(/; +const IDENTIFIER_RE = /^[$_\p{L}][$_\p{L}\p{N}]*/u; +const ATTR_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]*/; + +/** + * Parse one source file. Returns + * `{ root, sections, elements, imports, comments, embeds, pieces, failure }`: + * - `root`/`sections`: the positional section tree — per section the + * construct extents (open/close tag index ranges, self-closing flag), + * the positional SECTION parent (invalid element frames are skipped: + * SPEC 11.4's innermost-enclosing-section parenting), and every spelled + * attribute in tag order as `{name, form, value, start, end, valueStart}` + * (name `null` for a spread attribute; `form` one of "quoted", "braced", + * "none", "spread"); + * - `elements`: each invalid non-section element's whole construct extent + * (14.16 — content preserved byte-for-byte, no view node); + * - `imports`: each declaration at an MDX ESM block position with its + * extent, default-binding identifier (or null), binding-form validity, + * and specifier; + * - `comments`: each MDX comment container's extent; + * - `embeds`: each `{text(...)}` container with its extent, reference, and + * owning section (or root); + * - `pieces`: the whole file in document order as content / removal / + * embed pieces for the SPEC 3 compile (invalid elements' tags are + * CONTENT — they match no removal rule's form); + * - `failure`: null, or `{ at, message }` (14.20 — an unparseable source, + * masking the conditions inside). + */ +function parseMdx(text) { + const root = { + isRoot: true, + parent: null, + children: [], + attrs: [], + openStart: 0, + openEnd: 0, + closeStart: text.length, + closeEnd: text.length, + selfClosing: false, + }; + const sections = []; + const elements = []; + const imports = []; + const comments = []; + const embeds = []; + const pieces = []; + /** Frames: sections and invalid elements interleaved (proper nesting). */ + const frames = [{ kind: "section", node: root }]; + /** @type {{ at: number, message: string } | null} */ + let failure = null; + let i = 0; + let contentStart = 0; + // The MDX ESM block rule (SPEC 2.1; the FP-094 lesson): an `import` line + // is a declaration only at a block position — file start, after a blank + // line, or continuing a run of import declarations — and only at top + // level. `importRunUntil` marks the line start reached by consuming a + // declaration plus its terminator. + let importRunUntil = -1; + + const innermostSection = () => { + for (let f = frames.length - 1; f >= 0; f -= 1) { + if (frames[f].kind === "section") return frames[f].node; + } + return root; + }; + const flushContent = (end) => { + if (end > contentStart) { + pieces.push({ + kind: "content", + text: text.slice(contentStart, end), + owner: innermostSection(), + }); + } + }; + const result = () => ({ + root, + sections, + elements, + imports, + comments, + embeds, + pieces, + failure, + }); + const fail20 = (at, message) => { + failure = { at, message }; + }; + + /** Whether `i` is a line start whose PREVIOUS line is blank. */ + const afterBlankLine = (index) => { + if (index === 0) return true; + // The character(s) before `index` must be a terminator; then the line + // before that terminator must be empty or whitespace-only. + let lineEnd = index - 1; + if (text[lineEnd] === "\n" && text[lineEnd - 1] === "\r") lineEnd -= 1; + if (text[lineEnd] !== "\n" && text[lineEnd] !== "\r") return false; + let lineStart = lineEnd; + while ( + lineStart > 0 && + text[lineStart - 1] !== "\n" && + text[lineStart - 1] !== "\r" + ) { + lineStart -= 1; + } + return isWhitespaceOnlyForDrop(text.slice(lineStart, lineEnd)); + }; + + /** Scan a tag's attribute region; record entries when `record` given. */ + const scanAttributes = (start, record) => { + let j = start; + for (;;) { + while (j < text.length && TAG_WHITESPACE.has(text[j])) j += 1; + if (j >= text.length) return { end: -1, selfClosing: false, at: j }; + if (text[j] === ">") return { end: j + 1, selfClosing: false, at: j }; + if (text[j] === "/" && text[j + 1] === ">") { + return { end: j + 2, selfClosing: true, at: j }; + } + if (text[j] === "{") { + // A spread attribute (SPEC 2.7): its `name` is structurally absent + // and its source text is the whole braced construct. + const scanned = scanBracedValue(j); + if (scanned === null) return { end: -1, selfClosing: false, at: j }; + record?.push({ + name: null, + form: "spread", + value: undefined, + start: j, + end: scanned.end, + valueStart: j + 1, + }); + j = scanned.end; + continue; + } + const attr = ATTR_NAME_RE.exec(text.slice(j)); + if (!attr) return { end: -1, selfClosing: false, at: j }; + const name = attr[0]; + const nameStart = j; + j += name.length; + if (text[j] !== "=") { + // Valueless bare-name attribute: the entry is the name alone. + record?.push({ + name, + form: "none", + value: undefined, + start: nameStart, + end: j, + valueStart: j, + }); + continue; + } + j += 1; + const open = text[j]; + if (open === '"' || open === "'") { + const valueStart = j + 1; + const end = text.indexOf(open, valueStart); + if (end === -1) return { end: -1, selfClosing: false, at: j }; + record?.push({ + name, + form: "quoted", + value: text.slice(valueStart, end), + start: nameStart, + end: end + 1, + valueStart, + }); + j = end + 1; + continue; + } + if (open === "{") { + const scanned = scanBracedValue(j); + if (scanned === null) return { end: -1, selfClosing: false, at: j }; + record?.push({ + name, + form: "braced", + value: text.slice(j + 1, scanned.end - 1), + start: nameStart, + end: scanned.end, + valueStart: j + 1, + }); + j = scanned.end; + continue; + } + return { end: -1, selfClosing: false, at: j }; + } + }; + + /** Quote-aware brace scan from an opening `{`; returns { end } or null. */ + const scanBracedValue = (start) => { + let depth = 0; + let k = start; + for (;;) { + if (k >= text.length) return null; + const c = text[k]; + if (c === '"' || c === "'") { + const end = text.indexOf(c, k + 1); + if (end === -1) return null; + k = end + 1; + continue; + } + if (c === "{") depth += 1; + else if (c === "}") { + depth -= 1; + if (depth === 0) return { end: k + 1 }; + } + k += 1; + } + }; + + /** Parse one import declaration at `start`; returns record or null. */ + const parseImportAt = (start) => { + let j = start + "import".length; + const skipSpaces = () => { + while (text[j] === " " || text[j] === "\t") j += 1; + }; + const readString = () => { + const q = text[j]; + if (q !== '"' && q !== "'") return null; + const end = text.indexOf(q, j + 1); + if (end === -1) return null; + const value = text.slice(j + 1, end); + if (/[\r\n]/.test(value)) return null; + j = end + 1; + return value; + }; + skipSpaces(); + let defaultName = null; + let hasNamed = false; + let hasNamespace = false; + let sideEffect = false; + if (text[j] === '"' || text[j] === "'") { + sideEffect = true; // side-effect-only form: no binding clause at all + } else { + const readClause = () => { + if (text[j] === "{") { + const close = text.indexOf("}", j); + if (close === -1) return false; + if (/[\r\n]/.test(text.slice(j, close))) return false; + hasNamed = true; + j = close + 1; + return true; + } + if (text[j] === "*") { + j += 1; + skipSpaces(); + if (!text.startsWith("as", j)) return false; + j += 2; + skipSpaces(); + const ident = IDENTIFIER_RE.exec(text.slice(j)); + if (!ident) return false; + hasNamespace = true; + j += ident[0].length; + return true; + } + const ident = IDENTIFIER_RE.exec(text.slice(j)); + if (!ident || ident[0] === "from") return false; + defaultName = ident[0]; + j += ident[0].length; + return true; + }; + if (!readClause()) return null; + skipSpaces(); + if (text[j] === ",") { + j += 1; + skipSpaces(); + if (!readClause()) return null; + skipSpaces(); + } + if (!text.startsWith("from", j)) return null; + j += "from".length; + skipSpaces(); + } + const specifier = readString(); + if (specifier === null) return null; + if (text[j] === ";") j += 1; + return { + start, + end: j, + name: defaultName, + // The 2.1 form is a SINGLE default binding: any named clause, + // namespace clause, or side-effect-only spelling is an invalid + // binding form (14.15) — the declaration is still listed (11.4). + formValid: defaultName !== null && !hasNamed && !hasNamespace, + sideEffect, + specifier, + }; + }; + + while (i < text.length) { + const ch = text[i]; + const atLineStart = i === 0 || text[i - 1] === "\n" || text[i - 1] === "\r"; + if ( + ch === "i" && + atLineStart && + frames.length === 1 && + /^import[ \t"'{*]/.test(text.slice(i, i + 8)) && + (i === importRunUntil || afterBlankLine(i)) + ) { + const declaration = parseImportAt(i); + if (declaration === null) { + fail20(i, "malformed import declaration at an ESM block position"); + return result(); + } + flushContent(i); + imports.push(declaration); + pieces.push({ + kind: "removal", + text: text.slice(declaration.start, declaration.end), + }); + i = declaration.end; + contentStart = i; + const terminator = terminatorAt(text, i); + importRunUntil = terminator === null ? -1 : i + terminator.length; + continue; + } + if (ch === "<") { + const closeSection = /^<\/(S|Spec)[ \t\r\n\v\f]*>/.exec(text.slice(i)); + if (closeSection) { + const frame = frames[frames.length - 1]; + if (frame.kind !== "section" || frame.node.isRoot) { + fail20(i, "closing section tag without a matching open section"); + return result(); + } + flushContent(i); + frame.node.closeStart = i; + frame.node.closeEnd = i + closeSection[0].length; + pieces.push({ kind: "removal", text: closeSection[0] }); + frames.pop(); + i = frame.node.closeEnd; + contentStart = i; + continue; + } + const openSection = /^<(S|Spec)(?=[ \t\r\n\v\f/>])/.exec(text.slice(i)); + if (openSection) { + flushContent(i); + /** @type {object[]} */ + const attrs = []; + const scanned = scanAttributes(i + openSection[0].length, attrs); + if (scanned.end === -1) { + fail20(scanned.at, "malformed or unterminated section tag"); + return result(); + } + const node = { + isRoot: false, + parent: innermostSection(), + children: [], + attrs, + openStart: i, + openEnd: scanned.end, + closeStart: scanned.selfClosing ? scanned.end : -1, + closeEnd: scanned.selfClosing ? scanned.end : -1, + selfClosing: scanned.selfClosing, + }; + node.parent.children.push(node); + sections.push(node); + pieces.push({ kind: "removal", text: text.slice(i, scanned.end) }); + if (!scanned.selfClosing) frames.push({ kind: "section", node }); + i = scanned.end; + contentStart = i; + continue; + } + const closeElement = /^<\/([A-Za-z][A-Za-z0-9]*)[ \t\r\n\v\f]*>/.exec( + text.slice(i), + ); + if (closeElement) { + const frame = frames[frames.length - 1]; + if (frame.kind !== "element" || frame.name !== closeElement[1]) { + fail20(i, `mismatched closing tag </${closeElement[1]}>`); + return result(); + } + // The element's whole construct is one invalid construct (14.16): + // located by its finding, no view entry, and CONTENT to the compile + // (it matches no removal rule's form) — so its tags stay in the + // pending content run, preserved byte-for-byte. + elements.push({ start: frame.start, end: i + closeElement[0].length }); + frames.pop(); + i += closeElement[0].length; + continue; + } + const openElement = /^<([A-Za-z][A-Za-z0-9]*)(?=[ \t\r\n\v\f/>])/.exec( + text.slice(i), + ); + if (openElement) { + const scanned = scanAttributes(i + openElement[0].length, null); + if (scanned.end === -1) { + fail20(scanned.at, "malformed or unterminated element tag"); + return result(); + } + if (scanned.selfClosing) { + elements.push({ start: i, end: scanned.end }); + } else { + frames.push({ kind: "element", name: openElement[1], start: i }); + } + i = scanned.end; + continue; + } + i += 1; // a plain `<` is ordinary content in this scope + continue; + } + if (ch === "{") { + if (text.startsWith("{/*", i)) { + const end = text.indexOf("*/}", i + 3); + if (end === -1) { + fail20(i, "unterminated MDX comment"); + return result(); + } + flushContent(i); + comments.push({ start: i, end: end + 3 }); + pieces.push({ kind: "removal", text: text.slice(i, end + 3) }); + i = end + 3; + contentStart = i; + continue; + } + const embedMatch = EMBED_OPEN_RE.exec(text.slice(i)); + if (embedMatch) { + let j = i + embedMatch[0].length; + const skipWs = () => { + while (j < text.length && TAG_WHITESPACE.has(text[j])) j += 1; + }; + skipWs(); + let ref; + const q = text[j]; + if (q === '"' || q === "'") { + const end = text.indexOf(q, j + 1); + if (end === -1) { + fail20(j, "unterminated text(...) string argument"); + return result(); + } + ref = { form: "local", id: text.slice(j + 1, end) }; + j = end + 1; + } else { + const ident = IDENTIFIER_RE.exec(text.slice(j)); + if (!ident) { + fail20(j, "malformed text(...) argument"); + return result(); + } + const binding = ident[0]; + j += binding.length; + const segments = []; + for (;;) { + if (text[j] === ".") { + const seg = IDENTIFIER_RE.exec(text.slice(j + 1)); + if (!seg) { + fail20(j, "malformed property chain in text(...)"); + return result(); + } + segments.push(seg[0]); + j += 1 + seg[0].length; + continue; + } + if (text[j] === "[") { + const qq = text[j + 1]; + if (qq !== '"' && qq !== "'") { + fail20(j, "malformed computed access in text(...)"); + return result(); + } + const end = text.indexOf(qq, j + 2); + if (end === -1 || text[end + 1] !== "]") { + fail20(j, "malformed computed access in text(...)"); + return result(); + } + segments.push(text.slice(j + 2, end)); + j = end + 2; + continue; + } + break; + } + ref = { form: "external", binding, segments }; + } + skipWs(); + if (text[j] !== ")") { + fail20(j, "text(...) takes exactly one argument"); + return result(); + } + j += 1; + skipWs(); + if (text[j] !== "}") { + fail20(j, "unterminated text(...) expression container"); + return result(); + } + j += 1; + flushContent(i); + const embed = { + start: i, + end: j, + ref, + owner: innermostSection(), + target: null, + }; + embeds.push(embed); + pieces.push({ + kind: "embed", + text: text.slice(i, j), + owner: embed.owner, + embed, + }); + i = j; + contentStart = i; + continue; + } + i += 1; // a stray `{` is ordinary content in this scope + continue; + } + i += 1; + } + flushContent(text.length); + if (frames.length !== 1) { + const frame = frames[frames.length - 1]; + fail20( + Math.max(0, text.length - 1), + frame.kind === "section" ? "unclosed section tag" : "unclosed element", + ); + } + return result(); +} + +// --------------------------------------------------------------------------- +// `d` reference parsing (SPEC 2.2 — resolution and occurrence positions) +// --------------------------------------------------------------------------- + +/** + * Parse a braced `d` value's body (offsets relative to the body): a single + * static reference or an array literal of them, each a string literal + * (local form — the occurrence spans the literal, quotes included) or a + * property chain rooted at an import binding (external form — the + * occurrence spans the chain's characters). Returns the reference list with + * per-reference `exprStart`/`exprEnd`, or null when malformed. + */ +function parseDReferences(body) { + let j = 0; + const skipWs = () => { + while (j < body.length && TAG_WHITESPACE.has(body[j])) j += 1; + }; + const parseOne = () => { + const exprStart = j; + const q = body[j]; + if (q === '"' || q === "'") { + const end = body.indexOf(q, j + 1); + if (end === -1) return null; + const id = body.slice(j + 1, end); + j = end + 1; + return { form: "local", id, exprStart, exprEnd: j }; + } + const ident = IDENTIFIER_RE.exec(body.slice(j)); + if (!ident) return null; + const binding = ident[0]; + j += binding.length; + const segments = []; + for (;;) { + if (body[j] === ".") { + const seg = IDENTIFIER_RE.exec(body.slice(j + 1)); + if (!seg) return null; + segments.push(seg[0]); + j += 1 + seg[0].length; + continue; + } + if (body[j] === "[") { + const qq = body[j + 1]; + if (qq !== '"' && qq !== "'") return null; + const end = body.indexOf(qq, j + 2); + if (end === -1 || body[end + 1] !== "]") return null; + segments.push(body.slice(j + 2, end)); + j = end + 2; + continue; + } + break; + } + return { form: "external", binding, segments, exprStart, exprEnd: j }; + }; + const refs = []; + skipWs(); + if (body[j] === "[") { + j += 1; + skipWs(); + if (body[j] === "]") { + j += 1; // `d={[]}`: no dependencies (SPEC 2.2) + } else { + for (;;) { + const ref = parseOne(); + if (ref === null) return null; + refs.push(ref); + skipWs(); + if (body[j] === ",") { + j += 1; + skipWs(); + continue; + } + if (body[j] === "]") { + j += 1; + break; + } + return null; + } + } + } else { + const ref = parseOne(); + if (ref === null) return null; + refs.push(ref); + } + skipWs(); + return j >= body.length ? refs : null; +} + +// --------------------------------------------------------------------------- +// Import specifier resolution (SPEC 2.1) +// --------------------------------------------------------------------------- + +/** Import specifier → designated source path, or null where form defines none. */ +function resolveImportTarget(fromRel, specifier) { + if (!specifier.startsWith("./") && !specifier.startsWith("../")) return null; + if (!specifier.endsWith(".xspec")) return null; + const joined = path.posix.normalize( + path.posix.join(path.posix.dirname(fromRel), specifier), + ); + if (joined === ".." || joined.startsWith("../")) return null; + return joined.slice(0, -".xspec".length) + ".mdx"; +} + +// --------------------------------------------------------------------------- +// Workspace analysis: identities, interpreted data, findings, occurrences +// --------------------------------------------------------------------------- + +// SPEC 14's stable code tokens by condition ordinal ("14.N" → token). The +// JSON report carries the token string alone (SPEC 12.7, 14); the ordinal +// orders findings and is no part of the value. Only the conditions this +// conformer's scope reports appear. +const CODE_TOKENS = { + 14.1: "missing-id", + 14.2: "invalid-structural-id", + 14.3: "duplicate-id", + 14.4: "invalid-segment-or-tag", + 14.5: "unknown-dependency", + 14.6: "unknown-text-target", + 14.8: "invalid-argument", + 14.9: "cycle", + 14.15: "invalid-import", + 14.16: "invalid-construct", + 14.17: "invalid-prop", + "14.20": "unparseable-source", +}; + +/** Analyze one discovered source's bytes into a file record. */ +function analyzeFile(rel, bytes) { + const base = { + rel, + bytes, + text: "", + byteOf: (index) => index, + parsed: null, + /** spelling → sections spelling it (uniqueness + resolution). */ + idMap: new Map(), + /** binding identifier → target rel (valid default imports only). */ + bindings: new Map(), + /** per-section derived data (Map section → info). */ + info: new Map(), + failure: null, + }; + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return { + ...base, + failure: { at: 0, message: `${rel} is not valid UTF-8 (SPEC 1.6)` }, + }; + } + if (text.charCodeAt(0) === 0xfeff) { + return { + ...base, + failure: { + at: 0, + message: `${rel} begins with a byte-order mark (SPEC 1.6)`, + }, + }; + } + const byteOf = byteOffsetMapper(text, bytes.length); + const parsed = parseMdx(text); + if (parsed.failure !== null) { + return { ...base, text, byteOf, failure: parsed.failure }; + } + return { ...base, text, byteOf, parsed }; +} + +/** A byte range for a string-index range of one record, clamped. */ +function byteRange(record, startIndex, endIndex) { + const clamp = (index) => Math.max(0, Math.min(index, record.text.length)); + return { + start: record.byteOf(clamp(startIndex)), + end: record.byteOf(clamp(endIndex)), + }; +} + +/** + * Load and analyze the whole workspace: discovery, per-file parse, + * identity/interpreted-data computation, import resolution, reference + * resolution with occurrence records, and every finding of the scope's + * condition set. Reads sources only; writes nothing (graph data and refresh + * behavior are out of CONF-AVAIL scope). + */ +async function loadWorkspace(cwd, configFlag) { + const config = await loadConfig(cwd, configFlag); + const rels = await discoverSources(config.root, config.groups); + /** @type {{condition: string, message: string, locations: {file: string, range: {start: number, end: number}}[]}[]} */ + const findings = []; + const files = new Map(); + for (const rel of rels) { + const bytes = await fsp.readFile(path.join(config.root, ...rel.split("/"))); + files.set(rel, analyzeFile(rel, bytes)); + } + + const addFinding = (condition, message, locations) => { + findings.push({ condition, message, locations }); + }; + + // --- Pass 1: per-file structure — attributes, spelled identities, + // interpreted tags/coverage, invalid elements, imports. + for (const record of files.values()) { + if (record.failure !== null) { + addFinding( + "14.20", + `unparseable source: ${record.failure.message} (SPEC 14.20)`, + [ + { + file: record.rel, + range: byteRange(record, record.failure.at, record.failure.at + 1), + }, + ], + ); + continue; + } + const { parsed } = record; + const attrRange = (attr) => byteRange(record, attr.start, attr.end); + const constructRange = (node) => + byteRange(record, node.openStart, node.closeEnd); + + for (const element of parsed.elements) { + addFinding( + "14.16", + "invalid construct: a non-section element is not a recognized construct — content preserved, no view entry (SPEC 11.2, 11.4, 14.16)", + [ + { + file: record.rel, + range: byteRange(record, element.start, element.end), + }, + ], + ); + } + + for (const section of parsed.sections) { + const info = { + spelled: null, + wellFormed: false, + conformant: true, + unique: true, + defined: false, + tags: [], + coverage: "required", + dRefs: [], + }; + record.info.set(section, info); + + // Identity spelling (SPEC 11.2): exactly one `id` attribute with a + // quoted static-string value spells; every other shape spells none. + const idAttrs = section.attrs.filter((attr) => attr.name === "id"); + if (idAttrs.length === 0) { + addFinding( + "14.1", + "missing id: every section must spell an identity via an `id` prop (SPEC 1.3, 14.1)", + [{ file: record.rel, range: constructRange(section) }], + ); + } else if (idAttrs.length > 1) { + addFinding( + "14.17", + "invalid prop: `id` is repeated — a section spells an identity via exactly one quoted static `id` (SPEC 2.7, 11.2, 14.17)", + idAttrs.map((attr) => ({ file: record.rel, range: attrRange(attr) })), + ); + } else if (idAttrs[0].form !== "quoted") { + addFinding( + "14.17", + "invalid prop: `id` must carry a quoted static-string value (SPEC 2.7, 11.2, 14.17)", + [{ file: record.rel, range: attrRange(idAttrs[0]) }], + ); + } else { + info.spelled = idAttrs[0].value; + info.wellFormed = isWellFormedIdentity(info.spelled); + if (!info.wellFormed) { + addFinding( + "14.4", + `invalid segment: the spelled identity ${JSON.stringify(info.spelled)} carries an invalid segment (SPEC 1.4, 14.4)`, + [{ file: record.rel, range: attrRange(idAttrs[0]) }], + ); + } + } + + // Interpreted tags (SPEC 2.6, 11.2): plain list, or unavailable. + const tagAttrs = section.attrs.filter((attr) => attr.name === "tags"); + if (tagAttrs.length > 1) { + info.tags = UNAVAILABLE; + addFinding( + "14.17", + "invalid prop: `tags` is repeated (SPEC 2.7, 14.17)", + tagAttrs.map((attr) => ({ + file: record.rel, + range: attrRange(attr), + })), + ); + } else if (tagAttrs.length === 1 && tagAttrs[0].form !== "quoted") { + info.tags = UNAVAILABLE; + addFinding( + "14.17", + "invalid prop: `tags` must carry a quoted static-string value (SPEC 2.7, 14.17)", + [{ file: record.rel, range: attrRange(tagAttrs[0]) }], + ); + } else if (tagAttrs.length === 1) { + const tokens = splitTags(tagAttrs[0].value); + let valid = true; + for (const token of tokens) { + if (!isValidValue(token, "tag")) { + valid = false; + addFinding( + "14.4", + `invalid tag: ${JSON.stringify(token)} is not a valid tag (SPEC 1.4, 2.6, 14.4)`, + [{ file: record.rel, range: attrRange(tagAttrs[0]) }], + ); + } + } + info.tags = valid ? [...new Set(tokens)].sort() : UNAVAILABLE; + } + + // Interpreted coverage (SPEC 2.5, 11.2): "required"/"none", or + // unavailable (any repeated, malformed, or invalid-valued spelling — + // condition 17 in every case, never 14.4). + const coverageAttrs = section.attrs.filter( + (attr) => attr.name === "coverage", + ); + if (coverageAttrs.length > 1) { + info.coverage = UNAVAILABLE; + addFinding( + "14.17", + "invalid prop: `coverage` is repeated (SPEC 2.7, 14.17)", + coverageAttrs.map((attr) => ({ + file: record.rel, + range: attrRange(attr), + })), + ); + } else if (coverageAttrs.length === 1) { + const attr = coverageAttrs[0]; + if (attr.form !== "quoted") { + info.coverage = UNAVAILABLE; + addFinding( + "14.17", + "invalid prop: `coverage` must carry a quoted static-string value (SPEC 2.5, 2.7, 14.17)", + [{ file: record.rel, range: attrRange(attr) }], + ); + } else if (attr.value !== "required" && attr.value !== "none") { + info.coverage = UNAVAILABLE; + addFinding( + "14.17", + `invalid prop: ${JSON.stringify(attr.value)} is not a coverage value — "required" or "none" (SPEC 2.5, 14.17)`, + [{ file: record.rel, range: attrRange(attr) }], + ); + } else { + info.coverage = attr.value; + } + } + + // `d` (SPEC 2.2): braced static reference(s); other shapes are + // invalid prop usage / invalid arguments, never dependencies. + const dAttrs = section.attrs.filter((attr) => attr.name === "d"); + if (dAttrs.length > 1) { + addFinding( + "14.17", + "invalid prop: `d` is repeated (SPEC 2.7, 14.17)", + dAttrs.map((attr) => ({ file: record.rel, range: attrRange(attr) })), + ); + } else if (dAttrs.length === 1 && dAttrs[0].form !== "braced") { + addFinding( + "14.17", + "invalid prop: `d` must carry a braced expression value (SPEC 2.2, 2.7, 14.17)", + [{ file: record.rel, range: attrRange(dAttrs[0]) }], + ); + } else if (dAttrs.length === 1) { + const refs = parseDReferences(dAttrs[0].value); + if (refs === null) { + addFinding( + "14.8", + "invalid argument: the `d` value is not a static reference or an array literal of static references (SPEC 2.2, 2.4, 14.8)", + [{ file: record.rel, range: attrRange(dAttrs[0]) }], + ); + } else { + info.dRefs = refs.map((ref) => ({ + ...ref, + range: byteRange( + record, + dAttrs[0].valueStart + ref.exprStart, + dAttrs[0].valueStart + ref.exprEnd, + ), + })); + } + } + + // Unknown props and spread attributes (SPEC 2.7, 14.17): one finding + // per afflicted prop name per element; one per spread entry. + const KNOWN = new Set(["id", "d", "tags", "coverage"]); + const unknownByName = new Map(); + for (const attr of section.attrs) { + if (attr.name === null) { + addFinding( + "14.17", + "invalid prop: a spread attribute is not a recognized prop form (SPEC 2.7, 14.17)", + [{ file: record.rel, range: attrRange(attr) }], + ); + continue; + } + if (KNOWN.has(attr.name)) continue; + const list = unknownByName.get(attr.name) ?? []; + list.push(attr); + unknownByName.set(attr.name, list); + } + for (const [name, attrs] of unknownByName) { + addFinding( + "14.17", + `invalid prop: ${JSON.stringify(name)} is not a recognized prop (SPEC 2.7, 14.17)`, + attrs.map((attr) => ({ file: record.rel, range: attrRange(attr) })), + ); + } + } + + // Structural conformance (SPEC 1.3, 14.2), masked where the positional + // section parent spells no identity. + for (const section of parsed.sections) { + const info = record.info.get(section); + if (info.spelled === null) continue; + const parent = section.parent; + if (parent.isRoot) { + if (identitySegments(info.spelled).length !== 1) { + info.conformant = false; + } + } else { + const parentSpelled = record.info.get(parent).spelled; + if (parentSpelled === null) continue; // masked (SPEC 14.2) + const prefix = `${parentSpelled}.`; + if ( + !info.spelled.startsWith(prefix) || + info.spelled.slice(prefix.length).includes(".") || + info.spelled.length === prefix.length + ) { + info.conformant = false; + } + } + if (!info.conformant) { + addFinding( + "14.2", + `invalid structural id: ${JSON.stringify(info.spelled)} does not extend its parent's spelled identity by exactly one segment (SPEC 1.3, 14.2)`, + [{ file: record.rel, range: constructRange(section) }], + ); + } + } + + // Uniqueness (SPEC 11.2, 14.3): spelled identities only — one finding + // per duplicated spelling, locating EVERY bearer; every bearer's own + // identity is undefined (no winner), while descendants judge their own + // spelling alone (duplication is not a chain condition). + for (const section of parsed.sections) { + const info = record.info.get(section); + if (info.spelled === null) continue; + const list = record.idMap.get(info.spelled) ?? []; + list.push(section); + record.idMap.set(info.spelled, list); + } + for (const [spelling, bearers] of record.idMap) { + if (bearers.length < 2) continue; + for (const bearer of bearers) record.info.get(bearer).unique = false; + addFinding( + "14.3", + `duplicate id: ${JSON.stringify(spelling)} is spelled by ${String(bearers.length)} sections of ${record.rel} (SPEC 1.3, 14.3)`, + bearers.map((bearer) => ({ + file: record.rel, + range: constructRange(bearer), + })), + ); + } + + // Definedness (SPEC 11.2): the chain conditions — every section of the + // positional chain spells a well-formed, structurally conformant + // identity — plus the section's own uniqueness. + for (const section of parsed.sections) { + const info = record.info.get(section); + let chainOk = info.unique; + for (let node = section; !node.isRoot; node = node.parent) { + const chainInfo = record.info.get(node); + if ( + chainInfo.spelled === null || + !chainInfo.wellFormed || + !chainInfo.conformant + ) { + chainOk = false; + break; + } + } + info.defined = chainOk; + } + + // Imports (SPEC 2.1, 11.4): every declaration is listed; the resolved + // target turns on specifier form and discovery ALONE (binding validity + // notwithstanding); one 14.15 per invalid declaration. Only a valid + // single-default-binding declaration with a resolved target defines a + // spec-module binding for the file's external references. + for (const declaration of parsed.imports) { + const targetRel = resolveImportTarget(record.rel, declaration.specifier); + const resolved = + targetRel !== null && files.has(targetRel) ? targetRel : null; + declaration.resolvedTarget = resolved; + if (!declaration.formValid || resolved === null) { + addFinding( + "14.15", + `invalid import: the declaration does not bind a single default import of a discovered spec source (${JSON.stringify(declaration.specifier)}) (SPEC 2.1, 14.15)`, + [ + { + file: record.rel, + range: byteRange(record, declaration.start, declaration.end), + }, + ], + ); + } + if ( + declaration.formValid && + resolved !== null && + !record.bindings.has(declaration.name) + ) { + record.bindings.set(declaration.name, resolved); + } + } + } + + // --- Node identities (for records and answers): rel for roots, + // `rel#spelling` for defined sections, the marker otherwise. Paths are + // valid throughout the scope (valid UTF-8, `#`-free). + const nodeIdentity = (record, node) => { + if (node.isRoot) return record.rel; + const info = record.info.get(node); + return info.defined ? `${record.rel}#${info.spelled}` : UNAVAILABLE; + }; + + // --- Pass 2: reference resolution (SPEC 11.2) and occurrence records + // (SPEC 5.7). A reference resolves exactly when it names exactly one + // target whose own node identity is defined; a non-resolving spelling + // records nothing (never an unavailable target) and is reported by its + // finding at the reference. + const resolveRef = (record, ref) => { + if (ref.form === "local") { + const candidates = record.idMap.get(ref.id) ?? []; + if (candidates.length !== 1) return null; + const node = candidates[0]; + if (!record.info.get(node).defined) return null; + return { record, node }; + } + const targetRel = record.bindings.get(ref.binding); + if (targetRel === undefined) return null; + const target = files.get(targetRel); + if (target === undefined || target.failure !== null) return null; + if (ref.segments.length === 0) + return { record: target, node: target.parsed.root }; + const candidates = target.idMap.get(ref.segments.join(".")) ?? []; + if (candidates.length !== 1) return null; + const node = candidates[0]; + if (!target.info.get(node).defined) return null; + return { record: target, node }; + }; + + /** @type {object[]} every recorded occurrence, in file/document order. */ + const records = []; + for (const record of files.values()) { + if (record.failure !== null) continue; + const fileRecords = []; + for (const section of record.parsed.sections) { + const info = record.info.get(section); + for (const ref of info.dRefs) { + const resolved = resolveRef(record, ref); + if (resolved === null) { + addFinding( + "14.5", + "unknown dependency: the `d` reference does not name exactly one target with a defined identity (SPEC 2.2, 11.2, 14.5)", + [{ file: record.rel, range: ref.range }], + ); + continue; + } + fileRecords.push({ + file: record.rel, + range: ref.range, + kind: "depends", + sourceNode: section, + sourceRecord: record, + targetNode: resolved.node, + targetRecord: resolved.record, + }); + } + } + for (const embed of record.parsed.embeds) { + const resolved = resolveRef(record, embed.ref); + if (resolved === null) { + // The finding's one location is EXACTLY the full braced container — + // the span the occurrence would occupy (SPEC 14, 5.7). + addFinding( + "14.6", + "unknown text target: the text(...) reference does not name exactly one target with a defined identity (SPEC 2.3, 11.2, 14.6)", + [ + { + file: record.rel, + range: byteRange(record, embed.start, embed.end), + }, + ], + ); + continue; + } + embed.target = resolved; + fileRecords.push({ + file: record.rel, + range: byteRange(record, embed.start, embed.end), + kind: "embeds", + sourceNode: embed.owner, + sourceRecord: record, + targetNode: resolved.node, + targetRecord: resolved.record, + }); + } + fileRecords.sort( + (a, b) => a.range.start - b.range.start || a.range.end - b.range.end, + ); + records.push(...fileRecords); + } + + // --- Cycles (SPEC 5.3, 14.9): strongly connected components over the + // recorded reference edges — one finding per cycle (a self-loop, or an + // SCC of two or more nodes), locating every participating reference + // spelling in file/range order. + { + const nodeKeys = new Map(); + const keyOf = (rec, node) => { + let map = nodeKeys.get(rec); + if (map === undefined) { + map = new Map(); + nodeKeys.set(rec, map); + } + let key = map.get(node); + if (key === undefined) { + key = { rec, node }; + map.set(node, key); + } + return key; + }; + const adjacency = new Map(); + const edges = records.map((occurrence) => { + const from = keyOf(occurrence.sourceRecord, occurrence.sourceNode); + const to = keyOf(occurrence.targetRecord, occurrence.targetNode); + const list = adjacency.get(from) ?? []; + list.push(to); + adjacency.set(from, list); + return { from, to, occurrence }; + }); + // Tarjan's SCC over the touched nodes. + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack = []; + const sccOf = new Map(); + let counter = 0; + let sccCount = 0; + const strongConnect = (v) => { + index.set(v, counter); + low.set(v, counter); + counter += 1; + stack.push(v); + onStack.add(v); + for (const w of adjacency.get(v) ?? []) { + if (!index.has(w)) { + strongConnect(w); + low.set(v, Math.min(low.get(v), low.get(w))); + } else if (onStack.has(w)) { + low.set(v, Math.min(low.get(v), index.get(w))); + } + } + if (low.get(v) === index.get(v)) { + const members = []; + for (;;) { + const w = stack.pop(); + onStack.delete(w); + members.push(w); + if (w === v) break; + } + for (const member of members) sccOf.set(member, sccCount); + sccCount += 1; + } + }; + const allKeys = new Set(); + for (const edge of edges) { + allKeys.add(edge.from); + allKeys.add(edge.to); + } + for (const key of allKeys) { + if (!index.has(key)) strongConnect(key); + } + const cyclic = new Map(); + for (const edge of edges) { + const same = sccOf.get(edge.from) === sccOf.get(edge.to); + const cycleEdge = + edge.from === edge.to || (same && sccSize(sccOf, edge.from) > 1); + if (!cycleEdge) continue; + const scc = sccOf.get(edge.from); + const list = cyclic.get(scc) ?? []; + list.push(edge.occurrence); + cyclic.set(scc, list); + } + for (const participants of cyclic.values()) { + const locations = participants + .map((occurrence) => ({ + file: occurrence.file, + range: occurrence.range, + })) + .sort( + (a, b) => + compareRelBytes(a.file, b.file) || + a.range.start - b.range.start || + a.range.end - b.range.end, + ); + addFinding( + "14.9", + "cycle: the reference spellings below form a dependency cycle (SPEC 5.3, 14.9)", + locations, + ); + } + } + + return { config, files, findings, records, nodeIdentity }; +} + +/** The size of a key's SCC (helper for the cycle pass above). */ +function sccSize(sccOf, key) { + const target = sccOf.get(key); + let size = 0; + for (const value of sccOf.values()) { + if (value === target) size += 1; + } + return size; +} + +// --------------------------------------------------------------------------- +// Attributed compilation (SPEC 3 + 1.6) and expansion definedness (11.2) +// --------------------------------------------------------------------------- + +/** Whether `owner` is `node` or one of its descendants. */ +function ownerWithin(owner, node) { + for (let n = owner; n !== null && n !== undefined; n = n.parent) { + if (n === node) return true; + } + return false; +} + +/** The subtree text of `node` over an atom list (SPEC 1.6). */ +function textOfSubtreeAtoms(atoms, node) { + let out = ""; + for (const atom of atoms) { + if (ownerWithin(atom.owner, node)) out += atom.text; + } + return out; +} + +/** The own text of `node` over an atom list (SPEC 1.6). */ +function textOfOwnAtoms(atoms, node) { + let out = ""; + for (const atom of atoms) { + if (atom.owner === node) out += atom.text; + } + return out; +} + +/** + * Compile one parsed file to attributed output atoms per SPEC 3 — the + * CONF-MD fixture's line model with ownership tracked per atom. + * `expansionFor(piece, atoms)` supplies each embedding's expansion (the + * target's compiled subtree text; the empty string where no complete + * expansion exists — read only from poisoned nodes' values, which are + * emitted as the marker, never these bytes). It receives the running atom + * list, whose finalized lines a same-file backward target's subtree is + * read from (the target closed on an earlier line, so its atoms are final + * by the time its embedding compiles — true of every staged fixture). + */ +function compileAttributed(record, expansionFor) { + const atoms = []; + let survivors = []; + let sourceHadNonWhitespace = false; + let expansionContributed = false; + + const finalizeLine = (terminator, terminatorOwner) => { + let remaining = ""; + for (const survivor of survivors) remaining += survivor.text; + const dropped = + sourceHadNonWhitespace && + !expansionContributed && + isWhitespaceOnlyForDrop(remaining); + if (!dropped) { + for (const survivor of survivors) { + if (survivor.text !== "") atoms.push(survivor); + } + if (terminator !== "") { + atoms.push({ text: terminator, owner: terminatorOwner }); + } + } + survivors = []; + sourceHadNonWhitespace = false; + expansionContributed = false; + }; + + const consumeSourceChunk = (chunk, owner) => { + if (chunk.length === 0) return; + survivors.push({ text: chunk, owner }); + if (!isWhitespaceOnlyForDrop(chunk)) sourceHadNonWhitespace = true; + }; + + for (const piece of record.parsed.pieces) { + if (piece.kind === "content") { + const text = piece.text; + let start = 0; + let i = 0; + while (i < text.length) { + const code = text.charCodeAt(i); + if (code !== 0x0a && code !== 0x0d) { + i += 1; + continue; + } + const terminator = terminatorAt(text, i); + if (terminator === null) { + i += 1; + continue; + } + consumeSourceChunk(text.slice(start, i), piece.owner); + finalizeLine(terminator, piece.owner); + i += terminator.length; + start = i; + } + consumeSourceChunk(text.slice(start), piece.owner); + } else { + // The construct's own characters are source characters of the current + // logical line: their non-whitespace counts for "contained + // non-whitespace in the source". They are deleted — internal + // terminators included. + if (!isWhitespaceOnlyForDrop(piece.text)) sourceHadNonWhitespace = true; + if (piece.kind === "embed") { + const expansion = expansionFor(piece, atoms); + if (expansion.length > 0) { + survivors.push({ text: expansion, owner: piece.owner }); + expansionContributed = true; + } + } + } + } + finalizeLine("", record.parsed.root); + return atoms; +} + +/** + * Per-workspace text engine: expansion definedness (the poisoning rules of + * SPEC 11.2 — a value is defined exactly when every embedding its expansion + * transitively reaches records an occurrence and the recursion re-enters no + * node already being expanded) plus the attributed compile per file. + * Returns per-node own/subtree text datums (a byte-exact string or the + * unavailability sentinel). + */ +function buildTextEngine(ws) { + // subtreeExpansionOk, memoized tri-state: can `node`'s subtree be fully + // expanded? A re-entry while computing is a cycle: poisoned. + const subtreeMemo = new Map(); + const subtreeExpansionOk = (record, node) => { + const memo = subtreeMemo.get(node); + if (memo === "computing") return false; + if (memo !== undefined) return memo; + subtreeMemo.set(node, "computing"); + let ok = true; + for (const embed of record.parsed.embeds) { + if (!ownerWithin(embed.owner, node)) continue; + if (embed.target === null) { + ok = false; + break; + } + if (!subtreeExpansionOk(embed.target.record, embed.target.node)) { + ok = false; + break; + } + } + subtreeMemo.set(node, ok); + return ok; + }; + const ownExpansionOk = (record, node) => { + for (const embed of record.parsed.embeds) { + if (embed.owner !== node) continue; + if (embed.target === null) return false; + if (!subtreeExpansionOk(embed.target.record, embed.target.node)) { + return false; + } + } + return true; + }; + + // Per-file attributed compile, memoized. Cross-file expansions compile + // the target's file first; a same-file target must close before its + // embedding (true of every staged fixture) — a self, enclosing, forward, + // or cross-file-cyclic target yields no expansion, and such an embedding + // is always poisoned (its owner's values are the marker), so the + // fabricated bytes are never read (module header). + const compiled = new Map(); + const inProgress = new Set(); + const compileFile = (rel) => { + const memo = compiled.get(rel); + if (memo !== undefined) return memo; + if (inProgress.has(rel)) return null; // cross-file cycle: poisoned + inProgress.add(rel); + const record = ws.files.get(rel); + const result = compileAttributed(record, (piece, runningAtoms) => { + const target = piece.embed.target; + if (target === null) return ""; + if (target.record.rel === rel) { + // A same-file target must have closed on an earlier line for its + // atoms to be final in the running list; a self, enclosing, or + // forward target yields no expansion and is always poisoned. + if (!(target.node.closeEnd <= piece.embed.start)) return ""; + return textOfSubtreeAtoms(runningAtoms, target.node); + } + const targetAtoms = compileFile(target.record.rel); + if (targetAtoms === null) return ""; + return textOfSubtreeAtoms(targetAtoms, target.node); + }); + inProgress.delete(rel); + compiled.set(rel, result); + return result; + }; + + return { + textsFor(record) { + if (record.failure !== null) return null; + const atoms = compileFile(record.rel) ?? []; + const texts = new Map(); + const nodes = [record.parsed.root, ...record.parsed.sections]; + for (const node of nodes) { + texts.set(node, { + ownText: ownExpansionOk(record, node) + ? textOfOwnAtoms(atoms, node) + : UNAVAILABLE, + subtreeText: subtreeExpansionOk(record, node) + ? textOfSubtreeAtoms(atoms, node) + : UNAVAILABLE, + }); + } + return texts; + }, + }; +} + +// --------------------------------------------------------------------------- +// Findings documents (SPEC 12.7, 14) +// --------------------------------------------------------------------------- + +/** A condition's ordinal (the `N` of `14.N`), ordering findings (SPEC 12.7). */ +function conditionOrdinal(condition) { + return Number(condition.slice(3)); +} + +/** + * The pinned findings order (SPEC 12.7): by code (numbered conditions in + * numeric order — this scope reports no refusal or code-less findings), + * then locations element-wise (file path bytes, range start, range end; a + * proper prefix first), then concerned path (null before any path), then + * identities, then message — this scope's identities are always empty. + */ +function compareFindingDocs(a, b) { + const byOrdinal = + conditionOrdinal(a.internalCondition) - + conditionOrdinal(b.internalCondition); + if (byOrdinal !== 0) return byOrdinal; + const shared = Math.min(a.locations.length, b.locations.length); + for (let i = 0; i < shared; i += 1) { + const byFile = compareRelBytes(a.locations[i].file, b.locations[i].file); + if (byFile !== 0) return byFile; + if (a.locations[i].range.start !== b.locations[i].range.start) { + return a.locations[i].range.start - b.locations[i].range.start; + } + if (a.locations[i].range.end !== b.locations[i].range.end) { + return a.locations[i].range.end - b.locations[i].range.end; + } + } + if (a.locations.length !== b.locations.length) { + return a.locations.length - b.locations.length; + } + if ((a.path === null) !== (b.path === null)) return a.path === null ? -1 : 1; + if (a.path !== null && b.path !== null) { + const byPath = compareRelBytes(a.path, b.path); + if (byPath !== 0) return byPath; + } + return Buffer.compare( + Buffer.from(a.message, "utf8"), + Buffer.from(b.message, "utf8"), + ); +} + +/** + * Render internal findings as the 12.7 `findings` array value: one + * `{"code", "message", "locations", "path", "identities"}` per finding — + * every scope condition locates in source, so `path` is null and + * `locations` non-empty, each finding's locations already in file/range + * order — in the pinned order, identical findings collapsed to one. + */ +function findingsValue(findings) { + const docs = findings.map((finding) => ({ + code: CODE_TOKENS[finding.condition], + message: finding.message, + locations: finding.locations.map((location) => ({ + file: location.file, + range: { start: location.range.start, end: location.range.end }, + })), + path: null, + identities: [], + internalCondition: finding.condition, + })); + docs.sort(compareFindingDocs); + const collapsed = []; + for (const doc of docs) { + const previous = collapsed[collapsed.length - 1]; + if (previous !== undefined && compareFindingDocs(previous, doc) === 0) { + continue; + } + collapsed.push(doc); + } + return collapsed.map(({ code, message, locations, path: p, identities }) => ({ + code, + message, + locations, + path: p, + identities, + })); +} + +/** + * The findings of a consulted domain (SPEC 11.2, 11.3, 11.4): a finding + * accompanies exactly the answers whose domain includes a file it locates + * in (every scope condition is located; a cross-file finding accompanies + * when any participant's file is in the domain). + */ +function domainFindings(ws, domain) { + return ws.findings.filter((finding) => + finding.locations.some((location) => domain.has(location.file)), + ); +} + +// --------------------------------------------------------------------------- +// Argument parsing (SPEC 12.0) +// --------------------------------------------------------------------------- + +/** + * Parse flags per command. `flagSpec` maps flag names to "bool" | "value"; + * unknown and repeated flags are usage errors (SPEC 12.0). + */ +function parseArgs(argv, flagSpec, positionalRange) { + const flags = {}; + const positionals = []; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg.startsWith("--")) { + const kind = flagSpec[arg]; + if (kind === undefined) + throw new UsageError(`unknown flag ${arg} (SPEC 12.0)`); + if (Object.hasOwn(flags, arg)) { + throw new UsageError( + `repeated flag ${arg}: a flag may be given at most once (SPEC 12.0)`, + ); + } + if (kind === "bool") { + flags[arg] = true; + } else { + const value = argv[i + 1]; + if (value === undefined) + throw new UsageError(`missing value for ${arg} (SPEC 12.0)`); + flags[arg] = value; + i += 1; + } + } else { + positionals.push(arg); + } + } + const [min, max] = positionalRange; + if (positionals.length < min || positionals.length > max) { + throw new UsageError( + `expected ${min === max ? String(min) : `${String(min)}-${String(max)}`} argument(s), got ${String(positionals.length)} (SPEC 12.0)`, + ); + } + return { flags, positionals }; +} + +// --------------------------------------------------------------------------- +// `xspec view` (SPEC 11.4) +// --------------------------------------------------------------------------- + +/** + * One per-file view (SPEC 11.4, 12.7): `{"file", "root", "imports", + * "occurrences", "comments"}` — the full positional tree with per-node + * identity/tags/coverage datums (and own/subtree text under `--text`), + * every import declaration, the file's own occurrence records, and the + * comment ranges, all in document order. + */ +function fileViewDoc(ws, record, fileRecords, texts) { + const { parsed } = record; + const nodeDoc = (node) => { + const doc = { + identity: ws.nodeIdentity(record, node), + range: node.isRoot + ? { start: 0, end: record.byteOf(record.text.length) } + : byteRange(record, node.openStart, node.closeEnd), + opening: node.isRoot + ? null + : byteRange(record, node.openStart, node.openEnd), + closing: + node.isRoot || node.selfClosing + ? null + : byteRange(record, node.closeStart, node.closeEnd), + attributes: node.attrs.map((attr) => ({ + name: attr.name, + range: byteRange(record, attr.start, attr.end), + text: record.text.slice(attr.start, attr.end), + })), + tags: node.isRoot ? null : record.info.get(node).tags, + coverage: node.isRoot ? null : record.info.get(node).coverage, + children: node.children.map(nodeDoc), + }; + if (texts !== null) { + const nodeTexts = texts.get(node); + doc.ownText = nodeTexts.ownText; + doc.subtreeText = nodeTexts.subtreeText; + } + return doc; + }; + return { + file: record.rel, + root: nodeDoc(parsed.root), + imports: parsed.imports.map((declaration) => ({ + range: byteRange(record, declaration.start, declaration.end), + name: declaration.name, + target: + declaration.resolvedTarget === null + ? UNAVAILABLE + : declaration.resolvedTarget, + })), + occurrences: fileRecords.map((occurrence) => occurrenceDoc(ws, occurrence)), + comments: parsed.comments.map((comment) => + byteRange(record, comment.start, comment.end), + ), + }; +} + +/** One occurrence record in the 12.7 form (SPEC 5.7, 11.2). */ +function occurrenceDoc(ws, occurrence) { + const sourceIdentity = ws.nodeIdentity( + occurrence.sourceRecord, + occurrence.sourceNode, + ); + return { + file: occurrence.file, + range: { start: occurrence.range.start, end: occurrence.range.end }, + kind: occurrence.kind, + // Source: the graph node `{identity, range}` — or the unavailability + // marker where 11.2 leaves that identity undefined: identity and range + // withheld together as ONE datum, never a picked bearer, never null. + source: + sourceIdentity === UNAVAILABLE + ? UNAVAILABLE + : { + identity: sourceIdentity, + range: occurrence.sourceNode.isRoot + ? { + start: 0, + end: occurrence.sourceRecord.byteOf( + occurrence.sourceRecord.text.length, + ), + } + : byteRange( + occurrence.sourceRecord, + occurrence.sourceNode.openStart, + occurrence.sourceNode.closeEnd, + ), + }, + target: ws.nodeIdentity(occurrence.targetRecord, occurrence.targetNode), + }; +} + +async function commandView(io, cwd, argv) { + const { flags, positionals } = parseArgs( + argv, + { + "--json": "bool", + "--config": "value", + "--text": "bool", + "--file": "value", + }, + [0, Number.POSITIVE_INFINITY], + ); + if (positionals.length > 0 && flags["--file"] !== undefined) { + throw new UsageError( + "`view` takes `<file>` operands or `--file`, not both (SPEC 11.4, 12.0)", + ); + } + const ws = await loadWorkspace(cwd, flags["--config"]); + const discovered = [...ws.files.keys()]; + + // The requested files (SPEC 11.4): operands assert membership in the + // discovered spec-source domain and form a set; `--file` is a set + // restriction over the domain; neither means the whole domain. + let requested; + if (positionals.length > 0) { + const set = new Set(); + for (const operand of positionals) { + if (!ws.files.has(operand)) { + throw new UsageError( + `unknown file: ${operand} is not a discovered spec source (SPEC 11.4, 12.0)`, + ); + } + set.add(operand); + } + requested = discovered.filter((rel) => set.has(rel)); + } else if (flags["--file"] !== undefined) { + requested = discovered.filter((rel) => globMatches(flags["--file"], rel)); + } else { + requested = discovered; + } + + // The consulted domain (SPEC 11.4): the requested files — plus, exactly + // under `--text`, the files of resolved targets reachable through + // occurrence-recording embeddings (expansion consults them). + const domain = new Set(requested); + if (flags["--text"]) { + for (;;) { + let grew = false; + for (const occurrence of ws.records) { + if (occurrence.kind !== "embeds") continue; + if (!domain.has(occurrence.file)) continue; + const targetRel = occurrence.targetRecord.rel; + if (!domain.has(targetRel)) { + domain.add(targetRel); + grew = true; + } + } + if (!grew) break; + } + } + + const textEngine = flags["--text"] ? buildTextEngine(ws) : null; + const views = []; + for (const rel of requested) { + const record = ws.files.get(rel); + if (record.failure !== null) continue; // no view; the 14.20 accompanies + const fileRecords = ws.records.filter( + (occurrence) => occurrence.file === rel, + ); + const texts = textEngine === null ? null : textEngine.textsFor(record); + views.push(fileViewDoc(ws, record, fileRecords, texts)); + } + const doc = { + findings: findingsValue(domainFindings(ws, domain)), + views, + }; + const exitCode = doc.findings.length > 0 || containsUnavailable(doc) ? 1 : 0; + io.stdout(renderDocument(doc)); + return exitCode; +} + +// --------------------------------------------------------------------------- +// `xspec occurrences` (SPEC 11.3) +// --------------------------------------------------------------------------- + +async function commandOccurrences(io, cwd, argv) { + const { flags } = parseArgs( + argv, + { + "--json": "bool", + "--config": "value", + "--file": "value", + "--to": "value", + }, + [0, 0], + ); + const ws = await loadWorkspace(cwd, flags["--config"]); + + // The consulted domain (SPEC 11.3): the entire discovered set, or the + // discovered files the `--file` glob admits. §VIOL-AVAIL-NOFILE + // (bin-nofile.mjs, `ignoreFileRestriction`) hooks exactly here: the flag + // and its argument are accepted as specified, but the consulted domain is + // the entire discovered set, exactly as with the flag absent — the + // enumeration and the findings accompanying it follow that widened + // domain; `--to` selection and `view` are unchanged. + const restriction = + deviations.ignoreFileRestriction === true ? undefined : flags["--file"]; + const domain = new Set( + restriction === undefined + ? ws.files.keys() + : [...ws.files.keys()].filter((rel) => globMatches(restriction, rel)), + ); + + // The enumeration: the domain files' records, in occurrence order (5.7: + // file path bytes, then range start, then range end), selected by `--to` + // where given (acceptance is syntactic: an empty selection is an answer). + let selected = ws.records.filter((occurrence) => domain.has(occurrence.file)); + if (flags["--to"] !== undefined) { + selected = selected.filter((occurrence) => { + const target = ws.nodeIdentity( + occurrence.targetRecord, + occurrence.targetNode, + ); + return target === flags["--to"]; + }); + } + selected = [...selected].sort( + (a, b) => + compareRelBytes(a.file, b.file) || + a.range.start - b.range.start || + a.range.end - b.range.end, + ); + + const doc = { + findings: findingsValue(domainFindings(ws, domain)), + occurrences: selected.map((occurrence) => occurrenceDoc(ws, occurrence)), + }; + const exitCode = doc.findings.length > 0 || containsUnavailable(doc) ? 1 : 0; + io.stdout(renderDocument(doc)); + return exitCode; +} + +// --------------------------------------------------------------------------- +// Entry: deviation seam + dispatch +// --------------------------------------------------------------------------- + +/** + * Run one xspec invocation. Returns the exit code (SPEC 12.0 partition). + * `options` is the seam through which each violator fixture's bin-<name>.mjs + * entry threads exactly one deviation switch (the conformer's bin.mjs passes + * none); see the `deviations` doc in the module header for where + * §VIOL-AVAIL-NULLMARKER, §VIOL-AVAIL-OMIT, and §VIOL-AVAIL-NOFILE hook. + */ +export async function runXspec(argv, cwd, options = {}) { + deviations = options; + const io = { + stdout: (text) => process.stdout.write(text), + stderr: (text) => process.stderr.write(text), + }; + return await dispatchCommand(io, cwd, argv); +} + +/** Dispatch one parsed invocation and map its outcome to SPEC 12.0's codes. */ +async function dispatchCommand(io, cwd, argv) { + const command = argv[0]; + // The served surfaces are JSON-only (SPEC 11): JSON output is in effect + // for them whatever the arguments, so their usage errors emit the single + // 12.7 error document; an unknown command emits it only under `--json`. + const jsonInEffect = + command === "view" || command === "occurrences" || argv.includes("--json"); + try { + const rest = argv.slice(1); + switch (command) { + case "view": + return await commandView(io, cwd, rest); + case "occurrences": + return await commandOccurrences(io, cwd, rest); + default: + throw new UsageError( + `unknown command ${String(command)} (SPEC 12.0; this fixture's surface is view and occurrences, CERTIFICATIONS.md §CONF-AVAIL)`, + ); + } + } catch (error) { + if (error instanceof UsageError) { + // Usage/configuration errors (SPEC 12.0): the message is stderr + // content; with JSON output in effect the single 12.7 error document + // — {"error": …} holding one finding form — is the entire stdout. + if (jsonInEffect) { + io.stdout( + renderDocument({ + error: { + code: error.code, + message: error.message, + locations: [], + path: error.path, + identities: [], + }, + }), + ); + } + io.stderr(`xspec: ${error.message}\n`); + return 2; + } + // A crash is a fixture bug: exit outside the 12.0 partition so every + // exit-code assertion fails loudly and the diagnosis carries the stack. + io.stderr( + `xspec: internal fixture error: ${error?.stack ?? String(error)}\n`, + ); + return 70; + } +} diff --git a/test/self/certification-document.test.ts b/test/self/certification-document.test.ts index 33f46b8..8a6b888 100644 --- a/test/self/certification-document.test.ts +++ b/test/self/certification-document.test.ts @@ -31,8 +31,8 @@ const CERTIFICATIONS_PATH = fileURLToPath( // equality below carries the detail; these pins force a deliberate visit to // this gate when the document's fixture set changes, and guard against a // parser regression losing entries wholesale. -const EXPECTED_CONFORMERS = 4; -const EXPECTED_VIOLATORS = 13; +const EXPECTED_CONFORMERS = 5; +const EXPECTED_VIOLATORS = 16; /** A violator entry as parsed from CERTIFICATIONS.md. */ interface DocumentViolator { @@ -265,7 +265,7 @@ function parseDocument(): readonly DocumentConformer[] { ); } -test("CERTIFICATIONS.md defines exactly 4 conformers and 13 violators (C-1 whole-document gate)", () => { +test("CERTIFICATIONS.md defines exactly 5 conformers and 16 violators (C-1 whole-document gate)", () => { const document = parseDocument(); expect( { diff --git a/test/self/certification-fixtures.ts b/test/self/certification-fixtures.ts index b8afbb0..9e2317c 100644 --- a/test/self/certification-fixtures.ts +++ b/test/self/certification-fixtures.ts @@ -260,4 +260,51 @@ export const CERTIFICATION_FIXTURES: readonly CertificationConformer[] = [ violator("VIOL-DISC-DERIVED", "conf-disc/bin-derived.mjs", ["T7-6"]), ], ), + // CONF-AVAIL (§CONF-AVAIL): availability answers and JSON datum forms — + // `view` (with and without `--text`) and `occurrences` over spec-only + // `.mdx` workspaces, answering in the form-exact 12.7 document forms with + // the three-state datums (plain value / stated `null` / the unavailability + // marker), the 11.2 availability rules (spelled-identity definedness, + // chain conditions, resolution through defined identities, whole-value + // expansion poisoning, removal classification by form), occurrence + // records per SPEC 5.7/11.3, the `--file`/`--to` domain rules of 11.3, + // the raw attribute and import data of 11.4, findings with stable codes + // for the staged conditions, and the 11.2 exit discipline. + conformer( + "CONF-AVAIL", + "conf-avail/bin.mjs", + ["T11.2-2", "T11.2-4", "T11.3-4", "T11.4-1", "T11.4-3", "T11.4-4"], + [ + // VIOL-AVAIL-NULLMARKER: the unavailability marker is never emitted — + // every datum the rules of SPEC 11.2 leave undefined is carried as + // `null` in place of {"unavailable": true} (12.7). Which data are + // undefined, all defined values, findings, exit codes, and every + // other document member are unchanged. + violator("VIOL-AVAIL-NULLMARKER", "conf-avail/bin-nullmarker.mjs", [ + "T11.2-2", + "T11.2-4", + "T11.4-3", + "T11.4-4", + ]), + // VIOL-AVAIL-OMIT: `null`-valued members are omitted — every member + // whose value an answer would carry as the stated `null` (12.7) is + // absent from the emitted document (a viewed root's `tags` and + // `coverage` and a located finding's `path` among them). Members with + // plain, marker, or list values, which findings exist, and exit codes + // are unchanged. + violator("VIOL-AVAIL-OMIT", "conf-avail/bin-omit.mjs", [ + "T11.2-2", + "T11.2-4", + "T11.4-1", + "T11.4-3", + "T11.4-4", + ]), + // VIOL-AVAIL-NOFILE: `occurrences` does not apply the `--file` + // restriction — the flag and its argument checks behave as specified + // (11.3), but the consulted domain is the entire discovered set, + // exactly as with the flag absent. `--to` selection, `view`, and + // every other behavior are unchanged. + violator("VIOL-AVAIL-NOFILE", "conf-avail/bin-nofile.mjs", ["T11.3-4"]), + ], + ), ]; diff --git a/test/self/certification.test.ts b/test/self/certification.test.ts index 99e9eef..7802913 100644 --- a/test/self/certification.test.ts +++ b/test/self/certification.test.ts @@ -1,8 +1,8 @@ // Certification of CERTIFICATIONS.md fixtures (TEST-SPEC 17 C-1, C-2). // // One per-fixture verification is generated below for every entry of the -// CERTIFICATION_FIXTURES manifest (certification-fixtures.ts) — all four -// conformers and all thirteen violators — and the whole-document gate +// CERTIFICATION_FIXTURES manifest (certification-fixtures.ts) — all five +// conformers and all sixteen violators — and the whole-document gate // (certification-document.test.ts) proves that manifest equal to // specs/CERTIFICATIONS.md, so certification demonstrably runs against each // fixture in the document (C-1). From 26adc0b7e0bf82a09864dbffb2d91fb7fb8ca2c9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 18:27:26 +0000 Subject: [PATCH 137/226] sdg(phase-9): extend the E-6 fixture with the new command steps (FP-092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The representative fixture now also exercises `version` (opening step), bare `occurrences`, `view --text`, `at` (offset derived from the staged Core.mdx constant, inside the {text(Other.oth)} embedding), a `move --preview --json` of exactly the move then applied, and `inventory` invoked from the nested specs/sub working directory the move created (pinning the relative /-joined anchoring), via an optional cwd on the step helper — 17 to 23 invocations, exchange format unchanged. Both E-6 tests stay red-as-diagnosed against the stub (now at "version"); test:self fully green (330). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 21 +++++- test/helpers/e6.ts | 99 +++++++++++++++++++++++++-- test/suite/e6-exchange-writer.test.ts | 12 ++-- test/windows/e6-byte-identity.test.ts | 19 +++-- 4 files changed, 131 insertions(+), 20 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index f147d59..e2c8aab 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -4087,7 +4087,7 @@ certify against FP-091's fixtures once those land. ## Stage J — E-6 cross-platform legs -- [ ] FP-092 — Extend the E-6 representative fixture with the new command +- [x] FP-092 — Extend the E-6 representative fixture with the new command steps. [R2 #43; TEST-SPEC E-6] `test/helpers/e6.ts` (the `step(...)` sequence): add `occurrences`, `view --text`, `at`, `inventory` (invoked from a NESTED working @@ -4095,6 +4095,25 @@ certify against FP-091's fixtures once those land. documents byte-compared across legs via the existing exchange (`test/suite/e6-exchange-writer.test.ts`, `test/windows/ e6-byte-identity.test.ts`, `XSPEC_E6_EXCHANGE_DIR`; see AGENTS.md). + [Done 2026-08-29: test/helpers/e6.ts — the step sequence now opens with + `version` and adds bare `occurrences`, `view --text`, and `at` after the + impact steps (the `at` offset derived from the staged Core.mdx constant, + landing inside the `{text(Other.oth)}` embedding so the answer carries + section + occurrence + resolved target; Core.mdx is still pristine + there), `move --preview --json` immediately before the applied move + (previewing exactly the operation the next step performs), and + `inventory` as the final step from `specs/sub` — the nested directory + the move created — via a new optional `cwd` parameter on the `step` + helper (root `../..`, config `../../xspec.config.ts`: the /-joined + anchoring at its densest — journal occupied, session listed). 17 → 23 + invocations; exchange format unchanged (step names/argv/exit codes plus + stream and workspace bytes still compared). Command-list and + 23-invocation comments updated in e6-exchange-writer.test.ts and + e6-byte-identity.test.ts, hang guards 240s → 300s. Verified: typecheck + + format:check clean; both E-6 tests fail red-as-diagnosed at the new + first step (`version` — the stub knows no such command, exit 2), no + crash/hang, the Windows leg failing before consulting the exchange; + `npm run test:self` fully green (330 passed).] - [ ] FP-093 — Add the Windows-subset drive-mismatch anchoring arm of T11.6-1. [R2 #43; TEST-SPEC §11.6 (E-6 arm)] After FP-069. diff --git a/test/helpers/e6.ts b/test/helpers/e6.ts index fdda5ae..e71234b 100644 --- a/test/helpers/e6.ts +++ b/test/helpers/e6.ts @@ -2,14 +2,17 @@ // Harness machinery only: no product imports; the product is driven strictly // as a subprocess through a ProductBinding (H-2, C-2). // -// One fixture story exercises the E-6 command set — `build`, `check`, -// `query`, `coverage`, `impact`, a journaled `rename`, a journaled file-form -// `move`, and an `audit` review session (`review create --strategy audit`, -// `next --json`, a `resolve`, an `export`) — and captures two kinds of -// output: +// One fixture story exercises the E-6 command set — `version`, `build`, +// `check`, `query`, `coverage`, `impact`, `occurrences`, `view --text`, +// `at`, a `move --preview`, a journaled `rename`, a journaled file-form +// `move`, an `audit` review session (`review create --strategy audit`, +// `next --json`, a `resolve`, an `export`), and `inventory` invoked from a +// nested working directory, pinning the relative `/`-joined anchoring (SPEC +// 11.6) — and captures two kinds of output: // // - the transcript: every invocation's argv, exit code, and exact -// stdout/stderr bytes (reports, 12.0); +// stdout/stderr bytes — the path- and range-dense occurrence, view, at, +// inventory, and preview documents included (reports, 12.0); // - the final workspace tree, `.git/` excluded: move-rewritten sources, // generated files, emitted Markdown, graph data, the journal, and the // session file (stored data, 1.5/13.4). @@ -172,6 +175,14 @@ const E6_APP_SOURCE = [ "", ].join("\n"); +// `at` probe: the fixture points `at` at the byte offset of `Other.oth` +// inside core.mid.leaf's `{text(Other.oth)}` embedding — within the +// occurrence's braced span (SPEC 5.7), so the answer carries the innermost +// section, the occurrence, and its resolved target (11.5). The offset is +// derived from the staged constant (pure ASCII, so character offsets are +// byte offsets), making both legs pass the identical decimal argument. +const E6_AT_EMBEDDING = "{text(Other.oth)}"; + const GIT_DIR_BYTES = Buffer.from(".git", "utf8"); /** Exclude exactly the top-level `.git` tree from workspace snapshots. */ @@ -210,9 +221,10 @@ export async function runE6RepresentativeFixture( argv: readonly string[], expectedExit: number, why: string, + cwd: string = workspace.root, ): Promise<RunResult> => { const result = await runProduct(product, { - cwd: workspace.root, + cwd, argv, }); assertExitCode( @@ -230,6 +242,15 @@ export async function runE6RepresentativeFixture( return result; }; + // The interface handshake first: workspace-independent, JSON-only, fixed + // per build — both legs build the same commit, so the document compares + // byte-identical (SPEC 12.6, 12.0). + await step( + "version", + ["version"], + 0, + "the version handshake answers anywhere, workspace-independent (SPEC 12.6)", + ); await step( "build", ["build"], @@ -296,6 +317,42 @@ export async function runE6RepresentativeFixture( "the pinned baseline commit resolves and the report answers (SPEC 9, 5.6)", ); + // The per-file query surfaces (SPEC 11.2–11.5), JSON-only and + // non-mutating, over the valid workspace: the whole-domain occurrence + // enumeration (`d` references, `text(...)` embeddings, and the code + // file's TypeScript markers alike), every spec source's structural view + // with own/subtree text, and one byte-position resolution — the path- + // and range-dense documents E-6 byte-compares across legs. Core.mdx is + // still byte-identical to its staged constant here (the leaf edit + // touched Other.mdx only; the rename comes later), so the `at` offset + // derived from the constant addresses the live file. + await step( + "occurrences", + ["occurrences"], + 0, + "the whole-domain enumeration answers finding-free on the valid workspace (SPEC 11.3, 5.7)", + ); + await step( + "view-text", + ["view", "--text"], + 0, + "every discovered spec source serves its structural view with text (SPEC 11.4, 11.2)", + ); + const atBase = E6_CORE_SOURCE.indexOf(E6_AT_EMBEDDING); + if (atBase < 0) { + throw new Error( + `E-6 fixture bug: Core.mdx no longer stages the ${E6_AT_EMBEDDING} ` + + `embedding the \`at\` step probes — realign E6_AT_EMBEDDING with ` + + `E6_CORE_SOURCE`, + ); + } + await step( + "at", + ["at", E6_CORE, String(atBase + E6_AT_EMBEDDING.indexOf("Other.oth"))], + 0, + "the byte position resolves to the innermost section and its enclosing occurrence (SPEC 11.5)", + ); + // Journaled rename: rewrites the ID and its references in MDX and // TypeScript sources, appending the mapping to the journal (SPEC 6.4). await step( @@ -305,6 +362,18 @@ export async function runE6RepresentativeFixture( "a valid rename succeeds and appends to the journal (SPEC 6.4, 6.1)", ); + // Preview of exactly the move the next step performs: full validation + // and planning, modifying nothing (SPEC 6.6) — the identity mapping, the + // per-file edit classes with pre-operation ranges, and the derived-file + // delta form the path- and range-dense preview document (12.7); the real + // move then still proceeds identically. + await step( + "move-preview", + ["move", E6_CORE, E6_MOVED, "--preview", "--json"], + 0, + "the planned file-form move would proceed, reported while performing nothing (SPEC 6.6, 6.5)", + ); + // Journaled file-form move — the specifier-computation probe (E-6): the // moved file's own import and both importers of its generated module are // recomputed across the directory change (SPEC 6.5). @@ -366,6 +435,22 @@ export async function runE6RepresentativeFixture( "the session exports as one JSON payload (SPEC 10.7)", ); + // Inventory, invoked from the nested directory the move created + // (specs/sub, so it exists exactly when this step runs): the anchoring + // is the relative, `/`-joined canonical spelling — `root` `../..`, + // `config` `../../xspec.config.ts` (SPEC 11.6; the E-6 nested-cwd + // probe, which a native-path product misspells with `\` only on Windows) — + // and the report is at its densest: journal occupied, session `r` + // listed, recorded derived paths reflecting the move's finishing + // regeneration. + await step( + "inventory-nested", + ["inventory"], + 0, + "the workspace shape reports from a nested working directory with relative /-joined anchoring (SPEC 11.6, 12.0)", + workspace.path("specs/sub"), + ); + const snapshot = await snapshotDirectory(workspace.root, { exclude: excludeGitTree, }); diff --git a/test/suite/e6-exchange-writer.test.ts b/test/suite/e6-exchange-writer.test.ts index f5b5ac2..3613b3d 100644 --- a/test/suite/e6-exchange-writer.test.ts +++ b/test/suite/e6-exchange-writer.test.ts @@ -1,7 +1,9 @@ // E-6 Linux-side leg of the cross-platform byte-identity comparison -// (TEST-SPEC §18 E-6; CI-01). Runs the representative fixture — `build`, -// `check`, `query`, `coverage`, `impact`, a journaled `rename`, a journaled -// file-form `move`, and an `audit` review session — against the built product +// (TEST-SPEC §18 E-6; CI-01). Runs the representative fixture — `version`, +// `build`, `check`, `query`, `coverage`, `impact`, `occurrences`, +// `view --text`, `at`, a `move --preview`, a journaled `rename`, a journaled +// file-form `move`, an `audit` review session, and a nested-working-directory +// `inventory` — against the built product // (helpers/e6.ts), asserting every step's exact exit code, and writes the // captured outputs (transcript + final workspace tree) into // XSPEC_E6_EXCHANGE_DIR when it is set. The suite-linux CI job sets that @@ -29,10 +31,10 @@ import { } from "../helpers/e6.js"; import { builtProductBinding } from "../helpers/subprocess.js"; -// Generous hang guard for the whole 17-invocation fixture (H-8; each product +// Generous hang guard for the whole 23-invocation fixture (H-8; each product // invocation also carries its own subprocess timeout). Never an assertion // input (H-10). -const FIXTURE_TIMEOUT_MS = 240_000; +const FIXTURE_TIMEOUT_MS = 300_000; test( "E-6 Linux leg: the representative fixture runs against the built product; its outputs are written to XSPEC_E6_EXCHANGE_DIR for the Windows leg when set (TEST-SPEC E-6)", diff --git a/test/windows/e6-byte-identity.test.ts b/test/windows/e6-byte-identity.test.ts index fb3aeae..7998a40 100644 --- a/test/windows/e6-byte-identity.test.ts +++ b/test/windows/e6-byte-identity.test.ts @@ -1,13 +1,18 @@ // E-6 platform-sensitive subset, part 2 of 2 (TEST-SPEC §18 E-6; CI-01) — // the representative-fixture byte-identity comparison against the Linux leg. // -// The identical fixture the Linux leg ran (helpers/e6.ts: `build`, `check`, -// `query`, `coverage`, `impact`, a journaled `rename`, a journaled file-form -// `move` — the specifier-computation probe, with `check` clean after it — and -// an `audit` review session) is run here against the built product, and its +// The identical fixture the Linux leg ran (helpers/e6.ts: `version`, +// `build`, `check`, `query`, `coverage`, `impact`, `occurrences`, +// `view --text`, `at`, a `move --preview`, a journaled `rename`, a journaled +// file-form `move` — the specifier-computation probe, with `check` clean +// after it — an `audit` review session, and `inventory` from a nested +// working directory, pinning the relative `/`-joined anchoring) is run here +// against the built product, and its // outputs are asserted byte-identical to the Linux leg's, read from // XSPEC_E6_EXCHANGE_DIR (the `e6-linux-outputs` CI artifact, -// .github/workflows/ci.yml): reports (every step's stdout/stderr), +// .github/workflows/ci.yml): reports (every step's stdout/stderr — the +// path- and range-dense occurrence, view, at, inventory, and preview +// documents included), // move-rewritten sources, generated files, emitted Markdown, graph data, the // journal, and the session file — a product-to-itself comparison, permitted // by H-4, sound because both legs consume byte-identical input (12.0; the @@ -32,9 +37,9 @@ import { } from "../helpers/e6.js"; import { builtProductBinding } from "../helpers/subprocess.js"; -// Generous hang guard for the 17-invocation fixture plus the comparison +// Generous hang guard for the 23-invocation fixture plus the comparison // (H-8); never an assertion input (H-10). -const FIXTURE_TIMEOUT_MS = 240_000; +const FIXTURE_TIMEOUT_MS = 300_000; test( "E-6 byte-identity: the representative fixture's reports, rewritten sources, generated files, emitted Markdown, graph data, journal, and session file are byte-identical to the Linux leg's outputs from XSPEC_E6_EXCHANGE_DIR (TEST-SPEC E-6)", From 2e7688891e86ab557beba2f1179c645c6149af4f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 18:44:52 +0000 Subject: [PATCH 138/226] sdg(phase-9): add the Windows-subset drive-mismatch anchoring arm of T11.6-1 (FP-093) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 2 +- specs/tmp/FIX_PLAN.md | 38 +- test/windows/e6-byte-identity.test.ts | 2 +- test/windows/e6-drive-mismatch.test.ts | 516 +++++++++++++++++++++++++ test/windows/e6-subset.test.ts | 7 +- 5 files changed, 559 insertions(+), 6 deletions(-) create mode 100644 test/windows/e6-drive-mismatch.test.ts diff --git a/AGENTS.md b/AGENTS.md index 3cce1f9..73f5cf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Running tests also requires the system `git` executable on PATH: harness fixtures script local git repositories (`test/helpers/workspace.ts`). No git configuration is needed — the builder isolates all ambient git config and identity. - Harness self-tests and certification only (TEST-SPEC 17): `npm run test:self`. - Certification fixture products (CERTIFICATIONS.md, e.g. `test/fixtures/conf-core/`) are plain Node ESM programs with no build step and no dependencies: the certification runner (and manual debugging) invokes `node test/fixtures/<fixture>/bin.mjs <command> …` with a staged workspace as the working directory. Violator executables sit beside the conformer's entry as `bin-<deviation>.mjs` (e.g. `bin-nolock.mjs`) and run the same way. `test/fixtures/` is excluded from the harness typecheck; Prettier still formats it. -- Windows-leg subset (TEST-SPEC E-6; run by the Windows CI job): `npm run test:windows`. Build the product first. Its byte-identity test compares this leg's representative-fixture outputs against the Linux leg's, read from the directory named by `XSPEC_E6_EXCHANGE_DIR`; the Linux outputs are written by the suite project's E-6 writer test (`test/suite/e6-exchange-writer.test.ts`) whenever that variable is set during `npm test`, and CI exchanges them as the `e6-linux-outputs` artifact (`.github/workflows/ci.yml`). Against the stub product the whole subset is red (diagnosed failures) before any exchange is consulted; once the product conforms, run the Linux suite with the variable set, then the Windows subset with the same variable — with the variable unset or the directory absent, the byte-identity test fails loudly by design (never skips). +- Windows-leg subset (TEST-SPEC E-6; run by the Windows CI job): `npm run test:windows`. Build the product first. Its byte-identity test compares this leg's representative-fixture outputs against the Linux leg's, read from the directory named by `XSPEC_E6_EXCHANGE_DIR`; the Linux outputs are written by the suite project's E-6 writer test (`test/suite/e6-exchange-writer.test.ts`) whenever that variable is set during `npm test`, and CI exchanges them as the `e6-linux-outputs` artifact (`.github/workflows/ci.yml`). Against the stub product the whole subset is red (diagnosed failures) before any exchange is consulted; once the product conforms, run the Linux suite with the variable set, then the Windows subset with the same variable — with the variable unset or the directory absent, the byte-identity test fails loudly by design (never skips). The T11.6-1 drive-mismatch test (`test/windows/e6-drive-mismatch.test.ts`) additionally stages a substituted drive mapping (`subst`), which exists only on Windows: on any other platform it fails loudly after its same-drive premise arm (never skips), so a fully green `npm run test:windows` needs an actual Windows machine. - Local-only suite (TEST-SPEC E-2; separately invocable, never run in CI, currently empty): `npm run test:local`. - Property tests (TEST-SPEC 16; machinery in `test/helpers/property.ts`) run a fixed seed set by default — the CI mode, fully deterministic. To rerun with a specific seed: `XSPEC_PROPERTY_SEED=<uint32 from the failure message>`. Optional randomized local mode: `XSPEC_PROPERTY_SEED=random` (each property reports its seed for replay). Never set the variable in CI. Vitest intercepts the seed reports (`console.info`); add `--disable-console-intercept` to the vitest invocation to see them — but place any test-file filter argument *before* that flag (a file path following `--disable-console-intercept` is not treated as a filter and the whole project runs). - Format code (Prettier, default config, `src/` and `test/` only): `npm run format`; verify: `npm run format:check`. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index e2c8aab..806407c 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -4115,10 +4115,46 @@ certify against FP-091's fixtures once those land. crash/hang, the Windows leg failing before consulting the exchange; `npm run test:self` fully green (330 passed).] -- [ ] FP-093 — Add the Windows-subset drive-mismatch anchoring arm of +- [x] FP-093 — Add the Windows-subset drive-mismatch anchoring arm of T11.6-1. [R2 #43; TEST-SPEC §11.6 (E-6 arm)] After FP-069. `test/windows/` (beside `e6-subset.test.ts`): the drive-mismatch anchoring arm runs on the Windows leg only. + [Done 2026-08-29: new test/windows/e6-drive-mismatch.test.ts (subset + part 2 of 3; sibling headers renumbered, AGENTS.md notes the + Windows-only staging). Body order pins the e6-byte-identity failure + taxonomy (H-8/H-9 — never a skip, never a vacuous pass): (1) fixed- + vector self-checks of the platform-absolute spelling validator + (accepts `C:\…\work` vs cwd drive Z; rejects forward slashes, + drive-less, relative, trailing separator, `\\?\` prefix, same-drive) + plus the platform-join composition — pure path.win32 arithmetic, run + on every platform; (2) same-drive premise arm — flag-less `inventory` + from the workspace root, exit 0, findings [], anchoring `.` / + `xspec.config.ts` byte-exact — so a stub or nonconforming product + fails first, diagnosed, on any platform, and on the Windows leg the + arm carries its own contrast (same workspace, relative until the + drives differ); (3) non-win32 → loud plain-Error (the product answers + but `subst` exists only on Windows; run the Windows leg); (4) the + staging: a free letter substituted over a tempRoot scratch dir + (claim-by-trying Z→E — `subst` refuses in-use letters, so concurrent + instances race safely, H-1/E-3; released in finally, leak named if + deletion fails), cwd the mapped drive's root, `--config` the + realpath.native long-name drive-qualified spelling of the config (one + canonical form whether or not the product canonicalizes; the + registered body's absolute-`--config` same-drive arm already proves + the spelling never flips the form, so the absolute output is + attributable to the mismatch alone); expected `root`/`config` that + same platform-absolute spelling byte-exact through + decodeInventoryAnchoring (whose doc-comment anticipated exactly this + caller), findings [] at exit 0, repeated invocation byte-identical + stdout (deterministic per invocation, 12.0; H-4). Verified: typecheck + + format:check clean; `npm run test:windows` on this Linux container + — the arm fails red-as-diagnosed at the premise arm (unknown command + `inventory`, exit 2 vs expected 0), no crash/hang, subst never + attempted, the two sibling tests unchanged; `npm run test:self` fully + green (330 passed). The subst leg itself first executes on the + Windows CI leg once a product answers `inventory` — same epistemic + position as the exchange comparison (FP-081/FP-092), its failure + modes all loud staging errors.] ## Stage K — findings discovered mid-loop diff --git a/test/windows/e6-byte-identity.test.ts b/test/windows/e6-byte-identity.test.ts index 7998a40..add34dd 100644 --- a/test/windows/e6-byte-identity.test.ts +++ b/test/windows/e6-byte-identity.test.ts @@ -1,4 +1,4 @@ -// E-6 platform-sensitive subset, part 2 of 2 (TEST-SPEC §18 E-6; CI-01) — +// E-6 platform-sensitive subset, part 3 of 3 (TEST-SPEC §18 E-6; CI-01) — // the representative-fixture byte-identity comparison against the Linux leg. // // The identical fixture the Linux leg ran (helpers/e6.ts: `version`, diff --git a/test/windows/e6-drive-mismatch.test.ts b/test/windows/e6-drive-mismatch.test.ts new file mode 100644 index 0000000..0f88078 --- /dev/null +++ b/test/windows/e6-drive-mismatch.test.ts @@ -0,0 +1,516 @@ +// E-6 platform-sensitive subset, part 2 of 3 (TEST-SPEC §18 E-6; CI-01) — +// the drive-mismatch anchoring arm of T11.6-1, the sole platform-form output +// in the whole surface, stageable on no Linux runner. Run by the +// suite-windows CI job (`npm run test:windows`); the path/identity +// assertions and casing probes live in e6-subset.test.ts, the byte-identity +// comparison in e6-byte-identity.test.ts. +// +// SPEC 11.6: the inventory's anchoring (`root`, `config`) is the canonical +// relative spelling — ascent `..` segments then descent segments, joined +// with `/` on every platform — except when the platform admits no relative +// path between the working directory and the workspace root (roots on +// different Windows drives): then, and only then, it is reported in the +// platform's absolute form, drive-qualified in the platform's own spelling — +// the sole absolute-path case and the sole output spelling whose separator +// is the platform's, still a pure function of invocation input, +// deterministic per invocation (SPEC 12.0). The registered T11.6-1 body +// (test/suite/registry/section-11.6.ts) pins every relative arm plus the +// Linux side of this one (an unrelated directory tree still yields the pure +// relative form: on Linux no absolute form ever appears); this arm stages +// the mismatch itself, which needs only a substituted drive mapping +// (`subst`, E-6) — per-logon-session state, no elevation, no second volume. +// +// Staging: the working directory is the root of a freshly substituted drive +// letter mapping a scratch directory, while the workspace root stays on the +// real temporary volume; `--config` names the configuration file absolutely +// (a relative spelling cannot cross drives). The registered body already +// proves an absolute `--config` from a same-drive working directory still +// yields the relative anchoring, so the absolute output here is +// attributable to the drive mismatch alone — never an echo of the +// argument's spelling (SPEC 11.6, 12.0). A relative answer computed by +// resolving the substituted mapping to its target would not even resolve +// correctly against the actual working directory, which is exactly why +// TEST-SPEC pins that a substituted mapping suffices to stage the mismatch. +// +// Drive letters are machine-global, per-logon-session state: the claim +// tries free letters until `subst` accepts one, so concurrent harness +// instances race safely (H-1, E-3) — each claims its own letter and deletes +// exactly the mapping it created. If the harness process is killed before +// the release, the mapping leaks until logoff (`subst <L>: /D` cleans it +// up by hand); CI runners are fresh per job. +// +// Failure taxonomy (H-8/H-9 — never a skip, never a vacuous pass), +// mirroring e6-byte-identity.test.ts: +// - stub or nonconforming product → the same-drive premise arm fails first, +// as a diagnosed assertion failure, on any platform this project is run +// on locally (the expected pre-product red on this leg); +// - premise passed, platform not Windows → loud error: the product answers +// `inventory`, but a substituted drive mapping exists only on Windows — +// the arm runs on the Windows leg (E-6), and passing here would be +// vacuous; +// - premise passed, Windows, mapping staged, values differ → diagnosed +// assertion failure — the platform-form divergence this arm exists to +// catch. + +import { execFile } from "node:child_process"; +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import { promisify } from "node:util"; +import { test } from "vitest"; +import type { PathValue } from "../helpers/adapters/index.js"; +import { + decodeInventoryAnchoring, + decodeInventoryFindings, + renderPathValue, +} from "../helpers/adapters/index.js"; +import { + assertBytesEqual, + assertExitCode, + fail, + parseJsonStdout, +} from "../helpers/assertions.js"; +import { DEFAULT_PRODUCT_TEST_TIMEOUT_MS } from "../helpers/registry.js"; +import { builtProductBinding, runProduct } from "../helpers/subprocess.js"; +import type { ProductBinding, RunResult } from "../helpers/subprocess.js"; +import { TestWorkspace } from "../helpers/workspace.js"; + +const execFileAsync = promisify(execFile); + +/** + * Native realpath (GetFinalPathNameByHandle semantics on Windows): resolves + * 8.3 short-name components (a GitHub runner's TEMP contains one) and + * substituted mappings, where the JS `fs.realpath` resolves symlinks only. + * The expectation and the `--config` argument are both spelled from this + * canonical form, so a product that canonicalizes natively and one that + * resolves the argument as-is agree on the same bytes. + */ +function realpathNative(p: string): Promise<string> { + return new Promise((resolve, reject) => { + fs.realpath.native(p, (error, resolved) => { + if (error) reject(error); + else resolve(resolved); + }); + }); +} + +// --- fixture ------------------------------------------------------------------ +// +// A minimal valid workspace (the registered T11.6-1 body's staging): the +// inventory parses no sources (SPEC 11.6), so the anchoring depends on none +// of this — the staging keeps the workspace valid so every answer is the +// complete, finding-free, exit-0 case. + +const ANCHOR_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + } +}) +`; + +const ANCHOR_SOURCE = '<S id="racine">\nAncrage — contenu stable.\n</S>\n'; + +const CONFIG_FILE = "xspec.config.ts"; + +// --- the platform-absolute spelling (harness-side) ---------------------------- + +/** + * Whether `spelling` is a well-formed expected value for the drive-mismatch + * anchoring: the platform's absolute, drive-qualified form — `<L>:\` then + * backslash-joined segments, no `/`, no trailing separator, no `\\?\` + * namespace prefix — on a drive other than the working directory's (SPEC + * 11.6, 12.0). Pure string arithmetic (path.win32 works on every platform), + * so the fixed-vector self-check below runs even where the arm itself + * cannot stage. + */ +function isPlatformAbsoluteMismatchSpelling( + spelling: string, + cwdDriveLetter: string, +): boolean { + return ( + path.win32.isAbsolute(spelling) && + /^[A-Za-z]:\\/.test(spelling) && + !spelling.includes("/") && + !spelling.endsWith("\\") && + spelling.slice(0, 1).toUpperCase() !== cwdDriveLetter.toUpperCase() + ); +} + +/** + * Fixture self-check (harness-side, before any product invocation, on every + * platform): the expected-spelling validator must accept the platform's + * absolute drive-qualified form and reject every near-miss — forward + * slashes, drive-less or relative forms, a trailing separator, the `\\?\` + * namespace prefix, and the working directory's own drive (no mismatch) — + * and the config spelling must compose by platform join. A failure here is + * a harness-arithmetic defect, never a product failure. + */ +function selfCheckPlatformSpellingRule(): void { + const vectors: readonly [string, string, boolean][] = [ + ["C:\\t\\lieu\\work", "Z", true], + ["D:\\a\\_temp\\xh-1\\work\\xspec.config.ts", "Z", true], + ["C:/t/lieu/work", "Z", false], + ["\\t\\lieu\\work", "Z", false], + ["..\\lieu\\work", "Z", false], + ["C:\\t\\lieu\\work\\", "Z", false], + ["\\\\?\\C:\\t\\lieu\\work", "Z", false], + ["Z:\\t\\lieu\\work", "Z", false], + ["c:\\t\\lieu\\work", "C", false], + ]; + for (const [spelling, cwdLetter, expected] of vectors) { + if (isPlatformAbsoluteMismatchSpelling(spelling, cwdLetter) !== expected) { + fail( + `E-6 drive-mismatch fixture self-check — the platform-absolute ` + + `spelling validator judges ${JSON.stringify(spelling)} against ` + + `working-directory drive ${cwdLetter}: as ` + + `${String(!expected)}, expected ${String(expected)} (a ` + + `harness-arithmetic defect, not a product failure)`, + ); + } + } + const joined = path.win32.join("C:\\t\\work", CONFIG_FILE); + if (joined !== `C:\\t\\work\\${CONFIG_FILE}`) { + fail( + `E-6 drive-mismatch fixture self-check — platform join composed ` + + `${JSON.stringify(joined)}, expected ` + + `${JSON.stringify(`C:\\t\\work\\${CONFIG_FILE}`)} (a ` + + `harness-arithmetic defect, not a product failure)`, + ); + } +} + +/** Self-check one computed expectation (see selfCheckPlatformSpellingRule). */ +function selfCheckComputedPlatformSpelling( + spelling: string, + cwdDriveLetter: string, + what: string, +): void { + if (isPlatformAbsoluteMismatchSpelling(spelling, cwdDriveLetter)) return; + fail( + `E-6 drive-mismatch fixture self-check — ${what}: the computed ` + + `expected spelling ${JSON.stringify(spelling)} is not the platform's ` + + `absolute, drive-qualified form on a drive other than the working ` + + `directory's ${cwdDriveLetter}: (a harness staging or arithmetic ` + + `defect, not a product failure)`, + ); +} + +/** The drive letter of an absolute drive-qualified path, or a loud error. */ +function driveLetterOf(absPath: string, what: string): string { + const letter = /^([A-Za-z]):[\\/]/.exec(absPath)?.[1]; + if (letter === undefined) { + throw new Error( + `E-6 drive-mismatch staging: ${what} (${JSON.stringify(absPath)}) ` + + `carries no drive letter — the arm stages a working directory and ` + + `a workspace root on different drive letters (SPEC 11.6, TEST-SPEC ` + + `E-6), so the workspace root must live on a drive-lettered path (a ` + + `UNC or namespace-prefixed temporary root cannot stage this arm). ` + + `A staging environment problem, not a product failure.`, + ); + } + return letter; +} + +// --- substituted drive mapping ------------------------------------------------ + +const SUBST_TIMEOUT_MS = 15_000; + +/** + * Letters tried for the mapping, most-obscure first; A/B (floppies), C/D + * (system and runner work volumes) are never tried. `subst` refuses a + * letter that is in use, so claiming is try-until-accepted: safe under + * concurrent harness instances (H-1, E-3), which simply claim different + * letters. + */ +const CANDIDATE_DRIVE_LETTERS = "ZYXWVUTSRQPONMLKJIHGFE"; + +interface SubstDrive { + /** The claimed letter, e.g. "Z". */ + readonly letter: string; + /** The mapped drive's root directory, e.g. "Z:\\" — the arm's cwd. */ + readonly root: string; + /** Delete exactly the mapping this claim created (`subst <L>: /D`). */ + release(): Promise<void>; +} + +function describeExecFailure(error: unknown): string { + const failure = error as { + code?: number | string; + killed?: boolean; + stdout?: string; + stderr?: string; + message?: string; + }; + if (failure.killed === true) return "killed (timeout)"; + const output = [failure.stdout, failure.stderr] + .filter((s): s is string => typeof s === "string" && s.trim() !== "") + .join(" / ") + .replaceAll(/\s+/g, " ") + .trim(); + const detail = output === "" ? (failure.message ?? "") : output; + return `exit ${String(failure.code ?? "unknown")}${ + detail === "" ? "" : `: ${detail.slice(0, 200)}` + }`; +} + +/** + * Map a free drive letter onto `targetDir` via `subst` and verify the + * mapping answers. Failures here are staging environment problems (plain + * errors), never product failures: the product is not involved. + */ +async function claimSubstDrive(targetDir: string): Promise<SubstDrive> { + const attempts: string[] = []; + for (const letter of CANDIDATE_DRIVE_LETTERS) { + const drive = `${letter}:`; + try { + await execFileAsync("subst", [drive, targetDir], { + timeout: SUBST_TIMEOUT_MS, + windowsHide: true, + }); + } catch (error) { + // In use (or otherwise refused) — try the next letter. + attempts.push(`${drive} (${describeExecFailure(error)})`); + continue; + } + const stats = await fsp.stat(`${drive}\\`).catch(() => undefined); + if (stats === undefined || !stats.isDirectory()) { + await execFileAsync("subst", [drive, "/D"], { + timeout: SUBST_TIMEOUT_MS, + windowsHide: true, + }).catch(() => undefined); + throw new Error( + `E-6 drive-mismatch staging: \`subst ${drive} ${targetDir}\` ` + + `reported success but ${drive}\\ does not answer as a directory. ` + + `A staging environment problem, not a product failure.`, + ); + } + let released = false; + return { + letter, + root: `${drive}\\`, + release: async () => { + if (released) return; + released = true; + try { + await execFileAsync("subst", [drive, "/D"], { + timeout: SUBST_TIMEOUT_MS, + windowsHide: true, + }); + } catch (error) { + throw new Error( + `E-6 drive-mismatch staging: failed to delete the substituted ` + + `mapping ${drive} (${describeExecFailure(error)}). The ` + + `mapping leaks until logoff — clean it up with ` + + `\`subst ${drive} /D\`.`, + ); + } + }, + }; + } + throw new Error( + `E-6 drive-mismatch staging: no candidate drive letter accepted a ` + + `substituted mapping — tried ${attempts.join("; ")}. A staging ` + + `environment problem (every letter in use, or subst unavailable), ` + + `not a product failure.`, + ); +} + +// --- shared assertion --------------------------------------------------------- + +function assertAnchoringMember( + actual: PathValue, + expected: string, + member: string, + form: string, + context: string, +): void { + if (actual === expected) return; + fail( + `${context}: the inventory's ${member} anchoring must be exactly ` + + `${JSON.stringify(expected)} — ${form}; got ${renderPathValue(actual)}`, + ); +} + +/** + * Run `inventory` from `cwd` and assert the T11.6-1 contract (the registered + * body's frame): exit 0 exactly (a complete, finding-free answer, SPEC + * 12.0/11.6; H-5); exactly one JSON document as the entire stdout (JSON-only, + * SPEC 11); `findings` decoding to [] (form-exact, 12.7); and the + * `root`/`config` anchoring byte-exact against `expected`, with `form` + * naming the spelling rule the expectation realizes. + */ +async function expectAnchoredInventory( + product: ProductBinding, + cwd: string, + argv: readonly string[], + expected: { readonly root: string; readonly config: string }, + form: string, + context: string, +): Promise<RunResult> { + const result = await runProduct(product, { cwd, argv }); + assertExitCode( + result, + 0, + `${context} — a complete, finding-free inventory answer exits 0 ` + + `(SPEC 12.0, 11.6)`, + ); + const doc = parseJsonStdout( + result, + `${context} — inventory is JSON-only: a single JSON document is its ` + + `only output form, with or without --json (SPEC 11, 12.0)`, + ); + const findings = decodeInventoryFindings(doc, context); + if (findings.length !== 0) { + fail( + `${context}: the staged workspace is valid and the inventory parses ` + + `no sources, so the answer is finding-free — findings [] (SPEC ` + + `11.6, 12.7); got ${String(findings.length)} finding(s), first: ` + + `${JSON.stringify(findings[0]?.message)}`, + ); + } + const anchoring = decodeInventoryAnchoring(doc, context); + assertAnchoringMember(anchoring.root, expected.root, "`root`", form, context); + assertAnchoringMember( + anchoring.config, + expected.config, + "`config`", + form, + context, + ); + return result; +} + +// --- the arm ------------------------------------------------------------------ + +test( + "T11.6-1 drive-mismatch arm (Windows leg, E-6): with the working directory on a substituted drive and the workspace root on another drive letter, `inventory` reports the anchoring in the platform's absolute, drive-qualified spelling — the sole absolute-path case and sole platform-separator output — byte-exact, deterministic per invocation, the answer complete and finding-free at exit 0; same-drive premise first: from the workspace root the anchoring stays the relative `.`/`xspec.config.ts` (SPEC 11.6, 12.0, 11; TEST-SPEC E-6)", + { timeout: DEFAULT_PRODUCT_TEST_TIMEOUT_MS }, + async () => { + selfCheckPlatformSpellingRule(); + const product = builtProductBinding(); + const workspace = await TestWorkspace.create({ + files: { + [CONFIG_FILE]: ANCHOR_CONFIG, + "specs/a.mdx": ANCHOR_SOURCE, + }, + }); + try { + // --- same-drive premise arm, any platform: the workspace stages and + // the product answers `inventory` with the canonical relative + // anchoring (the registered T11.6-1 body's first arm). Against a stub + // or nonconforming product this fails first, diagnosed, before any + // platform-only staging is attempted — so the platform gate below can + // only mean "the product works, the platform cannot stage the arm" — + // and on the Windows leg it is the arm's discriminating contrast: the + // same workspace anchors relatively until the drives differ. + await expectAnchoredInventory( + product, + workspace.root, + ["inventory"], + { root: ".", config: CONFIG_FILE }, + "the canonical relative spelling from the invocation working " + + "directory — the working directory itself spelled `.`, the " + + "configuration file the pure descent (SPEC 11.6): a drive " + + "mismatch is the sole case that ever departs from it", + "T11.6-1 (E-6 drive-mismatch premise) — `inventory` from the " + + "workspace root: the same-drive anchoring is the relative form " + + "(SPEC 11.6)", + ); + + // --- platform gate: the mismatch stages only on Windows (H-9 — a + // loud error, never a skip, never a vacuous pass). + if (process.platform !== "win32") { + throw new Error( + `E-6 drive-mismatch arm: the product answers \`inventory\` (the ` + + `premise arm passed), but the drive-mismatch staging — a ` + + `substituted drive mapping (\`subst\`) — exists only on ` + + `Windows; this arm runs on the Windows CI leg (TEST-SPEC E-6). ` + + `Failing loudly rather than passing vacuously (H-9); every ` + + `platform-portable Windows-subset assertion lives in ` + + `e6-subset.test.ts and e6-byte-identity.test.ts.`, + ); + } + + // --- stage the mismatch: cwd on a substituted drive letter, the + // workspace root untouched on the real volume. The expectation is the + // canonical native spelling of the root (realpath.native: long-name, + // drive-qualified, backslash-separated), the `--config` argument the + // same spelling of the configuration file — so the identified file + // and the expected output are one canonical form, whatever + // canonicalization the product applies (pure invocation input, never + // an argument echo, SPEC 11.6, 12.0). + const physicalRoot = await realpathNative(workspace.root); + const rootDrive = driveLetterOf(physicalRoot, "the workspace root"); + const expectedRoot = physicalRoot; + const expectedConfig = path.win32.join(physicalRoot, CONFIG_FILE); + const mountDir = path.join(workspace.tempRoot, "lecteur"); + await fsp.mkdir(mountDir); + const drive = await claimSubstDrive(mountDir); + try { + if (drive.letter.toUpperCase() === rootDrive.toUpperCase()) { + throw new Error( + `E-6 drive-mismatch staging: the claimed substituted letter ` + + `${drive.letter}: equals the workspace root's drive — no ` + + `mismatch staged (\`subst\` should refuse an in-use ` + + `letter). A staging defect, not a product failure.`, + ); + } + selfCheckComputedPlatformSpelling( + expectedRoot, + drive.letter, + "the expected `root`", + ); + selfCheckComputedPlatformSpelling( + expectedConfig, + drive.letter, + "the expected `config`", + ); + + const argv = ["inventory", "--config", expectedConfig]; + const form = + `the platform's absolute, drive-qualified spelling: the working ` + + `directory ${drive.root} is a substituted drive mapping and the ` + + `workspace root sits on drive ${rootDrive}:, so the platform ` + + `admits no relative path between them — the sole absolute-path ` + + `case and the sole output spelling whose separator is the ` + + `platform's (SPEC 11.6, 12.0; TEST-SPEC E-6)`; + const context = + `T11.6-1 (E-6 drive-mismatch arm) — \`inventory --config\` from ` + + `${drive.root}, the root of a substituted drive, with the ` + + `workspace root on drive ${rootDrive}:`; + const first = await expectAnchoredInventory( + product, + drive.root, + argv, + { root: expectedRoot, config: expectedConfig }, + form, + context, + ); + const second = await expectAnchoredInventory( + product, + drive.root, + argv, + { root: expectedRoot, config: expectedConfig }, + form, + `${context} — repeated invocation`, + ); + assertBytesEqual( + second.stdoutBytes, + first.stdoutBytes, + "T11.6-1 (E-6 drive-mismatch arm) — the platform-absolute " + + "anchoring is invocation-anchored content: a pure function of " + + "invocation input, deterministic per invocation, so repeating " + + "the identical invocation from the identical working directory " + + "yields byte-identical stdout (SPEC 12.0, 11.6; a " + + "product-to-itself comparison, H-4)", + ); + } finally { + await drive.release(); + } + } finally { + await workspace.dispose(); + } + }, +); diff --git a/test/windows/e6-subset.test.ts b/test/windows/e6-subset.test.ts index 90c2106..6b8dee5 100644 --- a/test/windows/e6-subset.test.ts +++ b/test/windows/e6-subset.test.ts @@ -1,7 +1,8 @@ -// E-6 platform-sensitive subset, part 1 of 2 (TEST-SPEC §18 E-6; CI-01) — +// E-6 platform-sensitive subset, part 1 of 3 (TEST-SPEC §18 E-6; CI-01) — // the path/identity assertions and the single-casing case-mismatch probes. -// Run by the suite-windows CI job (`npm run test:windows`); the byte-identity -// comparison lives in e6-byte-identity.test.ts. +// Run by the suite-windows CI job (`npm run test:windows`); the T11.6-1 +// drive-mismatch anchoring arm lives in e6-drive-mismatch.test.ts and the +// byte-identity comparison in e6-byte-identity.test.ts. // // One code path with the suite leg (C-2): // From dacaf3f4725ca6561b465810be26cebf3d9d2ebd Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 18:57:36 +0000 Subject: [PATCH 139/226] sdg(phase-9): restage T11.5-1's fixture as real MDX blocks (FP-094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specs/total.mdx separated its head prose, both import declarations, the comments, and every section tag by single newlines — under MDX block grammar one paragraph, so the imports were prose, not declarations (proven against the built product: a typo specifier in the old layout drew no 14.15). Blank-line-separate every block construct: the imports are now mdxjsEsm declarations, both comments flow expression blocks (the deep one kept inside a.b.c), every offset recomposed by the running byte-offset builder and the construct-slice constants. Verified: typecheck + format:check clean; the restaged layout probed against the built product — `build --json` finding-free, the typo-specifier control now reports exactly one 14.15 locating the import declaration, `ids --json` yields the identical a/a.b/a.b.c/z tree; suite run — fixture self-checks pass and T11.5-1 fails red-as-diagnosed at its first product invocation (unknown command `at`, exit 2 vs expected 0), T11.5-2/3 red at their gates on the product's old finding shape as before; `npm run test:self` fully green (330 passed). FP-094 was the final FIX_PLAN task, so the plan file is deleted (PROCESS.md Ralph Loop note 3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 4187 --------------------------- test/suite/registry/section-11.5.ts | 47 +- 2 files changed, 31 insertions(+), 4203 deletions(-) delete mode 100644 specs/tmp/FIX_PLAN.md diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md deleted file mode 100644 index 806407c..0000000 --- a/specs/tmp/FIX_PLAN.md +++ /dev/null @@ -1,4187 +0,0 @@ -# FIX_PLAN — Phase 9 (test harness vs TEST-SPEC.md + CERTIFICATIONS.md) - -Planned 2026-08-10 from three compliance reviews (R1: TEST-SPEC §§1–9; R2: -TEST-SPEC §§10–18 + cross-cutting; R3: CERTIFICATIONS.md) and the red VERIFY -run at sha 8294929 (`npm run test:self`: 5 failed / 222 passed — -certification-document ×3, s1-traceability ×2). Citations `[R1 #n]`, -`[R2 #n]`, `[R3 gap n]`, `[VERIFY]` name the finding a task satisfies; -TEST-SPEC.md / CERTIFICATIONS.md / SPEC.md remain the sole authority for the -full requirement text — task summaries locate the gap, they do not replace -the spec. - -## How to work this plan - -- Phase 9 scope guard: **never modify product code** (`src/`). Everything - here is harness work under `test/` (fixtures included) plus this file. -- Take tasks top to bottom unless a task's "after:" note says otherwise. - Mark a completed task `[x]` in the same commit that completes it. Never - delete tasks; append new ones at the end if new findings arrive. -- Keep each spawn small and complete: one task (or one task plus its - explicitly paired partner) per spawn is the intended grain. -- Verify with `npm run build && npm run test:self` (the phase gate) and, for - suite subsets, `npx vitest run --config test/vitest.config.ts --project - suite test/suite/<file>.test.ts` (see AGENTS.md). Product-facing tests are - expected to FAIL against the stub product (red-as-diagnosed, H-8) — a new - or changed product-facing test is verified by (a) failing as diagnosed - against the stub, (b) certifying green where a CERTIFICATIONS.md fixture - has it in scope, (c) self-tests (S-1..S-7, C-1) staying/going green per the - task's "verify" note. -- Expected mid-loop redness: `test:self` stays partially red until the whole - plan lands (S-1 unmapped-key list shrinks as new tests register; the - certification-document gate stays red until FP-091). Each task's verify - note says which failures it must clear and which may remain. - -## Conventions for every new-test task (stages E, G, H) - -A "new test T<x>" task always means, in one change: - -1. Implement the test body per its TEST-SPEC entry (title, arms, budget) in - the section's registry module `test/suite/registry/section-<x>.ts` - (create the module if the section is new, export a - `readonly ProductTestEntry[]`, and spread it into - `test/suite/registry/index.ts`). -2. Declare it in the matching `test/suite/section-<x>.test.ts` via - `declareProductTests` (create the thin wrapper file for new sections). -3. Add its `H7_TRACEABILITY` entry in - `test/suite/registry/traceability.ts` (home passage per H-7, plus "14" - when it asserts a numbered condition, plus any cross-section passages its - TEST-SPEC entry states). -4. Decode outputs through the H-3 adapter layer — except findings arrays, - findings-only reports, and other 12.7 form-exact surfaces, which must be - decoded literally (FP-001's layer). -5. Confirm red-as-diagnosed against the stub product, and note any - CERTIFICATIONS.md scope (the six CONF-AVAIL in-scope tests are called out - in their tasks). - ---- - -## Stage A — foundations (do these first; many tasks depend on them) - -- [x] FP-001 — Rebuild the findings decode layer as a literal, form-exact - SPEC 12.7 decode; add the three-state datum decode; add the S-5 guards. - [R1 #28, R2 #18, R2 #41; SPEC 12.7, TEST-SPEC §0 H-3 (amended), §17 S-5] - - `test/helpers/adapters/reports.ts` (`decodeFinding`, ASSUMED-SHAPE - comment at top): the current assumed finding shape - `{"condition":"14.N","message","file"?,"location"?,...}` is wrong. SPEC - 12.7 fixes findings as `{"code","message","locations","path", - "identities"}` with stable token codes, and amended H-3 makes findings - arrays and findings-only reports form-exact surfaces where no adapter - may re-map member names or shapes. Rebuild the findings assertion layer - as a literal 12.7 decode: exact member names, `null`-vs-omission rules, - `[]`-vs-`null` rules, the pinned findings-order comparator, duplicate - collapse. - - `test/helpers/adapters/model.ts` (`Finding` interface, ~line 183): - findings must carry the stable token `code`; keep a `14.N` condition - identity only as a value derived through a harness-pinned SPEC §14 - token→condition table so existing condition-identity assertions can be - expressed against tokens. - - Add a three-state datum decode helper (`plain` value / `null` / - `{"unavailable": true}`) under `test/helpers/` — nothing decodes that - triple today; T12.7-1 (FP-075) and the §11 tests need it. - - `test/self/s5-output-adapters.test.ts`: add guards for the literal 12.7 - findings decode and for the structural-unavailability walk T12.7-1 - relies on. - - Update every compile-affected call site in the same change (suite - registry modules import the decoder widely). - - Verify: `npm run test:self` — S-5 green; certification must not regress - on finding shape: if a conformer fixture emits old-shape findings on an - in-scope path, apply the minimal 12.7 form change to that fixture here - (CONF-VALID's full behavioral rework stays FP-009). - -- [x] FP-002 — Fix the exit-2 output-stream protocol everywhere it is - asserted: with JSON output in effect, exit-2 stdout is exactly one 12.7 - error document (`{"error": …}`); stdout is byte-empty on exit 2 only when - JSON is NOT in effect; stderr is byte-identical across output forms. - [R1 #26, R2 #17; SPEC 12.0/12.7, TEST-SPEC §0 H-5, T12.0-2] - - `test/helpers/assertions.ts` `assertJsonOutputConvention` (~line 175) - currently enforces "exit 2 → stdout byte-empty under `--json`" — invert - to the error-document contract. - - Call sites to sweep: `test/suite/registry/section-16-p8.ts` (P-8), - `section-12.0-i.ts` (T12.0-2 — also ADD its missing stderr-invariance - arm: stderr byte-identical across output forms), `section-6.3.ts` ~line - 233 (T6.3-4), `section-6.4.ts` ~line 348 (T6.4-4), `section-6.5.ts` - ~line 409 (T6.5-5), `test/suite/registry/support.ts` - `expectConfigurationError` ~line 95 (asserts `assertStdoutEmpty` on exit - 2 — must instead assert the single 12.7 error document; users T7-1, - T7-2, T7-3, T7-4, T7.2-1, T7.3-1, T7.4-1, T7.5-1), `section-7.4-7.5.ts` - ~line 590 (T7.4-1 unknown-profile arm), `section-7-basics.ts` ~line 733 - (T7-3 unknown `--from` arm). - - Verify: affected suite files compile and fail only as red-as-diagnosed - product assertions; `npm run test:self` no worse. - -- [x] FP-003 — Re-pin the H-7/S-1 traceability universe and apply the - renumber/remap fixes that need no new tests. - [VERIFY s1-traceability, R2 #39, R1 #12; TEST-SPEC §0 H-7, §17 S-1] - - `test/self/s1-traceability.test.ts`: `EXPECTED_KEY_COUNT` 71 → 81 (line - 44) and the "preamble + 60 subsections" comment (line 39) → 70 - subsections (SPEC.md now adds subsections 5.7, 6.7, 11.1–11.6, 12.6, - 12.7). `EXPECTED_SECTION_COUNT` stays 15. - - `test/suite/registry/section-6.6.ts` (`defineProductTest` at ~line 363): - the manual-restructuring test exists under the retired ID `T6.6-1`; - re-register it as `T6.7-1` (SPEC/TEST-SPEC renumber: manual - restructuring moved 6.6 → 6.7; new §6.6 is the preview command). Update - its title's SPEC citations accordingly and the traceability entry - `"T6.6-1": ["6.6"]` (traceability.ts ~line 194) → `"T6.7-1": ["6.7"]`. - - `test/suite/registry/traceability.ts`: remap T11-1..T11-7 from key - `"11"` to `"11.1"` while keeping SPEC §11's body key `"11"` covered - (assign §11-body coverage to the tests that assert it — §11 now has - subsections, so fix the header comment claiming 11 has none, and review - `SPEC_BODY_TEXT_KEY_SECTIONS` against amended H-7). - - Note in the map comment that T12.0-10 stops being alias-only once - FP-039 lands (its own arms make it an implemented test). - - Verify: S-1's universe-count assertion goes green; its unmapped-key - failure narrows to exactly {5.7, 6.6, 11.2, 11.3, 11.4, 11.5, 11.6, - 12.6, 12.7} (6.7 and 11.1 become mapped here; 6.6 — Previews — joins - the red set as planned-task fallout the original note missed: the - retired T6.6-1's false "6.6" coverage is removed, and "6.6" stays - unmapped until FP-028+ register T6.6-2..-6) and stays red until stages - E/G land — state left red on purpose. [Done 2026-08-10: exactly that - 9-key set observed; T6.7-1 red-as-diagnosed under its new ID; registry - module renamed section-6.6.ts → section-6.7.ts (wrapper too) so FP-028 - creates a fresh section-6.6.ts for the preview tests; - SPEC_BODY_TEXT_KEY_SECTIONS reviewed against amended H-7 — unchanged.] - -## Stage B — existing assertions that contradict the current spec - -- [x] FP-004 — Invert T10.7-12's code-impact-scope range assertion. - [R2 #19; SPEC 10.7, 1.7] - `test/suite/registry/section-10.7-ii.ts` ~line 638 asserts a present - `code-impact` scope "must carry no source range". Current SPEC 10.7/1.7: - a PRESENT code-location scope carries its source range; only a DELETED - location's entry carries none. Invert the assertion and stage both sides - (present location → range asserted byte-precisely; deleted location → - no range). Verify: red-as-diagnosed against stub; no self-test change. - [Done 2026-08-10: `assertPresentState` now requires the range (every - present node carries one, SPEC 10.7); the code-impact scope became the - named unit `src/ref.ts#refUnit` per TEST-SPEC's refreshed entry, its - construct range byte-asserted against precomputed offsets behind a - multi-byte prefix; a second location `src/del.ts` (added v1, deleted v2) - stages the deleted side — absent, no text, no range. T10.7-12 turned from - falsely-green to red-as-diagnosed exactly at the range assertion (stub - omits it); the reworked matrix including the deleted arm verified sound - against the stub via a local probe. Traceability gains "1.7" per - TEST-SPEC's stated delegation (T1.7-1/T1.7-2 → T10.7-12). Self-tests - unchanged: 4 planned mid-loop reds (certification-document ×3 → FP-091; - S-1's 9 unmapped keys → stages E/G).] - -- [x] FP-005 — Re-stage the two `move` destination spellings that changed - exit class from refusal (exit 1) to usage error (exit 2). - [R1 #25, non-UTF-8 half of R1 #22; TEST-SPEC T6.5-4 dead-letter note, - T6.5-5] - `test/suite/registry/section-6.5.ts`: the `#`-containing destination - (`specs/Ha#sh.mdx`, ~line 1476) and the non-UTF-8 destination (~line - 1499) are currently asserted as exit-1 refusals under T6.5-4; TEST-SPEC - now classifies both as exit-2 usage errors under T6.5-5. Move the arms to - T6.5-5 and assert exit 2 with the FP-002 protocol (single 12.7 error - document under `--json`), workspace unmodified. Verify: red-as-diagnosed; - T6.5-4 retains no exit-class contradiction. - [Done 2026-08-10: both arms now live in T6.5-5's valid-workspace block — - exit 2 via `expectMoveUsageError` (FP-002 protocol: single 12.7 error - document under `--json`, stderr message present), each wrapped in a - whole-root `assertLeavesUnchanged` compare; the helper widened to - raw-byte argv (`ArgvValue`) for the Linux-leg non-UTF-8 operand while - `expectRefusalModifiesNothing` narrowed back to strings; T6.5-4's - title/cases now carry the dead-letter note instead of the arms; the S-3 - driver self-test's staging citation updated T6.5-4 → T6.5-5. Verified: - T6.5-4 green against the current product (contradiction gone); T6.5-5 - red-as-diagnosed — direct probe shows the product still exits 1 with a - refusal document on both spellings (classifies the operand as a path) - where the arms demand exit 2; the suite test currently fails earlier at - its first arm (exit-2 stdout empty under `--json`, the FP-002-class - product gap). `npm run test:self`: unchanged 4 planned mid-loop reds - (certification-document ×3 → FP-091; S-1 unmapped keys → stages E/G).] - -## Stage C — §§1–9 cross-cutting assertion sweeps (after FP-001) - -- [x] FP-006 — Sweep §§1–9 condition assertions to assert stable code - tokens and the literal 12.7 finding form. [R1 #27, #28; TEST-SPEC §0 - ("where §14 assigns the condition or refusal reason a stable code, assert - that exact code string"), SPEC 12.7, 14] - No §§1–9 test asserts the SPEC 14 token strings today — the model carries - only `condition: "14.N"`. Using FP-001's layer, make every §§1–9 test that - asserts a numbered condition assert the exact token code string and - decode the finding's 12.7 members literally (`code`, `message`, - `locations`, `path`, `identities`). Sweep the registry modules for - sections 1–5, 7, 8, 9 (section 6 refusals are FP-007). Verify: affected - files red-as-diagnosed only; S-5/S-1 unaffected. - [Done 2026-08-10 — already satisfied by FP-001's landing; no code change. - The sweep found every §§1–9 numbered-condition assertion site (1.3: - 14.1–14.4; 1.4: 14.4; 1.5: 14.19; 1.6-1.7: 14.20; 2.1: 14.15/14.9; 2.4: - 14.5–14.8; 2.5-2.6: 14.17/14.12; 2.7: 14.16/14.17/14.8; 4: 14.15; - 4.3-4.4: 14.8/14.11; 4.5: 14.8/14.18; 5.1-5.3: 14.9; 7-*: 14.14/14.12/ - 14.15/14.19; 8: 14.12; sections 1.1-1.2, 2.2-2.3, 3, 4.1-4.2, 4.6, - 5.4-5.6, 9, 9.3 assert none) routing through the shared helpers FP-001 - rebuilt — `buildFindings`/`assertConditionCounts`/`assertFindingLocated`/ - `finding.condition`/`expectConfigurationError` — all on the form-exact - 12.7 decode (`decodeFindingForm`: exact five members, path forms, orders, - collapse). The `14.N` identity exists only as the pinned-table image of - the decode-validated token (FP-001's stated design: condition-identity - assertions are expressed against tokens; unknown or missing codes fail - the decode loudly), so each `"14.N"` assertion holds exactly for the one - SPEC 14 code string; `expectConfigurationError` asserts - `"configuration-error"` directly. No §§1–9 site asserts a condition via - stderr text, human-report mentions (`conditionMention` is S-5-only), or - ad-hoc JSON access. Verified: typecheck clean; `npm run test:self` - unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; - S-1 unmapped keys → stages E/G) with S-5 and certification green - (CONF-VALID, emitting 12.7-form findings with stable tokens, passes its - in-scope T1.3-*/T1.4-* tests; violators fail as certified); probe run of - FP-001-untouched section-2.4 red-as-diagnosed at the form-exact decode - ("expected no member \"condition\"" against the pre-12.7 product), no - crashes.] - -- [x] FP-007 — Make §6 refusal assertions assert stable refusal codes with - their concerned file/range/identity. [R1 #27; TEST-SPEC §§6.4–6.5, SPEC - 12.7, 14] - `expectRefusalModifiesNothing` (defined in - `test/suite/registry/section-6.4.ts` ~line 303 and used across - section-6.4.ts / section-6.5.ts) asserts only exit 1 plus - workspace-unchanged. Extend it (or its call sites) so each refusal arm - asserts the exact stable refusal code and the concerned file/range/ - identity of the refusal finding, most acutely T6.4-3, T6.5-4, T6.5-6. - Verify: red-as-diagnosed only. - [Done 2026-08-10: both modules' `expectRefusalModifiesNothing` now take a - per-arm `RefusalExpectation` — run with `--json`, exit 1, stdout decoded - as the form-exact 12.7 findings-only report, exactly one finding under the - arm's exact stable code (each arm isolates one cause; one finding per - applicable reason, SPEC 14), plus the reason's §14 concern via new - support.ts helpers: `assertFindingNamesIdentity` (full 1.5 identity or - bare ID — §14 requires identification, not spelling; refused-invalid-id, - refused-identity-unchanged, refused-missing-target-parent, - refused-structural-parent), `assertFindingConcernsPath` (12.7 `path` - member equality; refused-destination-exists, refused-invalid-destination), - `assertFindingMentionsLocation` (SOME-quantified; refused-id-collision - locates the remaining bearer — byte windows over the staged `a.sib`/`y` - constructs; refused-cycle's dependency arm locates the participating - `d={"keep"}` spelling; T6.5-6's collision asserts file-only, B.mdx being - product-rewritten). The spec-import-cycle arm pins code+form alone (the - would-be cycle's participating import declarations exist in no - pre-operation source, so no concern window is derivable). Precondition - arms (T6.4-6, T6.5-4) assert the invalid-workspace refusal as exactly the - one located 14.5 finding, no refusal reason beside it. Traceability: "14" - added to T6.4-3/T6.5-4/T6.5-6 per TEST-SPEC 14's refusal-reason staging - record. Verified: those four tests turned falsely-green → - red-as-diagnosed at the form-exact decode (the stub emits `{"refused":…}` - for refusals and old-shape `condition` findings for the gate; suite files - 6.4+6.5 went 2 failed/11 passed → 6 failed/7 passed, the other four being - the pre-existing FP-002-class exit-2 gaps); `npm run test:self` unchanged - 4 planned mid-loop reds (certification-document ×3 → FP-091; S-1 unmapped - keys → stages E/G), S-5 and certification green.] - -## Stage D — §§1–9 missing arms, with paired certification-fixture reworks - -- [x] FP-008 — T1.3-6: add the invalid-`id`-form arms. [R1 #13; TEST-SPEC - §1.3, SPEC 14.17] - `test/suite/registry/section-1.3.ts` has no 14.17 arm. Add: a - repeated-`id` bearer and a braced-`id` bearer (`id={"x"}`), each reporting - 14.17 and never 14.1, masking 14.2 for its immediate children, while - grandchildren's structural checks still report. Pair with FP-009 (the - CONF-VALID conformer must pass this arm once both land — certification - for the family may be red between the two commits; prefer one spawn). - [Done 2026-08-10, one spawn/commit with FP-009: two arms appended to - T1.3-6's body — `<S id="one" id="two">` and `<S id={"x"}>`, each behind a - valid sibling so the bearer's location window has teeth, each holding an - immediate child `a.b` (extends no candidate parent spelling and is - multi-segment against the empty prefix, so a non-masking or - value-adopting product reports an extra 14.2) with grandchild `zzz`. Each - arm asserts exact counts {14.17: 1, 14.2: 1} — hence no 14.1, no 14.20, - no immediate-child 14.2 — the 14.17 located within the bearer, the one - 14.2 within the grandchild. Title updated; traceability already - ["1.3", "14"]. Suite file red-as-diagnosed against the stub (all six - T1.3-* fail at the FP-001-class form-exact decode: the stub still emits - `condition`-member findings, so the new arms' first decode fails the same - way); arm soundness proven through the paired conformer: CONF-VALID - 12/12.] - -- [x] FP-009 — Rework CONF-VALID to its refreshed CERTIFICATIONS.md scope. - [R3 gap 4; CERTIFICATIONS.md CONF-VALID, SPEC 12.7, 14.17] - `test/fixtures/conf-valid/product.mjs` (+ `bin-ctrl.mjs`/`bin-wide.mjs` - deviations unchanged): (a) add the condition-17 path — today it emits - only 14.1–14.4/14.20; (b) repeated `id` must report 14.17 and never 14.1 - (today it silently takes the last value), masking 14.2 for immediate - children; (c) braced `id={"x"}` must report 14.17 (today 14.20); (d) emit - findings in the 12.7 form with stable token codes (today - `{"condition","file","location","message"}` with no token). After: FP-001, - FP-008. Verify: `npm run test:self` — CONF-VALID conformer passes all - in-scope tests, its violators still fail at least one certified test. - [Done 2026-08-10, same commit as FP-008. (d) had already landed with - FP-001 (findingsDoc emits the literal 12.7 form with stable tokens); - this task added the behavioral 14.17 path: the MDX-lite lexer now counts - attribute occurrences per element and scans braced values (`name={...}`, - balanced, string-aware) as well-formed MDX — never 14.20; a repeated - prop name (one finding per name) or an `id`/`tags` value not in - quoted-static form (braced or valueless) yields a 14.17 finding at the - bearing element (`"14.17": "invalid-prop"` in CODE_TOKENS); an afflicted - `id` spells no identity — never 14.1 (only a wholly absent `id` is - 14.1), own segment/structural/duplicate checks skipped, and the existing - parent-spells-no-identity masking covers repeated/braced parents - unchanged since `id` stays null. bin-ctrl/bin-wide untouched. Verified: - `npm run test:self` — CONF-VALID conformer 12/12 in-scope tests pass - (extended T1.3-6 included), VIOL-VALID-CTRL and VIOL-VALID-WIDE each - fail exactly their three certified tests and pass T1.3-6; the 4 planned - mid-loop reds unchanged (certification-document ×3 → FP-091; S-1's 9 - unmapped keys → stages E/G).] - -- [x] FP-010 — T3-1: add the grammar-boundary arm. [R1 #15; TEST-SPEC §3] - `test/suite/registry/section-3.ts` — the T3-1 fixture's fence (~line 92) - contains only plain text. Add fences and an inline code span containing - `<S id="x">`, `<div>`, `import X from "./X.xspec"`, `{text("a")}`, and - assert: no node, no edge, no finding, bytes preserved byte-for-byte. - Pair with FP-011 (same red-window note as FP-008/FP-009). - [Done 2026-08-11, one spawn/commit with FP-011: the ```text fence gained a - `<div>` line; a second ```md fence in gamma carries `<S id="x">`, - `import X from "./X.xspec"`, `{text("a")}`; an inline code span in gamma - carries `<S id="x">{text("a")}`. Arm asserts: `build` and `check` exit 0 - (no finding of any kind — a pattern-parsing product instead hits 14.20/ - 14.16/14.15/14.6); `query nodes` identity set exactly the six staged - requirement nodes via a new scoped identity-only decoder - (`decodeNodeIdentityRowsReport`, adapters/query.ts, S-5-guarded — CONF-MD's - scope pins no tags/coverage/range semantics); `query edges` set exactly - the 4 `contains` + 3 `depends` edges (decodeEdgesReport + - assertEdgeSetEqual); compiled bytes byte-asserted with the fence/span - lines preserved verbatim. NOT red against this repo's product: the - post-phase-10 product already parses fences/spans as literal (T3-1 stays - green, a real pass — probe below proves the arm's teeth); traceability - unchanged (T3-1 → "3"; asserts no numbered condition).] - -- [x] FP-011 — Rework CONF-MD to its refreshed CERTIFICATIONS.md scope. - [R3 gap 3; CERTIFICATIONS.md CONF-MD] - `test/fixtures/conf-md/product.mjs` (+ `bin-class.mjs`/`bin-cr.mjs`): - (a) support `check` with exit 0 on T3-1's grammar-boundary staging (today: - exit 2 "unknown command"); (b) support `query nodes`/`query edges` with - no-node/no-edge reports (today exit 2); (c) give `parseMdx` fence and - inline-code-span lexer state so `<S id="x">` inside a fence is literal - content — no node, no edge, no finding (today a spurious 14.20), bytes - preserved. After: FP-010. Verify: CONF-MD conformer green on in-scope - tests incl. T3-1's new arm; violators still certify. - [Done 2026-08-11, same commit as FP-010. `markdownLiteralRegions` pre-scan - (CommonMark-ish subset: >=3-backtick/tilde fences with up-to-3-space - indent, backtick info strings without backticks, unclosed-to-EOF; inline - spans close at an exactly-equal-length backtick run on the same line — - single-line spans are the staged scope) feeds parseMdx, which skips whole - regions into plain content; the scan uses plain Markdown line structure, - deliberately outside the CERT-13 deviation hook so each violator keeps its - single deviation. `check` = validate + cycle surface, write nothing, exit - 0/1; `query nodes`/`query edges` answer the honest whole reports (roots as - bare paths, contains/depends/embeds, set-collapsed, byte-ordered) gated on - validity per 13.3. No P-2 interference: the generator's prose alphabet - excludes backticks and `~`. bin-class/bin-cr untouched. Verified: - CONF-MD conformer 8/8 in-scope (new arm included); VIOL-MD-CLASS fails - exactly T3-3+P-2, VIOL-MD-CR exactly T3-4+P-2, both passing T3-1; - `npm run test:self` unchanged 4 planned mid-loop reds - (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G); - teeth probe: the pre-rework conformer on the new staging exits 1 with the - diagnosed spurious 14.20 ("unclosed section tag") — the arm fails any - parse-by-pattern product at `buildOk`.] - -- [x] FP-012 — T1.7-1: add the bare-identity edge-endpoint arms. [R1 #14; - TEST-SPEC §1.7] - `test/suite/registry/section-1.6-1.7.ts` asserts only `query node`/`show` - ranges; no code location, no `reachable` anywhere in the file. Add arms - asserting endpoints-as-identities-alone on (a) `edges` rows, (b) a - `reachable` witness path, and (c) `query node`'s incoming/outgoing edge - lists — each traversing a code location. - [Done 2026-08-11: T1.7-1 gains a second workspace (spec+code config; - `src/app.ts#entry` --references--> `alpha` --depends--> `omega`, - `src/app.ts#writer` --embeds--> `omega`) with the three arms: (a) - unfiltered `query edges` pinned to the exact five-edge set, (b) - `reachable --from src/app.ts#entry --to specs/E.mdx#omega` pinned to the - witness path [entry, alpha, omega], (c) `query node` on alpha and omega - pinned to exact incoming/outgoing lists — endpoint values via the H-3 - decoders (identity strings), the no-range-datum half via a new - adapter-layer 1.7 walk in `test/helpers/adapters/query.ts` - (`assertBareEdgeEndpoints` over whole edges/reachable documents; - `assertNodeEdgeListsBare` scoped to the node report's edge lists so the - node's own contractual sourceRange stays out of scope). The walk rejects - any member named `range`/`sourceRange` and any {start,end}-bearing object, - detector adapter-owned like the ASSUMED SHAPE. S-5 guards added - (accepts-bare + rejects-with-path synthetic cases, missing-edges-member - rejection). NOT red against this repo's product: it already reports bare - endpoints — probe confirmed the exact edge set and witness path above, so - the pass is genuine; teeth live in the S-5 rejections plus exact-value - pinning. Traceability unchanged (T1.7-1 → ["1.7"]; no numbered condition). - `npm run test:self`: unchanged 4 planned mid-loop reds - (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G); - suite file 1.6-1.7 shows only T1.6-5's pre-existing FP-001-class product - gap (form-exact findings decode), T1.7-1 green.] - -- [x] FP-013 — T4.3-2: add the zero-argument and two-argument `text(...)` - arms. [R1 #16; TEST-SPEC §4.3, SPEC 14.8] - `test/suite/registry/section-4.3-4.4.ts` (~line 294) has only the - string/computed-index/optional-chaining arms. Add `text()` and - `text("a","b")` calls in a TypeScript file, each → 14.8. - [Done 2026-08-11: two arms appended to T4_3_2_ARMS — `text();` and - `text(SPEC.a, SPEC.a.b);` — the two-argument arm passing two static - resolvable node chains per T2.4-3's MDX precedent (the language's valid - argument form; this task summary's literal `text("a","b")` would stage - two further string-form-in-TS 14.8 defects, making the finding count - ambiguous and letting an arity-tolerant product pass), so arity is each - arm's sole defect: exactly one 14.8 asserted at the call within the - offending statement's byte window, per TEST-SPEC §4.3 ("14.8's arity - clause holds in either language"). Title extended to the full TEST-SPEC - entry; traceability already ["4.3","14"], unchanged. Verified by direct - probe against the built product: a valid one-argument control of the - identical shape builds exit 0 (arity is the sole delta); each arm exits 1 - with exactly one 14.8 located within the computed window, no 14.18 - beside; the suite test itself stays red-as-diagnosed at the pre-existing - FP-001-class form-exact decode (the product still emits condition-member - findings — same first-arm failure as before, new arms unreached until - that product gap closes). `npm run test:self`: unchanged 4 planned - mid-loop reds (certification-document ×3 → FP-091; S-1's 9 unmapped keys - → stages E/G).] - -- [x] FP-014 — T4.5-2: add the upstream (cross-file) impact arm. [R1 #17; - TEST-SPEC §4.5] - `test/suite/registry/section-4.5.ts` stages only the same-document - subtreeHash edit (direct impact). Add: the marker's document bears a - root-sourced `{text(...)}` edge into another file; an edit THERE changing - only that root's effectiveHash leaves the location transitively impacted - while no node of the marker's own document is `changed`. - [Done 2026-08-11: T4.5-2 gains a second workspace — MAIN.mdx holds - `import OTHER from "./OTHER.xspec"` plus a top-level `{text(OTHER. - upstream)}` outside any section (the T8-5 shape: root-sourced `embeds` - edge, SPEC 2.3/1.2) and an untouched `local` section as in-document - control; the same root-marker `src/app.ts`. Staging integrity pins both - dependency edges as complete per-kind sets, so the marker's `references` - edge is the location's only impact edge and the root-sourced `embeds` - edge the root's only dependency edge. After baseline commit + edit of - the embedded target's text: `assertImpactedCode` (section-9's helper) — - direct EMPTY, transitive exactly [src/app.ts | references → - specs/MAIN.mdx | path specs/MAIN.mdx > specs/OTHER.mdx#upstream], the - witness forced (the `contains` step to `local` has unchanged - effectiveHash); `assertRequirementCategories` (section-5.6's helper, the - section-15 reuse precedent) with the complete table — `upstream` - `changed`, OTHER root `descendant-changed` exact [upstream], MAIN root - exactly `upstream-changed` exact [upstream], `local` uncategorized — - which realizes "no node of the marker's document `changed`" (a product - folding embedded text into the embedder's own content would flip MAIN - root to `changed`/the location to direct). NOT red against this repo's - product: impact semantics predate the patch — a direct probe of the - staging against the built product returned byte-for-byte the expected - edges, code groups, and category table, so the pass is genuine; teeth - live in the exact-value pinning over the forced fixture. Traceability - unchanged (T4.5-2 → ["4.5"]; the entry's 8/9.2 parentheticals are - context with home coverage at T8-*/T9.2-*, no numbered condition - asserted). Verified: section-4.5 unchanged 2 failed / 5 passed (T4.5-3/ - T4.5-5 red at the pre-existing FP-001-class form-exact product gap; - T4.5-2 green including the new arm); `npm run test:self` unchanged 4 - planned mid-loop reds (certification-document ×3 → FP-091; S-1's 9 - unmapped keys → stages E/G).] - -- [x] FP-015 — T6.4-1: assert the rename command's own report — the applied - mapping. [R1 #18; TEST-SPEC §6.4, SPEC 12.0, H-3] - `test/suite/registry/section-6.4.ts` asserts journal append and rewrites - only; rename stdout is never decoded. Add: decode rename's stdout (JSON - per 12.0, H-3 adapter) and assert the report is the applied mapping — - every journaled identity pair. - [Done 2026-08-11: T6.4-1's rename now runs with `--json` (runJson: exit 0, - single JSON document as the entire stdout, 12.0) and its report decodes - through a new adjustable H-3 adapter, `test/helpers/adapters/operations.ts` - `decodeAppliedMappingReport` (ASSUMED SHAPE `{"mapping":[{"from","to"}…]}`, - mirroring the preview's pinned 12.7 `mapping` member — the report shape - itself is unpinned, adapter adjustable to shape never values, fail-loud on - a mapping-less report; model type `AppliedMappingPair`). The test asserts - the pairs as a complete set via support.ts `assertAppliedMapping` (order is - shape, not information): exactly {core.mid→core.hub, core.mid.leaf→ - core.hub.leaf} in full 1.5 identity form — SPEC 6.4 pins the journaled - mapping as the renamed ID plus prefix-replaced descendants, nothing else, - and the fixture's post-rename identity assertions already pin those two as - the only new identities. S-5 gains the adapter's DECODERS entry (positive - control incl. an ignored `findings` sibling member; rejections: absent/ - null/non-array mapping — the current product's `{"findings":[]}` shape is - the labeled absent case — pair missing from/to, empty identity, non-object - pair). Verified: T6.4-1 turned falsely-green → red-as-diagnosed exactly at - the applied-mapping decode ("required key \"mapping\" … absent" — probe: - the product reports `{"findings":[]}` on successful rename and rejects - `--preview` as unknown, the whole 6.6 surface being patch-new); section-6.4 - went 3 failed/4 passed → 4 failed/3 passed, the other three being the - pre-existing FP-002/FP-007-class gaps (T6.4-3/-4/-6), downstream arms - unreached until the product reports the mapping. Typecheck clean; S-5 - 66/66; `npm run test:self` unchanged 4 planned mid-loop reds - (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages E/G); - T6.4-1 in no certification scope. Traceability unchanged (["6.4"]; 12.0/ - 6.6 are carriage context with home coverage elsewhere). FP-017 (move's - applied-mapping report) reuses this adapter and helper.] - -- [x] FP-016 — T6.4-4: add the wrong-kind and parse-local old-ID-existence - arms. [R1 #19; TEST-SPEC §6.4] - `test/suite/registry/section-6.4.ts`, none present today: (a) discovered - code source passed as `<file>` → exit 2; (b) duplicate-spelling bearers → - exit 1 via the duplicate-ID finding; (c) bearer beneath an ancestor - spelling no identity → exit 1; (d) sole would-be bearer spelling no - identity (repeated `id` attribute) → exit 2 beside that file's findings. - [Done 2026-08-11: (a) base and ordering arms now run on - SPEC_AND_CODE_CONFIG with a discovered, reference-free `src/app.ts`, each - adding `rename src/app.ts a a2` → exit 2 via `expectRenameUsageError` - (FP-002 protocol: single 12.7 error document under `--json`, stderr - message present) beside the existing nonexistent-file/old-ID invocations — - TEST-SPEC's "checked before source validation" covers all three, so the - wrong-kind operand rides both arms; three parse-local arms follow the - masking arm: (b) two top-level sections both spelling `dup` → - `expectRefusalModifiesNothing` (the T6.4-6 protocol: exit 1 under - `--json`, form-exact 12.7 findings-only report, exactly one 14.3 located - in the file, whole-root snapshot compare); (c) sole bearer `kid` beneath - an id-less `<S>` ancestor → same protocol, exactly one 14.1 (the bearer's - 14.2 masked per condition 2's rule, so the ancestor's finding is the - workspace's only one); (d) `<S id="solo" id="solo">` sole would-be bearer - → staging premise pinned first (`build --json` reports exactly one 14.17 - — a repeated `id` is condition 17, never 14.1, and spells no identity) - then `rename … solo solo2` → exit 2 via the usage-error protocol. - Title/module comments extended; traceability unchanged (["6.4","12.0"]: - T6.4-4 appears in no TEST-SPEC 14 staging record — the masking arm's - 14.20 precedent); no certification scope. Soundness proven by direct - probes against the built product (suite arms downstream of the first are - unreached — T6.4-4 stays red-as-diagnosed at the base arm's exit-2 - error-document decode, the FP-002-class product gap; findings decodes the - FP-001 class): wrong-kind exits 2 on valid and failing workspaces; dup → - exit 1 with exactly one 14.3 in-file; anc → exit 1 with exactly one 14.1, - no unknown-ID error (existence established by the spelled bearer); solo → - build exactly one 14.17, rename exit 2 "unknown ID 'solo'"; both refusals - wrote nothing. Section-6.4 unchanged 4 failed / 3 passed; `npm run - test:self` unchanged 4 planned mid-loop reds (certification-document ×3 → - FP-091; S-1's 9 unmapped keys → stages E/G).] - -- [x] FP-017 — T6.5-1: assert the applied-mapping report for the file-form - move (both forms report as rename does). [R1 #20; TEST-SPEC §6.5] - `test/suite/registry/section-6.5.ts`: decode the move command's stdout in - both file form and section form and assert the applied mapping, exactly - as FP-015 does for rename. - [Done 2026-08-11: both forms decode through FP-015's layer - (`decodeAppliedMappingReport` + `assertAppliedMapping`), split as the - journal clause already is (TEST-SPEC T6.5-1 carries the clause; module - header documents the split). T6.5-1's file-form move now runs via - `runJson` (`--json`: exit 0, single JSON document as the entire stdout, - 12.0) and asserts exactly four pairs — every node of the moved file, the - implicit root included (SPEC 1.2/1.5: its identity is the path alone; - its pair is journaled, else 6.3 replay could not unify the root across - the move, T6.2-2's purity). T6.5-3's section-form move gained `--json` - in R3_MOVE_ARGV (identical argv in both H-6 determinism directories, so - that compare is unaffected) and asserts exactly the three - prefix-replaced subtree pairs (`org.mv{,.k1,.k2}` → `tm{,.k1,.k2}`), - no other identity mapped. Titles extended; traceability unchanged - (both ["6.5"]; 12.0/6.4/6.6 carriage context per FP-015 precedent); - neither test in any certification scope; no new adapter, so S-5's - existing operations-adapter guards cover the decode. Verified: both - tests turned falsely-green → red-as-diagnosed exactly at the - applied-mapping decode ("required key \"mapping\" … absent" — probe: - the product reports `{"findings":[]}` on successful move in both - forms), downstream arms unreached until the product reports the - mapping; section-6.5 went 3 failed/3 passed → 5 failed/1 passed - (T6.5-2 stays green; T6.5-4/-5/-6 keep their pre-existing - FP-002/FP-007-class reds). Typecheck clean; `npm run test:self` - unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; - S-1's 9 unmapped keys → stages E/G).] - -- [x] FP-018 — T6.5-4: add the missing destination-refusal arms. [R1 #21; - TEST-SPEC §6.5] - `test/suite/registry/section-6.5.ts` (plain-file arm at ~line 1435 is the - only occupancy arm): add (a) file-form destination occupied by a symbolic - link and by a broken symbolic link; (b) section-form target path occupied - by a directory, by a symlink resolving to a discovered spec source, and - by an out-of-group `.mdx` file — the latter asserting BOTH - `refused-invalid-destination` and `refused-destination-exists`; (c) empty - `<new-id>` (`b.mdx#`) refused `refused-invalid-id`; (d) the derived-path - arm of `refused-invalid-destination`: emission under `markdown.outDir` - with emit-destination component `<outDir>/new` occupied by a plain file. - Assert stable refusal codes per FP-007. - [Done 2026-08-11: all seven arms landed in T6.5-4. - `expectRefusalModifiesNothing` widened to take one `RefusalExpectation` - per applicable reason (counts assert the complete multiset — no reason - beside the staged ones; per-reason concern lookup by counting key, total - since a refusal report holds one finding per reason, SPEC 14). (a)+(b) - ride the shared refusal workspace — occupants staged before the premise - `build` (directory `specs/DirTarget.mdx`; symlinks `specs/SymDest.mdx`/ - `specs/LinkTarget.mdx` → B.mdx, broken `specs/GoneDest.mdx`; plain - out-of-group `docs/Occ.mdx` in the files map), each arm - `refused-destination-exists` concerning the occupied path, the Occ arm - additionally `refused-invalid-destination`, both concerning - `docs/Occ.mdx`. (c) `move specs/A.mdx#keep specs/B.mdx#` → - `refused-invalid-id` concerning `specs/B.mdx#` (zero-segment id; exit 1, - never the exit-2 generalization of 11.3's `--to` rule). (d) its own - workspace (`V4_OUTDIR_CONFIG`: glob admits `new/**/*.mdx`, - `markdown.outDir: "mdout"`; plain file at `mdout/new`; premise `build` - exit 0 — the occupant lies under no current source's write path) → - `move specs/Solo.mdx new/b.mdx` refused `refused-invalid-destination` - concerning the destination path `new/b.mdx`, never 14.22, the count map - excluding a 14.22 beside. Verified by direct probes against the built - product (suite arms past the first are unreached — T6.5-4 stays - red-as-diagnosed at the first arm's FP-001-class form-exact decode, the - product still emitting `{"refused":…}`/`condition`-member shapes; - section-6.5 unchanged 5 failed / 1 passed): both premise builds exit 0 - with every occupant present; the five occupant arms and the empty-id arm - each exit 1 modifying nothing on exactly the staged ground (the product's - old-shape message names the symlink/directory/broken-link occupant, the - no-spec-group cause, the empty-segment ID); the two-reason Occ arm's - count assertion has teeth (the current product reports only one reason); - a control twin proves (d)'s staging — without the occupant the identical - move succeeds and writes `mdout/new/b.md`, pinning the 13.2/7.3 emit - shape — while with it the product exits 70 (internal error) and modifies - the workspace: exactly the diagnosed vets-only-own-components gap the arm - discriminates. Typecheck clean; `npm run test:self` unchanged 4 planned - mid-loop reds (certification-document ×3 → FP-091; S-1's 9 unmapped keys - → stages E/G), S-5 and certification green. Traceability unchanged - (["6.5","14"] — T14-7's refusal staging record already rides "14"; 7/ - 7.3/13.1/13.2/13.4 are context with home coverage elsewhere); T6.5-4 is - in no certification scope (Exclusions-shared machinery only).] - -- [x] FP-019 — T6.5-5: add the missing usage-error arms. [R1 #22; TEST-SPEC - §6.5] - `test/suite/registry/section-6.5.ts` (~line 1580 area has only - nonexistent origin file/ID, ordering, masking): add (a) wrong-kind - (code-source) origin in each form; (b) the three mixed-synopsis - invocations `a.mdx b.mdx#y`, `a.mdx#x b.mdx`, `a.mdx b#c.mdx` → exit 2; - (c) parse-local ID existence arms mirroring T6.4-4 (FP-016). (The - non-UTF-8 destination operand arm arrives via FP-005's restage.) Exit-2 - protocol per FP-002. - [Done 2026-08-11: (a) base and ordering arms now run on a new - SPEC_AND_CODE_CONFIG (6.4's mirror) with a discovered, reference-free - `src/app.ts`; U5_WRONG_KIND_CASES adds `move src/app.ts specs/New.mdx` - (file form) and `move src/app.ts#noop specs/B.mdx#z` (section form — the - id part names the file's real exported unit, discriminating a product - that resolves code units in move origins), each exit 2 via - `expectMoveUsageError` (FP-002 protocol), riding both arms per - TEST-SPEC's "checked before source validation", the base-arm pair inside - whole-root `assertLeavesUnchanged` compares (an accepting product would - relocate the file). (b) the two missing mixed-synopsis arms `move - specs/A.mdx specs/B.mdx#y` and `move specs/A.mdx#a specs/B.mdx` landed - beside the FP-005-restaged `specs/Ha#sh.mdx` arm (the trio's third, kept - as the dead-letter staging), every operand naming staged content, each in - a whole-root modifies-nothing compare — exit 2, matches-neither by - spelling alone. (c) three parse-local arms mirror FP-016 with identical - sources: dup (two bearers of `dup`) → `expectRefusalModifiesNothing`, - exactly one 14.3 located in the file, target file not created; anc (sole - bearer `kid` beneath an id-less ancestor) → same protocol, exactly one - 14.1; solo (`<S id="solo" id="solo">`) → build premise exactly one 14.17, - then move exit 2 beside that file's findings. Title and module header - extended; traceability unchanged (["6.5","12.0"], the T6.4-4/FP-016 - precedent — T6.5-5 in no TEST-SPEC 14 staging record and no certification - scope). Soundness proven by direct probes against the built product - (suite arms past the first are unreached — T6.5-5 stays red-as-diagnosed - at the first arm's FP-002-class exit-2 error-document gap): base build - exit 0 with the code file; both wrong-kind forms exit 2 ("unknown file") - modifying nothing; mixed-2 exits 2 ("names no target section"); - mixed-1 exposes a real classification gap — the product treats - `specs/B.mdx#y` as a `#`-containing file-form destination path and - REFUSES exit 1 where the arm demands exit 2 (matches-neither), modifying - nothing; dup/anc exit 1 with exactly the one old-shape 14.3/14.1 finding - (the FP-001-class form gap), creating nothing (no New.mdx, no `.xspec/`); - solo: build reports exactly one 14.17, move exits 2 "unknown ID 'solo'". - Typecheck clean; section-6.5 unchanged 5 failed / 1 passed (T6.5-2 - green); `npm run test:self` unchanged 4 planned mid-loop reds - (certification-document ×3 → FP-091; S-1's 9 unmapped keys → stages - E/G), S-5 and certification green.] - -- [x] FP-020 — T7-2: add the string-literal group-name keys arm. [R1 #23; - TEST-SPEC §7] - Neither `test/suite/registry/section-7-basics.ts` nor - `section-7.4-7.5.ts` stages quoted keys. Add: group names as string - literals (`"my-group"`/`"test-code"`) load, discover, and resolve in a - coverage profile and in a policy selector. - [Done 2026-08-11: one arm appended to T7-2 (section-7-basics.ts) — a - config declaring spec group `"my-group"` and code group `"test-code"` - under string-literal keys (both names non-identifiers, so only that - spelling declares them), referenced from the coverage profile - (`target`/`boundary`, kinds inferred) and both selectors of a forbidden - rule. Asserted: `build` exit 0 (loads; an identifier-keys-only product - refuses 14.14/exit 2 here — TEST-SPEC's discriminator); discovery via - the exact `ids` listing plus whole-graph edge-set equality (2 contains + - `p`→`a` depends + the top-level marker's src/impl.ts→`a` references edge - — the code-group observation, T7-3's contrapositive); resolution via - profile "quoted" reporting covered `a` with path [src/impl.ts, a] and - uncovered `p` (counts/ignored stay T8.2-1's subject, the section-8 - discipline), and via `check --json` exit 1 with exactly one 14.12 - finding, identities [rule, p, depends, a], locations [], path null - (SPEC 14.12's contractual enumeration). Title extended; traceability - unchanged (["7","14"] already covers the asserted conditions; 7.4/7.5/8 - are context with home coverage at T7.4-*/T7.5-*/T8-*); no certification - scope. Verified: direct CLI probes against the built product returned - byte-for-byte the expected ids/edges/coverage documents and exactly the - one old-shape policy violation (the current product already accepts - quoted keys — those observations are genuinely green), and a scratch - execution of the arm body ran green through build/ids/edges/coverage/ - check-exit and red exactly at the form-exact findings decode ("expected - no member \"condition\"" — the FP-001-class product gap), so the arm is - red-as-diagnosed there; in the suite T7-2 still fails at its first - FORM_VIOLATIONS arm (the FP-002-class exit-2 gap), the new arm - unreached until that closes. Typecheck/format clean; `npm run - test:self` unchanged 4 planned mid-loop reds (certification-document ×3 - → FP-091; S-1's 9 unmapped keys → stages E/G).] - -- [x] FP-021 — T7.5-5: add the literal-`$` forms arm. [R1 #24; TEST-SPEC - §7.5, SPEC 14.14] - `test/suite/registry/section-7.4-7.5.ts`: stage `$0`, trailing `$`, and - `$` before a non-digit, each in `from` and in `to`; assert they load - without 14.14 and match only the literal bytes. - [Done 2026-08-11: six arms (e)-(j) appended to T7.5-5 — the three forms - staged once in `from` and once in `to` per TEST-SPEC's "one arm each". - Load-without-14.14 is each arm's `build` exit 0 (14.14 is load-enforced by - every command; a capture-reading product refuses the `to`-side `$0`/ - trailing-`$` arms as referencing an absent capture and dies there); - matching-only-the-literal-bytes is the exact 14.12 finding set over bait: - every fixture stages, beside the literal-byte path, the paths a capture - reading (`src/ab.ts` for `src/a$0.ts` — the spec's own example), - dropped-`$` reading (`a0.ts`/`ax.mdx`/`t0.mdx`/`tz.mdx`), one-byte-wildcard - reading (`aQx.mdx`/`tQz.mdx`), or regex-anchor reading (`src/end` for - `src/end$`) would match instead, each bearing a same-shaped edge. The - trailing-`$`-in-`from` arm stages the `$`-suffixed name as a code source - under the extension-free glob `src/*` (a spec source always ends `.mdx`, - 14.19; SPEC 7.2 restricts code groups by glob alone); the - trailing-`$`-in-`to` arm (`tgt/T.mdx$`) can match no discovered target, so - it pins the anchor-bait edge's presence via `query edges` (T7-3's - premise precedent) then asserts plain `check` exit 0 — zero findings. - Title and module header extended; traceability unchanged (["7.5"] — - T7.5-5 is in no TEST-SPEC 14 staging record, the T7.5-3/-4 precedent); no - certification scope. Verified: direct CLI probes of all six stagings - against the built product returned exactly the expected sets in the old - finding shape (the matcher is already literal on every form; all six - builds exit 0), and guarded solo executions of each arm body ran (e), - (f), (g), (i), (j) green through build/check-exit and red exactly at the - form-exact 12.7 findings decode ("expected no member \"condition\"" — the - FP-001-class product gap) while (h) passed in full, so each finding- - bearing arm is red-as-diagnosed at the known product gap with its set - assertion proven satisfiable. In the suite T7.5-5 still fails at its - first arm (a)'s decode, the new arms unreached until that closes; - section-7.4-7.5 unchanged 7 failed / 1 passed (T7.4-2 green). Typecheck/ - format clean; `npm run test:self` unchanged 4 planned mid-loop reds - (certification-document ×3 → FP-091; S-1 unmapped keys → stages E/G), - S-5 and certification green (CONF-VALID 12/12, CONF-MD 8/8, CONF-CORE - 9/9, CONF-DISC 3/3, violators failing as certified).] - -## Stage E — §§1–9 missing tests (new-test convention applies) - -- [x] FP-022 — Implement T1.7-2: code-location ranges via occurrence - records. [R1 #1; TEST-SPEC §1.7] - First harness use of `xspec occurrences` (it is never invoked anywhere - today). Assert ranges against precomputed byte offsets for: whole-file, - function/class, multi-declaration variable, dotted namespace, default - exports, and `@2` disambiguation. Registry module - `section-1.6-1.7.ts`; map `"1.7"`. - [Done 2026-08-11: T1.7-2 registered in section-1.6-1.7.ts — one valid - workspace, eight code files each staging one sanctioned reference inside - one SPEC 4.6 unit shape, every file opening with multi-byte UTF-8 before - its constructs (byte offsets diverge from code-point/UTF-16 counts); one - bare `occurrences` invocation (11.3 is JSON-only, no `--json`) asserting - the complete nine-record document per-index against precomputed offsets: - whole-file (top-level marker: identity the path alone, range 0..byteLen), - `function fn`/`class Cls` construct ranges (the class attribution via a - `text(SPEC.alt)` property initializer — a call expression is no named - unit, so the embed attributes to the class; also the one `embeds` arm), - `handler` name-through-initializer inside `const one = 1, handler = …` - (statement excluded), dotted `namespace Outer.Inner` whole-declaration - range (the shared range pinned through the reachable unit — every body - position lies within `Inner`, and a unit's range is reachable exactly - through occurrences it sources, so bare `Outer` sources none; comment - documents the reading), `export default function named` = construct's own - range (prefix excluded) vs anonymous default = whole declaration under - unit `default` (two files — one default export per module), getter - `Pair.value` vs setter `Pair.value@2` each carrying its own construct. - The document decodes through a NEW form-exact 12.7 layer - (`decodeOccurrencesReport`/`decodeOccurrenceRecordForm` in - adapters/forms.ts, model types in model.ts): exactly - {"findings","occurrences"}, records exactly - {"file","range","kind","source","target"}, kind from the dependency-kind - vocabulary (never `contains`), `source` decoded through the S-5-guarded - three-state datum decode with `null` rejected (defined or the - unavailability marker, never null), the 5.7 total order enforced (file - path bytes, start, end; identical spans reject). S-5 gains the document's - DECODERS entry (positive controls incl. unavailable source, byte-form - referencing file, accompanying findings, same-start tie by end; 24 - targeted rejections). A pre-product fixture self-check slices every - claimed range back out of the staged bytes, so staging-arithmetic errors - fail harness-side, never as wrong-but-satisfiable expectations. - Traceability: "T1.7-2": ["1.7"] (no numbered condition asserted; 4.6/5.7/ - 11.3/12.7 context with home coverage elsewhere); in no certification - scope (CERTIFICATIONS.md Exclusions names T1.7-2 explicitly). Verified: - red-as-diagnosed exactly at the `occurrences` invocation (exit 2 "unknown - command 'occurrences'" — the whole 11.3 surface is patch-new) with the - build premise green, and a scratchpad probe of the identical staging via - `query edges --kinds references,embeds` returned byte-for-byte all nine - expected (source identity, kind, target) triples — `#default`, `#named`, - `#Outer.Inner`, `#Pair.value`, `#Pair.value@2`, `#Cls`, `#fn`, - `#handler`, whole-file `src/top.ts` — against the product's pre-existing - 4.6 attribution, so only the ranges (the arm's patch-new subject) await - the product. Section file 2 failed / 5 passed (T1.6-5 keeps its - pre-existing FP-001-class red); typecheck/format clean; `npm run - test:self` unchanged 4 planned mid-loop reds (certification-document ×3 - → FP-091; S-1's 9 unmapped keys → stages E/G), S-5 (243 tests now) and - certification green.] - -- [x] FP-023 — Implement T5.7-1: occurrence units and duplicates. [R1 #2; - TEST-SPEC §5.7] - One record per `d` array entry / embedding / call / marker; collapsed - edges vs distinct occurrence records. New registry module for §5.7 (e.g. - `test/suite/registry/section-5.7.ts` + suite wrapper); map `"5.7"`. - [Done 2026-08-11: new registry module section-5.7.ts (wrapper - section-5.7.test.ts, spread into index.ts) registering T5.7-1 — one - workspace staging all five occurrence kinds: `tri`'s three-entry mixed - `d` array (external chain, local string, external chain), `solo`'s - single-reference `d={"peer"}`, `emb`'s MDX `{text(BASE.a.b)}`, and in - src/app.ts a `text(SPEC.emb)` call, a once-spelled marker, and the - twice-spelled marker, plus TEST-SPEC's literal duplicate pair - `d={[BASE.a.b, BASE.a.b]}`. Premise `build` exit 0; bare `occurrences` - (JSON-only, no `--json`) decoded through FP-022's form-exact 12.7 - layer; findings []; the complete 11-record (file, [kind], source -> - target) multiset asserted order-free — one record per `d` array entry, - never one for the array or the prop; one per embedding/call/marker, - each carrying its edge kind; two per duplicate pair (byte-precise - spans are T5.7-2's subject, the total order T5.7-3's, decode-enforced - as 12.7 form meanwhile) — each duplicate pair additionally pinned to - exactly two records at distinct ranges, and the collapse side pinned - via unfiltered `query edges` against the complete 17-edge set (8 - contains + 5 depends + 2 embeds + 2 references — the dup pair and the - twice-spelled marker one edge each). Traceability "T5.7-1": ["5.7"] - (no numbered condition asserted; 2.2/5.2/11.3 context with home - coverage elsewhere); in no certification scope (CERTIFICATIONS.md - Exclusions: the TS-side occurrence enumerations sit behind the - tooling wall). Verified: red-as-diagnosed exactly at the `occurrences` - invocation (exit 2 "unknown command 'occurrences'" — the whole 11.3 - surface is patch-new) with the build premise green; a direct probe of - the staging against the built product returned byte-for-byte the - expected 17-edge set (both duplicate groups already collapse, per-unit - 4.6 attribution confirmed); satisfiability proven by running the - registered body via a scratch binding against a shim product (real - product + conforming occurrences answer computed from the staged - bytes) — green through every assertion — and against a - one-record-per-array/per-group deviation shim — red exactly at the - multiset assertion (teeth). Typecheck/format clean; `npm run - test:self` 4 planned mid-loop reds (certification-document ×3 → - FP-091; S-1 unmapped keys now the 8-key set {6.6, 11.2, 11.3, 11.4, - 11.5, 11.6, 12.6, 12.7} — "5.7" mapped by this task), S-5 and - certification green.] - -- [x] FP-024 — Implement T5.7-2: byte-precise occurrence spans per kind. - [R1 #3; TEST-SPEC §5.7] - Array-entry expression only; the whole braced `{text(...)}` container; - callee-through-paren; bare marker chain without `;`. - [Done 2026-08-11: T5.7-2 registered in section-5.7.ts — one workspace, - six occurrences, every span byte-asserted against precomputed offsets - composed from the same string parts the files are (the T1.7-2 discipline: - multi-byte UTF-8 before every asserted construct so byte offsets diverge - from code-point/UTF-16 counts; a pre-product fixture self-check slices - each claimed range back out of the staged bytes). Arms: the three-entry - `d` array `[BASE.x , BASE.mid , "pre"]` with whitespace on BOTH sides of - each comma — all three entry spans asserted, the middle entry the featured - no-brackets/commas/whitespace subject, the string entry spanning its - quotes; the MDX `{text(BASE.y)}` container brace-through-brace; the - import-ALIASED TS callee `t(SPEC.x)` from its `t` through `)` (SPEC 4.4's - sanctioned aliasing), `;` excluded; the marker chain `SPEC.y.leaf` alone, - indentation, `;`, and trailing comment excluded. Records identified by - their unique (file, kind, source, target) tuples — report order stays - T5.7-3's subject (decode-enforced as 12.7 form meanwhile), source-node - range data likewise; findings [] and exact count 6 pinned. Decode through - FP-022's form-exact layer (no new adapter; S-5's existing guards cover - it). Traceability "T5.7-2": ["5.7"] (no numbered condition; 1.7/3/4.4/ - 11.3 context with home coverage elsewhere); in no certification scope - (CERTIFICATIONS.md Exclusions: T5.7-1 through T5.7-4 behind the tooling - wall). Verified: red-as-diagnosed exactly at the `occurrences` invocation - (exit 2 "unknown command" — the whole 11.3 surface is patch-new) with the - fixture self-check and build premise green; staging externally validated - by direct probe of the built product — `build` exit 0 and `query edges` - byte-for-byte the six expected dependency tuples (aliased callee - attributed to src/app.ts#call included); satisfiability and teeth proven - by running the registered body against a shim product (real product + - conforming occurrences answer recomputed from staged bytes by anchored - search, independent of the module's prefix arithmetic) — green through - every assertion — and against a marker-span-includes-`;` deviation — red - exactly at the marker span assertion. Typecheck/format clean; `npm run - test:self` unchanged 4 planned mid-loop reds (certification-document ×3 - → FP-091; S-1's 8 unmapped keys → stages E/G), S-5 and certification - green.] - -- [x] FP-025 — Implement T5.7-3: occurrence record data and total - deterministic order. [R1 #4; TEST-SPEC §5.7, H-6] - Record members: file, range, edge kind, source node as - identity-plus-range, target identity. Total order: path bytes, range - start, range end. - [Done 2026-08-11: T5.7-3 registered in section-5.7.ts — three referencing - files whose paths give the byte-order clause teeth (`specs/Zed.mdx`, 0x5A, - sorts before `specs/alpha.mdx`, 0x61, while any case-folding collation - reverses the pair; `specs/` before `src/`), six occurrences asserted - per-index as the complete document — every member: file, own range, kind, - source as ONE identity-plus-range datum, target identity — against - offsets composed from the staged files' own string parts (the - T1.7-2/T5.7-2 discipline: multi-byte UTF-8 before every asserted - construct; fixture self-checks slice each claimed range back out of the - staged bytes AND re-derive the claimed sequence under the pinned - comparator). Source-datum arms: MDX nested section `zout.zin` sourcing - both a `d` and an embedding (identical datum, construct range strictly - inside the parent's), the ROOT sourcing a top-level embedding (identity - the path alone, range 0..byteLen — the T8-5 shape), TS whole-file - (top-level marker) and innermost nested named unit `wrap.deep` (the inner - declaration's own construct, never the enclosing `wrap`). H-6: the - identical `occurrences` invocation twice, stdout byte-identical - (stdoutBytes compare); no two records share a range (pairwise-distinct - expected ranges; same-start pairs are unstageable — distinct spellings - occupy distinct spans — so the comparator's range-end leg decides no - staged pair, and the decode enforces it as 12.7 form over whatever a - product emits, the T6.6-4 latitude treatment). Traceability "T5.7-3": - ["5.7"] (no numbered condition; 1.7/4.6/11.3 context with home coverage - elsewhere); in no certification scope (CERTIFICATIONS.md Exclusions: - T5.7-1 through T5.7-4 behind the tooling wall). Verified: - red-as-diagnosed exactly at the `occurrences` invocation (exit 2 "unknown - command" — the whole 11.3 surface is patch-new) with self-checks and - build premise green; staging externally validated by direct probe of the - built product (`build` exit 0; `query edges` byte-for-byte the six - dependency edges with exactly the claimed source attributions — - root-sourced embeds, innermost nested section, whole-file and `wrap.deep` - code sources); satisfiability and teeth proven by running the registered - body against a shim product (real product + conforming occurrences answer - recomputed from staged bytes by anchored search, independent of the - module's prefix arithmetic) — green through every assertion — and against - three deviations: case-insensitive file order, outer-section source - attribution, run-to-run member-order jitter — each red at its diagnosed - assertion (order decode, record[0]'s source datum, the H-6 byte compare). - Typecheck/format clean; `npm run test:self` unchanged 4 planned mid-loop - reds (certification-document ×3 → FP-091; S-1's 8 unmapped keys → stages - E/G), S-5 and certification green.] - -- [x] FP-026 — Implement T5.7-4: no-occurrence constructs and the exit-1 - answer carrying the domain's findings. [R1 #5; TEST-SPEC §5.7] - Imports, type-only uses, shadowed chains, dynamic/unresolving spellings - produce no records; the exit-1 answer still carries the domain's - findings. - [Done 2026-08-11: T5.7-4 registered in section-5.7.ts — one workspace - staging every no-occurrence class beside three resolving spellings (one - per dependency-kind surface): MDX used + never-used imports (2.1's pair), - both T4-4 type-only forms with marker-shaped/call-shaped uses, the T4.5-4 - shadowing function re-spelling the IDENTICAL statement texts `SPEC.ok;`/ - `SPEC.absent;` rooted at the local, a dynamic `` d={`ok`} `` (template - literal spelling an EXISTING id, so an evaluating product both drops the - 14.8 and emits a phantom resolved record — failing twice), an unresolving - local `d={"nope"}` (14.5), an unresolving embedding `{text(BASE.gone)}` - (14.6), an unresolving marker `SPEC.absent` (14.7). Premise pinned first: - `build --json` reports EXACTLY {14.5, 14.6, 14.7, 14.8} ×1 — so imports/ - type-only/shadowed provably trigger nothing and the resolving spellings - resolve — each finding located in its construct's byte window, the 14.6 - finding's range asserted EXACTLY the full braced container (SPEC 14's - amended pinning, the span its occurrence would occupy; one spelling → one - location). Then bare `occurrences`: exit 1 with the full answer still - emitted (11.2), the same finding assertions on the answer's findings, and - the complete record multiset exactly the three resolving tuples — - phantom records fail by count/tuple, an unavailable TARGET is rejected by - the form-exact decode itself (12.7: target is an identity string). No new - adapter (FP-022's layer; S-5 unchanged). Traceability "T5.7-4": ["5.7", - "14"] (numbered conditions asserted; 2.1/2.4/4.5/11.2/11.3 context with - home coverage elsewhere); in no certification scope (Exclusions name - T5.7-1..-4). Verified: red-as-diagnosed — in the suite T5.7-4 fails at - the premise `build --json` form-exact findings decode (the FP-001-class - product gap: old-shape `condition`-member findings), downstream arms - unreached until that closes, then red at the `occurrences` invocation - (probe: exit 2 "unknown command") and at the 14.6 container range (probe: - the current product locates the chain `BASE.gone` [248,257) where the - container is [242,259)); staging premise externally validated by direct - probe of the built product — exit 1 with EXACTLY the four old-shape - findings, correctly classified and located, nothing for imports/ - type-only/shadowed; satisfiability and teeth proven by running the - registered body via a scratch binding against a pure-synthetic conforming - shim (12.7-form documents recomputed from the staged bytes by anchored - search, independent of the module's prefix arithmetic) — green through - every assertion — and against four deviations: unavailable-target (red at - the decode's target form), phantom-dynamic resolved record (red at the - multiset), chain-only 14.6 range = the current product's precision (red - at the exact container range), exit-0-with-findings (red at the exit - assertion). Typecheck/format clean; section-5.7 now 4 registered tests, - 4 failed as diagnosed; `npm run test:self` unchanged 4 planned mid-loop - reds (certification-document ×3 → FP-091; S-1's 8 unmapped keys → stages - E/G), S-5 and certification green.] - -- [x] FP-027 — Implement T6.5-7: operation-side rewrite bytes for the real - move. [R1 #6; TEST-SPEC §6.5] - Import-removal extents (own-line import dropped with terminator; - shared-line declaration's own characters only), double-quoted conversion - spellings, preserved single-quote local reference — whole files - byte-asserted against composed expected bytes. Registry - `section-6.5.ts`; map `"6.5"`. - [Done 2026-08-11: T6.5-7 registered in section-6.5.ts — one workspace, - TEST-SPEC's exact staging: Origin.mdx imports Target.xspec under two - bindings (own-line `TWO`; `TB` following the retained, still-referenced - `Keep` import on a shared line, `"; "`-separated with the removed - declaration last and semicolonless so its own-characters span is - rule-unique), the moved subtree `org.mv` holding every reference through - both bindings (`d={TWO.hub}`, `{text(TB.aux)}`) plus the single-quoted - local `d={'org.mv.leaf'}`, `Keep.keep` referenced only outside the - subtree, no reference to a moved node outside it — so the section move - into Target.mdx (top-level `mv`, end-of-file insertion) adds no import - anywhere. Body: premise `build` exit 0 (pins the two-declaration - shared-line staging parses, 2.1), plain `move` exit 0, then whole-file - byte compares of Origin (own-line declaration's line dropped with its - terminator; shared line kept as `import Keep from "./Keep.xspec"; ` — the - `;` AND the separating space survive, spelled as an explicit `+ " "` - concatenation; both pre-existing blanks around the dropped construct - kept), Target (before-bytes + rewritten moved text + U+000A: `d={"hub"}` - / `{text("aux")}` double-quoted conversions, `d={'mv.leaf'}` single-quote - preserved, ids prefix-replaced), and Keep.mdx as bystander — expected - constants independently composed from the rules of 6.5/6.4/3 with each - delta's rule cited — plus post-move `check` exit 0 as the composition's - soundness guard. Traceability "T6.5-7": ["6.5"] (no numbered condition; - 6.4/3/2.1 carriage context per precedent); CERTIFICATIONS.md names - T6.5-7 in the Exclusions (no fixture scope). NOT red against this repo's - product: the section-move rewrite machinery predates the patch — the - probe returned byte-for-byte the composed expectation (trailing space - included) with `check` clean, so the pass is genuine; teeth proven by a - scratch shim-binding probe running the registered body against two - deviations, whitespace-normalized shared line and double-quoted local - reference — each failed as HarnessAssertionError at exactly its - diagnosed compare (origin / target). Section-6.5 suite now 5 failed / - 2 passed (T6.5-2 and T6.5-7 green; the five reds are the pre-existing - FP-001/FP-002-class product gaps). Typecheck/format clean; `npm run - test:self` unchanged 4 planned mid-loop reds (certification-document ×3 - → FP-091; S-1's 8 unmapped keys → stages E/G), S-5 green, certification - 17/17.] - -- [x] FP-028 — Implement T6.6-2: preview is inert and predictive. [R1 #7; - TEST-SPEC §6.6 (new preview section)] - Preview modifies nothing; a subsequent real run's applied mapping equals - the preview's `mapping`; byte-determinism; form-exact 12.7 preview - document under `--json`. New-§6.6 tests live beside the renamed T6.7-1 - (FP-003) — keep registry module naming coherent with - `test/suite/registry/index.ts` imports; map `"6.6"`. - [Done 2026-08-11: new registry module section-6.6.ts (wrapper - section-6.6.test.ts, spread into index.ts) registering T6.6-2 — two arms, - each its own workspace under a specs+markdown-emit config so the premise - `build` materializes every derived-file kind under the compare: rename - (`core.mid` → `core.hub`, mid-tree with a descendant and sibling local - references) and section-form move (`org.mv` → existing-target `tm` with an - internal DOWNWARD local reference — the shim probe caught the first - draft's child-to-ancestor `d` as a 14.9 cycle in the combined - contains/depends graph, SPEC 5.3 — plus a staying reference the real move - converts to imported form). Each arm: journal-absent premise (6.1), then - inside ONE whole-root assertLeavesUnchanged all four preview invocations — - `--preview --json` twice and bare `--preview` twice, each pair through - assertRunTwiceDeterministic (H-6: byte-identical stdout/stderr/exit and - workspace state) — exit 0, stdout decoded through the NEW form-exact 12.7 - preview-document layer (forms.ts `decodePreviewReport` + model.ts types: - exactly {"findings","mapping","files","delta"}; mapping - `from`-byte-ordered one-per-identity; files path-byte-ordered - one-per-file, edits {"class","range"} drawn from the ten 12.7 class names - ordered by start/end/class-name-bytes — the - import-addition-before-target-insertion coincidence decode-admitted; - delta {"generated","removed"} with byte-ordered distinct paths per - direction, or the unavailability marker via the three-state datum decode; - mapping/files/delta null all-together-or-none — the refusal encoding, - mixed nullity rejects), findings asserted exactly [] and the plan members - non-null; then the subsequent real run with `--json` (exit 0, T6.4-1's - H-3 applied-mapping adapter) asserting applied mapping == preview mapping - as complete sets (assertAppliedMapping — the equality IS the TEST-SPEC - operationalization; mapping/files/delta CONTENT stays T6.6-4's/T6.6-5's). - S-5 gains the preview decoder's DECODERS entry (positive controls: full - plan incl. nested deletion geometry and the zero-length tie-break pair, - refused all-null, delta-unavailable-beside-full-plan, empty lists; 30 - targeted rejections incl. mixed nullity both ways, a replacement-text - edit member, tie-break order violation, duplicate mapped - identity/file/delta path). Traceability "T6.6-2": ["6.6"] (no numbered - condition asserted; 6.4/6.5/6.1/12.0 carriage context per precedent); - CERTIFICATIONS.md names T6.6-2 in the Exclusions — no fixture scope (its - compare-around machinery is certified via VIOL-CORE-CHATTYREADS). - Verified: red-as-diagnosed exactly at the first preview invocation (exit - 2 "unknown flag '--preview'" — the whole 6.6 surface is patch-new) with - premises green; satisfiability and teeth proven by running the registered - body via a scratch binding against a shim product (real product + - conforming preview/applied-mapping answers computed from the staged - bytes) — green through BOTH arms end-to-end, the real product performing - the actual rename and move (both stagings proceed, exit 0, validating - "the real operation would proceed" against the real product) — and - against three deviations: preview-writes-a-file (red at the - modifies-nothing compare), preview-mapping-missing-descendants (red at - the applied-mapping equality), nondeterministic bare-form output (red at - the H-6 byte compare). Typecheck/format clean; `npm run test:self` 4 - planned mid-loop reds (certification-document ×3 → FP-091; S-1 unmapped - keys now the 7-key set {11.2, 11.3, 11.4, 11.5, 11.6, 12.6, 12.7} — - "6.6" mapped by this task), S-5 (70 tests) and certification green.] - -- [x] FP-029 — Implement T6.6-3: refusal/usage-error equivalence under - `--preview`. [R1 #8; TEST-SPEC §6.6] - Same stable codes as the real operation; exit 1 with `mapping`/`files`/ - `delta` null; exit 2 identically; preview runs to completion under - `--test-hold` held by another command; `--test-hold` + `--preview` is a - usage error. - [Done 2026-08-11: T6.6-3 registered in section-6.6.ts. Refusal - equivalence runs over T6.4-3's and T6.5-4's COMPLETE case tables staged - identically — the stagings and expectation tables are now module-scope - exports of section-6.4.ts (RENAME_REFUSAL_*) and section-6.5.ts - (MOVE_REFUSAL_* + stageMoveRefusalOccupants, MOVE_DERIVED_PATH_*, - MOVE_PRECONDITION_* — the derived-path and invalid-workspace-precondition - arms included), consumed by the source tests and T6.6-3 alike so "staged - identically" holds by construction (behavior-preserving refactor: - section-6.4 unchanged 4 failed/3 passed, section-6.5 unchanged - 5 failed/2 passed, section-13.5 7/7 green). Each of the 24 refusal arms, - inside one whole-root modifies-nothing compare: real invocation `--json` - exit 1, form-exact 12.7 findings decode, per-arm code counts re-pinned - (the arm still isolates its staged cause; concerned-data assertions stay - in T6.4-3/T6.5-4), then `--preview --json` exit 1 decoded as the preview - document (refused encoding: mapping/files/delta all null; mixed nullity - already decode-rejected) with findings compared element-wise to the real - report over every member except message — code, locations, path, - identities, the contractual members (message composition unpinned, H-4; - both sides decode-validated in 12.7's total order whose keys precede the - message tie-break on exactly those members). Usage equivalence: every - T6.4-4/T6.5-5 usage-error invocation (exported tables RENAME_USAGE_CASES, - MOVE_USAGE_CASES, MOVE_WRONG_KIND_CASES, MOVE_MIXED_SYNOPSIS_CASES, - MOVE_NON_UTF8_ARGV Linux-gated, both solo argvs) runs real-then-preview - on the ordering-shaped staging (failing-build premise pinned, realizing - "argument checks precede either way"), each side exit 2 with the single - 12.7 error document and stderr presence, each sweep inside a - modifies-nothing compare; the spells-no-identity arms re-pin their - exactly-one-14.17 premise first. Scheduling: the runs-while-held arm - reuses T13.5-2's staging and choreography (section-13.5.ts now exports - CORE_DECL/holdPathFor/awaitHoldFile/runBounded/describeExit — the - CERTIFICATIONS.md Exclusions note binds exactly this sharing): while the - real `rename a a2 --test-hold` is held, `rename g g2 --preview --json` — - T13.5-2's refused second command, previewed — runs to completion exit 0 - (bounded run: a blocking product fails diagnosed) with findings [] and a - non-null plan, command 1 still running, held-baseline snapshot unchanged; - release → command 1 exits 0. `--test-hold`+`--preview` asserted for both - operations and both flag orders: exit 2, error document, stderr, no hold - file created, nothing modified. Traceability "T6.6-3": ["6.6", "14"] - (TEST-SPEC 14's refusal-reason staging record names T6.6-3; map comment - updated); CERTIFICATIONS.md keeps T6.6-3 in the Exclusions — no fixture - scope. No new adapter (decodePreviewReport/decodeFindingsReport/ - decodeErrorDocument are S-5-guarded already; the projection is a local - assertion). Verified: T6.6-3 red-as-diagnosed at the FIRST refusal arm's - real-side form-exact findings decode (the stub emits `{"refused":…}` — - the pre-existing FP-001/FP-007-class product gap), the preview-side gap - behind it (direct probe: `--preview` is exit 2 "unknown flag '--preview'", - the whole 6.6 surface patch-new); satisfiability and teeth proven by - running the registered body via a scratch binding against a conforming - shim (real-product delegation for build/held commands, synthetic 12.7 - refusal/usage/preview answers generated FROM the exported expectation - tables, old→12.7 build-findings translation for the premises, non-UTF-8 - detected from raw /proc/self/cmdline) — green end-to-end through all - arms, the held choreography running against the real product's lock — - and against four deviations: preview-drops-a-finding (red at the - same-findings compare), refused-preview-reports-a-plan (red at the - null-plan assertion), preview-writes-a-file (red at the modifies-nothing - compare), preview-refuses-while-held (red at the runs-while-held exit-0 - assertion). Typecheck/format clean; `npm run test:self` unchanged 4 - planned mid-loop reds (certification-document ×3 → FP-091; S-1's 7 - unmapped keys → stages E/G), S-5 and certification green.] - -- [x] FP-030 — Implement T6.6-4: preview report content — the ten 12.7 edit - classes. [R1 #9; TEST-SPEC §6.6, SPEC 12.7] - All ten edit classes with byte-precise pre-operation ranges, - class-plus-range only, tie-break comparator. - [Done 2026-08-11: T6.6-4 registered in section-6.6.ts, five arms. Expected - `mapping` and `files` are complete exact lists composed from the staged - fixture bytes by locator helpers (unique fragments, container-scoped for - recurring spellings; multi-byte text before every located construct), the - edit lists in 12.7's pinned order — the order itself enforced by the - already-S-5-guarded decodePreviewReport on every document, including the - full comparator's class-bytes tie-break. (a) rename across MDX and TS: - two id-rewrites (attribute's own characters — the construct-spelling - reading, H-4-noted in the module header) plus all four 5.7 occurrence - kinds as reference-rewrites (d entries, MDX embedding braces-included, - TS call callee-through-paren, TS marker sans terminator), with - unaffected-reference/id/specifier controls; (b) section move into an - existing target: origin-deletion one contiguous range over the indented - construct plus leftover indentation and merged-line terminator, - id-rewrites and the moved text's reference rewrites nested inside it - (compose-time containment self-checks), import-removal spanning the - declaration plus its dropped terminator, target-parent-rewrite spanning - the self-closing tag with target-insertion zero-length at the tag's end - (the one stable pre-operation anchor, H-4), and Third.mdx's - import-addition via a latitude slot (exactly one, zero-length, in-file; - offset captured) pinned by the real run on the preview-pinned state - (assertLeavesUnchanged realizes "on a copy"): the rewritten file must - equal pre-op bytes + known reference rewrite + one added-import line - spliced at exactly the previewed offset, fresh binding read from the one - added declaration, then `check` exit 0 (T6.5-7's soundness-guard - precedent); (c) file move: file-relocation spanning the whole file under - its pre-op path beside its own specifier rewrite, importer's specifier - rewrite, chains as controls, root pair in the mapping (T6.5-1 - precedent); (d) created target: exactly one file-creation edit at - {0,0} under the creation path — the staged rewrite NEEDS an import - addition there, so subsumption has teeth — moved text's rewrites in the - origin deletion; (e) the tie-break geometry: top-level `<new-id>` into an - existing file whose rewrite needs an import addition in that same file - (target-insertion at EOF, addition free to coincide). Traceability - "T6.6-4": ["6.6"] (12.7/5.7 carriage context, home coverage at - T12.7-*/T5.7-*); CERTIFICATIONS.md keeps T6.6-4 in the Exclusions. - Verified: red-as-diagnosed at arm (a)'s first preview invocation (exit 2 - "unknown flag '--preview'", the whole 6.6 surface patch-new; section-6.6 - suite 3 failed — T6.6-2/T6.6-3 keep their pre-existing FP-001/FP-028-class - reds); staging probes against the built product show all five premise - builds exit 0 and all five real operations proceeding with clean `check` - (the preview-succeeds premise is sound; the real move's Third.mdx - insertion observed at offset 33, binding `Target`, matching the - reconstruction). Satisfiability and teeth proven by running the - registered body via a scratch suite binding against a conforming shim - (real-product delegation for build/real-move/check, canned 12.7 preview - documents computed from the same fixture strings, arm-b addition offset - 33) — green end-to-end through all five arms — and against five - deviations: span-off (red at (a)'s byte-precise edit equality), - not-subsumed (red at (d)'s exactly-one-edit equality), tie-misorder on - the coinciding zero-length pair (red at the decode's comparator), - preview-writes-a-file (red at (b)'s modifies-nothing compare), - offset-lie (red at (b)'s real-run reconstruction). Typecheck/format - clean; `npm run test:self` unchanged 4 planned mid-loop reds - (certification-document ×3 → FP-091; S-1's 7 unmapped keys → stages - E/G), S-5 and certification green.] - -- [x] FP-031 — Implement T6.6-5: derived-file delta both directions, - record-based. [R1 #10; TEST-SPEC §6.6] - Not presence-based: with graph data deleted, `generated` approaches the - full set and the preview still writes nothing. - [Done 2026-08-11: T6.6-5 registered in section-6.6.ts, four arms on two - workspaces. Expected delta sets are composed from the premise build's own - observed writes (H-4, module header): per source the added plain files - under the 13.1 name shape `DIR/NAME.xspec.<suffix>` (module asserted - present; suffix set observed — implementation latitude) plus the 13.2/7.3 - Markdown destination (`NAME.md` beside the source, outDir unset), a - partition self-check failing diagnosed on any unattributable write; a - not-yet-existing file's paths are the origin's suffix set transposed under - the destination name (13.1: per-source derived paths are defined by the - name shape alone). Workspace 1 (arm (c)'s Mv/Pal/User sources under the - Markdown-emitting config): (1) file-form move preview — `generated` - exactly the destination's module+companions+Markdown, `removed` exactly - the moved file's recorded set; (2) rename preview (`pal`→`pal2`, - cross-file content rewrites) — [] both directions; (3) record-deleted - (T13.3-2's operational definition shared from section-13.3.ts — - isGraphDataKey/assertGraphDataPresent/deleteGraphData now exported): the - same move preview's `generated` equals the FULL post-move regeneration - set (staying sources' on-disk paths included) and `removed` exactly [] - (origin's on-disk paths in neither direction), findings [] (a missing - record is nothing-recorded — never 14.23; the guard rejects the - unavailable encoding), inside a whole-root modifies-nothing compare plus - an explicit graph-data-still-absent sweep. Workspace 2: T6.6-4(d)'s - staging reused verbatim — the created target's transposed - module+companions under `generated` (no Markdown, emission disabled), - `removed` []. Delta decode/order enforcement rides the existing - S-5-guarded decodePreviewReport; no new adapter. Traceability "T6.6-5": - ["6.6"] (13.1–13.3/7.3/12.7 carriage context, home coverage at - T13.1-*/T13.3-*/T7-*/T12.7-*); CERTIFICATIONS.md keeps T6.6-5 in the - Exclusions. Verified: red-as-diagnosed at the first preview invocation - (exit 2 "unknown flag '--preview'"; premise build and staging observation - pass against the current product — probes show its writes match the - partition model exactly: module + impl.d.ts/impl.d.ts.map/impl.js - companions, .md beside source, .xspec/graph.json). Satisfiability and - teeth proven by running the registered body via a scratch suite binding - against a conforming shim (real-product build delegation + sidecar - record, canned 12.7 preview documents; green end-to-end through all four - arms) and five deviations: presence-based generated (red at the - record-deleted full-set equality), presence-based removed (red at removed - []), preview-refreshes-record (red at the modifies-nothing compare), - unavailable-on-missing (red at the unavailable guard), - misplaced-markdown (red at the record-present set equality). - Typecheck/format clean; `npm run test:self` unchanged 4 planned mid-loop - reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys → stages - E/G), S-5 and certification green.] - -- [x] FP-032 — Implement T6.6-6: preview under a corrupt graph record. - [R1 #11; TEST-SPEC §6.6, SPEC 14.23] - Full preview with `delta` explicitly unavailable, a condition-23 finding - (`unreadable-record`, concerned path the graph-data area), exit 1; the - real operation proceeds; a refused preview reports refusal findings - alone. Share the corrupt-record staging with FP-041 (T12.2-2's - unreadable-record arm reuses it). - [Done 2026-08-11: T6.6-6 registered in section-6.6.ts. The corrupt-record - staging lives in the H-3 adapter layer per amended H-3's naming of - T6.6-6 — NEW test/helpers/adapters/record-staging.ts: - `corruptGraphDataShapeBlind` overwrites every product-written plain file - of T13.3-2's operational path set with fixed garbage (files stay present - but readable as no record — not even valid UTF-8), never creates a path, - and fails loudly with nothing modified on a missing area, an empty set, - or a non-plain-file occupant (SPEC 13.4); the operational predicate - isGraphDataKey moved there (one home), section-13.3.ts re-exporting it - for its existing importers (behavior-preserving: section-13.3 unchanged - 2 failed / 2 passed). FP-041/FP-044/FP-072 ("T6.6-6's staging") import - `corruptGraphDataShapeBlind` from the adapters index. One workspace - under the Markdown-emitting config, latitude-free section move - (self-contained subtree, no outside reference to a moved node → no - import edits, SPEC 6.5's one preview latitude): after the premise build - (record presence asserted), the intact-record reference preview runs in - its own modifies-nothing compare — exit 0, findings [], delta a plain - value, mapping pinned to the exact two-pair full-1.5-identity - expectation, files paths pinned to [Origin, Target] — then the record is - garbled and BOTH corrupt-state previews run inside ONE whole-root - compare (the corrupt state persists byte for byte): the move preview - exits 1 with exactly one 14.23 finding (stable code unreadable-record; - concerned path ".xspec" per 11.6; locations exactly [] — a - path-concerned condition is unlocated, T12.7-1's reading, module header) - and the full plan — mapping the exact expectation, files deep-equal to - the intact run (the "emitted in full" operationalization, sound because - the plan is latitude-free), delta the unavailability marker, never an - empty-record read; the refused preview (identity-unchanged rename) - reports exactly one refused-identity-unchanged finding — never a 14.23 - beside — with mapping/files/delta all null. Then the real move on the - same state: exit 0, applied mapping (T6.4-1's adapter) == the previewed - mapping, `check` exit 0 (the finishing regeneration replaced the corrupt - record, T12.2-2's protocol). S-5 gains three record-staging guards - (garbles-every-file positive control with durables/structure untouched - and the not-UTF-8 premise; nothing-to-corrupt rejections; non-plain-file - rejections, files untouched). Traceability "T6.6-6": ["6.6", "14"] - (TEST-SPEC 14 names T6.6-6 in 14.23's primary record); CERTIFICATIONS.md - keeps T6.6-6 in the Exclusions (shape-blind 14.23 stagings are - self-controlled — the condition-23 finding is the in-test reachability - control), no fixture change. Verified: red-as-diagnosed at the - intact-record reference preview (exit 2 "unknown flag '--preview'", the - whole 6.6 surface patch-new) with build and record-presence premises - green; staging soundness probed against the built product — build exit 0 - (record: .xspec/graph.json), check clean, and on the garbled record the - REAL move exits 0 with `check` clean afterward (the pre-patch machinery - already replaces the record) while the identity-unchanged rename refuses - exit 1 (old-shape document) leaving the garbage untouched; - satisfiability and teeth proven by running the registered body via a - scratch binding against a conforming shim (real-product delegation for - build/check/real move + synthetic 12.7 preview/applied-mapping answers - computed from the staged bytes, corruption detected shim-side) — green - end-to-end, the real product performing the actual move on the corrupt - record — and against six deviations: empty-record-read (red at the - exit-1 assertion), refused-consults-record (red at the - refusal-findings-alone count), preview-repairs (red at the - modifies-nothing compare), located-finding (red at the - no-path-inside-the-area locations assertion), incomplete-files (red at - the `files` complete equality), real-refuses (red at the - real-operation-proceeds exit). Typecheck/format clean; section-6.6 suite - 5 failed as diagnosed (T6.6-2..-5 keep their pre-existing reds); `npm - run test:self` unchanged 4 planned mid-loop reds (certification-document - ×3 → FP-091; S-1's 7 unmapped keys → stages G), S-5 (73 tests) and - certification green.] - -## Stage F — §§10–14 missing arms - -- [x] FP-033 — T10.1-4: stage the "malformed recorded decompositions" - corrupt-state arm. [R2 #20; TEST-SPEC §10.1] - `test/suite/registry/section-10.1.ts`. - [Done 2026-08-11: new arm between the creation-parameters and - garbage-bytes states — build, create an audit session, select the - subtree-coherence item scoped at `specs/A.mdx#a` (the one section with a - child) from the decoded status report, have the PRODUCT perform `review - split` on it (exit 0; post-split premise pins the original id gone — - removed and never reused, SPEC 10.7 — so a decomposition is genuinely - recorded durably), capture a current item id, then garble the recorded - decompositions via the new H-3 transformation - `stageGarbleDecompositions` (session-staging.ts: SESSION_SHAPE gains - `decompositionsKey`; the creation-parameters garble refactored into a - shared `garbleRecordedMember` structural-type-flip — shape-aware, - value-blind, fail-loud, staged file stays one JSON document) and run the - full `assertCorruptSessionContract` (six naming subcommands exit 1 + - /corrupt/i + modifies-nothing; `list` corrupt-in-place exit 1; `check` - 14.21). S-5 gains the decompositions guards (type-flip positive control - both directions with the rest of the session untouched; stays-parseable - loop entry; no-member rejection): 73 → 74 tests, green. Soundness proven - by direct probes against the built product (the suite arm is unreached: - T10.1-4 stays red-as-diagnosed at the FIRST adapter state's `check - --json` form-exact findings decode, the pre-existing FP-001-class - product gap — "expected no member \"condition\""): split records - `{"kind","scope"}` under `decompositions`; on the garbled state all six - naming subcommands exit 1 mentioning corrupt, `list --json` exit 1 with - `[{cor, corrupt: true}]`, `check --json` exit 1 with a 14.21 finding - (old shape), and the whole tree is byte-unchanged across every contract - command. Section-10.1 unchanged 3 failed / 1 passed (T10.1-2/-3 keep - their pre-existing FP-002-class exit-2 error-document reds; T10.1-1 - green). Typecheck/format clean; `npm run test:self` unchanged 4 planned - mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped - keys → stage G), S-5 and certification green. Traceability unchanged - (["10.1", "14"] — 14.21's primary record already rides "14"); T10.1-4 - in no certification scope.] - -- [x] FP-034 — T10.4-2: add the non-scope presence recordings. [R2 #21; - TEST-SPEC §10.4] - `test/suite/registry/section-10.4.ts`: context arm - (`metadata-consistency`, removed target `T` re-authored) and origin arm - (`dependency-consistency`, origin `D` deleted with hashes/context set - unchanged). - [Done 2026-08-11: both arms appended to T10.4-2 (title extended, timeout - 240s → 360s), each its own git-baselined workspace. Context arm: baseline - `D` bears `d={"tt"}` to sibling `T` in one file; one edit removes the - reference and deletes T's section; `review create --base` derives exactly - {metadata-consistency D, subtree-coherence root} (kindScopeSet-pinned; the - deleted T skipped for its changed ancestor), D's item's context exactly - [{T, absent}] via new `assertSoleContext`; resolve no-change; re-author T - → `invalidated` with context presented [{T, present}]. Origin arm: - X→T→D across three files (D beside sibling `e`); the d-list edit on D - (gains `d={"e"}`) derives exactly {metadata-consistency D, - dependency-consistency T, dependency-consistency X} — no `changed` node — - with X's item context [{T, present}] and origin [{D, after-present}] - (`assertSoleOrigin`); resolve no-change; one edit removes T's `d={O.d}` - (import kept — an unused import is valid and records no edges, SPEC 2.1) - and deletes D's section → `invalidated`, post-show pinning scope X - present, context T present, origin D after-side absent. Arm purity is - asserted in-test via `assertHashPremises` (extracted from - runSensitivityArms' inline loops, now fail-loud on a missing capture): - context arm — D.metadataHash the item's only relevant hash, unchanged - across the re-authoring, D still metadata-changed vs baseline (5.5's iff - pins the d-set, so the generated context set stays {T}); origin arm — - X.ownHash/X.metadataHash/T.subtreeHash unchanged (d-prop edits touch no - own content, 1.6/5.5) while T.metadataHash changes and T.effectiveHash - stays changed vs baseline (context set stays {T}). NOT red against this - repo's product: direct CLI probes of both stagings returned exactly the - expected item sets, hash brackets, presence flips, and invalidations (the - product records presence for every scope/context/origin node — its - `current` holds D presence-only with hashes {}), so the suite pass - (section-10.4 5/5) is genuine; teeth live in the purity brackets plus the - presence-flip assertion — a product recording presence for scope nodes - alone (or scope+context, origin arm) has no other divergence channel and - reports no-change where the arms demand invalidated. Typecheck/format - clean; `npm run test:self` unchanged 4 planned mid-loop reds - (certification-document ×3 → FP-091; S-1's 7 unmapped keys → stage G), - S-5 and certification green. Traceability unchanged (["10.4"]; 1.6/5.5/ - 5.6/10.5 are context with home coverage elsewhere; no numbered condition - asserted); T10.4-2 in no certification scope.] - -- [x] FP-035 — T10.7-7: assert payload source ranges for EVERY present - node. [R2 #22; TEST-SPEC §10.7] - Currently asserted "for present requirement nodes" only - (section-10.7-i/ii — locate the T10.7-7 body): cover every present - node's, requirement node and present code location alike; none for - absent nodes. - [Done 2026-08-13: the payload arm (section-10.7-ii.ts) now runs on - SPECS_CODE_CONFIG with a v1 code source whose named unit `nextUnit` - references a.k (SPEC 4.6 attribution behind a multi-byte prefix), and - covers the range clause on every payload position: ORIGIN nodes — the - H-3 review adapter's OriginEntry gains a node-level `sourceRange` - (SPEC 10.7 presents every scope/context/origin node under its current - identity and presence with its range when present; the after side IS the - current presence, so the decode forbids a range on an after-absent entry) - asserted via new `assertOriginRange` on every origin entry the test - reads, and carried through `payloadProjection` so the one-payload-rule - compares include it; CODE LOCATIONS — the walk (resolve no-change never - re-derives) reaches the code-impact item after the two spec items and - byte-asserts its present scope's named-unit construct range against - precomputed offsets, no text (SPEC 1.7's review-payload half), context - a.k subtree text + range, origin a.k pair + range; ABSENT nodes — the - code file is then deleted (build exit 0, zero-source code group valid), - and the SAME item id presents its scope absent: identity and absence - alone, no text, no range (assertAbsentState), context/origin still - present and ranged; the parent-consistency middle item's scope (own - text + range) asserted in passing. S-5 gains the origin-range guards - (present entry's range decoded and surfaced; range on a currently-absent - origin node rejected), 74/74. Verified: T10.7-7 turned falsely-green → - red-as-diagnosed at exactly the first new assertion — the first item's - origin-entry range, undefined against a.k's `query node` range (the - product emits no origin range and no code-impact scope range: probes - confirmed the whole walk, item order [sc a.k, pc a, ci], presence flip - after deletion, and every asserted text/range value against the built - product, so downstream arms are sound and unreached until the product - presents ranges). Traceability unchanged (["10.7"] — T1.7-1's entry - names T10.7-7 as field-presence coverage like T11-1/T12.4-1, which - carry no "1.7"; the range-key homes stay T1.7-*/T10.7-12). `npm run - test:self`: unchanged 4 planned mid-loop reds (certification-document - ×3 → FP-091; S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), - S-5 and certification green; T10.7-7 in no certification scope. - Typecheck/format clean.] - -- [x] FP-036 — T11-6: add the wrong-kind / unknown-unit / disambiguator - arms. [R2 #23; TEST-SPEC §11.1] - `test/suite/registry/section-11.ts`: (a) `query node`/`show` on a - code-group `path`/`path#unit` → exit 2; (b) `query edges --from/--to` and - `reachable --from/--to` with `#unspelled-unit` → exit 2; (c) out-of-range - `@2`; (d) `@1` unknown at every occurrence count, staged at one and at - two occurrences. - [Done 2026-08-13: twelve arms appended to T11-6's existing workspace (no - new files — its code file already stages chain `Box` once and `Box.v` - twice, getter/setter). (a) `query node` and `show` each given - `src/code.ts` and `src/code.ts#Box.v` → 4 arms via `expectUsageError` - (exit 2 exactly + single 12.7 error document under `--json`, the FP-002 - protocol). (b) `#ghost` (no unit spells it) in all four graph-node flag - positions — `edges --from`/`--to`, `reachable --from`/`--to` (valid - `specs/S.mdx#s1` as the counterpart flag). (c)+(d) ride `query edges - --from` (the entry's primary position) behind a new premise control - pinning the once-occurring chain as a spelled unit — `--from - src/code.ts#Box` exit 0 with an EMPTY edge list (the check is parse-local - over named units per TEST-SPEC T11-6, so a spelled unit is never unknown; - empty-at-exit-0 is the module's established valid-identity/no-matching- - edges operationalization), keeping the @-arms sharp (they fail on the - disambiguator, never on an unknown chain): `Box@2` out-of-range, `Box@1` - (once), `Box.v@1` (twice — the discriminating arm: a product resolving - `@1` to the first occurrence would answer the getter's `embeds` edge at - exit 0, an edgeless-graph-node product an empty list at exit 0; the exact - exit-2 assertion forbids both). Title extended; traceability unchanged - (["11.1"] — 12.0/12.4/4.6 are context with home coverage at T12.0-*/ - T12.4-*/T1.7-2; no numbered condition asserted); T11-6's unknown-unit and - `@N` arms are named in CERTIFICATIONS.md's Exclusions (shared machinery), - no fixture scope. Verified red-as-diagnosed by guarded solo executions of - every new arm against the built product: the premise control passes - (probe: `{"edges":[]}` exit 0; `Box.v`/`Box.v@2` resolve to getter embeds - / setter references), and all 11 usage-error arms pass their exact exit-2 - assertion (the product already classifies every one correctly per its - stderr) and fail exactly at the error-document decode ("stdout is empty" - — the FP-002-class product gap, the same first-arm failure T11-6 already - shows at its unknown-path arm, so the new arms are unreached in the suite - until that closes). Section-11 unchanged 4 failed / 3 passed (T11-1/-3/-7 - green); typecheck/format clean; `npm run test:self` unchanged 4 planned - mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys - {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] - -- [x] FP-037 — T12.0-1/-3/-4: extend the shared command sweep with the new - surfaces. [R2 #24; TEST-SPEC §12.0] - `test/suite/registry/section-12.0-i.ts` `SWEEP_STEPS` (~line 235): add - `occurrences`, `view`, `at`, `inventory`, `version`. T12.0-1 must also - assert the JSON-only surfaces emit the same single document with the - `--json` flag as without. - [Done 2026-08-13: SWEEP_STEPS gains the five surfaces after the query - family (mutations still last) — `occurrences`, `view`, `at specs/A.mdx 0`, - `inventory`, `version`, each a clean-domain read over the valid story - workspace (complete finding-free answer, exit 0; 11.2/11.6/12.6) — so all - three sweep users drive them with `--json` (T12.0-1), with a cwd-relative - `--config` (T12.0-3; accepted-not-consulted on version, 12.6), and under - T12.0-4's doubled-`--config` exit-2 protocol. T12.0-1's parity arm: - SweepStep gains a `jsonOnly` marker on the 12 JSON-only steps (the six - query subcommands, the five new surfaces, review export — SPEC 11 - preamble, 12.6, 10.7), and under assertJsonOnlyParity (T12.0-1 only) each - such step reruns without `--json` asserting exit 0, a single JSON document - as the entire stdout (H-5's JSON-only clause), and stdout byte-identical - to the flagged run's — "the same single document" operationalized as a - product-to-itself byte compare (H-4; module header documents the reading: - a single document as the surface's ONLY output form makes the flag inert). - Verified: T12.0-1 turned green → red-as-diagnosed exactly at - `occurrences --json` (exit 2 "unknown command" — the new-surface product - gap), the six query parity arms passing before it (probe: the product - already emits byte-identical stdout with/without the flag on query node - and review export, so the parity assertion is satisfiable); T12.0-3 now - red at the same step of its sweep (previously a later FP-002-class arm); - T12.0-4 keeps its pre-existing first failure (doubled-`--config` - error-document decode on build), new steps unreached there; - T12.0-2/-5/-6 unchanged. Typecheck/format clean; traceability unchanged - (all three ["12.0"]; 10.7/11/12.6 are context with home coverage at stage - G); T12.0-1/-3/-4 in no certification scope. `npm run test:self` - unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; - S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and - certification green.] - -- [x] FP-038 — T12.0-9: add the new exit-partition representatives. - [R2 #25; TEST-SPEC §12.0] - Exit 0: `version`, clean `occurrences`/`view`/`at`, `inventory`, - successful previews. Exit 1: refused previews and answers carrying - findings/explicitly-unavailable data (emitted in full). Exit 2: - wrong-kind operands and the `occurrences --to` malformed-only exception. - [Done 2026-08-13 (`test/suite/registry/section-12.0-ii.ts`): exit-0 rows - on the story workspace after its exit-0 build (the clean-domain premise) — - `version`, `occurrences`, `occurrences --to specs/NoSuch.mdx#nope` (the - exception's accepted side: well-formed unknown selects the empty set, - exit 0), `view`, `at specs/A.mdx 0`, `inventory`, and the successful - rename preview `alpha → gamma`; exit-1 rows — refused rename and move - previews beside their real refused twins (SPEC 6.6 refusal equivalence on - the same state), and `occurrences`/`view`/`at` answering on the findings - workspace (id-less `specs/U.mdx` staged beside the unresolved-reference - file: findings 14.5 + 14.1, the section's identity explicitly - unavailable), each such row asserting emitted-in-full at H-5's protocol - grain via the new `emitsAnswer` row marker (stdout exactly one JSON - document — T11.2-5 pins the full-answer contract); exit-2 rows — - `occurrences --to a#b..c` (the exception's usage-error side: an empty - segment), and a new wrong-kind workspace (SPEC_AND_CODE_CONFIG, - reference-free `src/app.ts`) with `show src/app.ts` (code source where a - requirement-node identity is required) and `view src/app.ts` (where a - spec source is required), discovery pinned by the premise probe `query - edges --from src/app.ts` → empty answer exit 0 (an unconfigured path - would be unknown, exit 2). Title updated to the refreshed TEST-SPEC - entry; module-header operationalization note extended (premise probes; - emitted-in-full grain; previews exit-code-only, T6.6-* owning - modifies-nothing/content). Traceability unchanged (["12.0"] — 11.2/11.6/ - 6.6/12.6 are context with home coverage at stages G/H); no certification - scope. Verified: T12.0-9 turned green → red-as-diagnosed exactly at the - first new row (`version` → exit 2 "unknown command", the new-surface - product gap); later arms unreached in the suite, proven sound by direct - probes against the built product — wrong-kind arm: build exit 0, edges - premise `{"edges":[]}` exit 0, `show src/app.ts` exit 2 with the - wrong-kind diagnosis (genuinely green today); findings arm: build/check - exit 1 with exactly the staged 14.5 + 14.1; all three `--preview` rows - exit 2 "unknown flag", `occurrences`/`view`/`at`/`inventory`/`version` - exit 2 "unknown command" — every 0/1-class row fails as diagnosed until - the product grows the surfaces, and the two coincidental exit-2 matches - (`view src/app.ts`, malformed `--to`) are toothed jointly by the same - surfaces' 0/1-class rows plus T11.4-2/T11.3-3's full semantics. - Section-12.0-ii now 4 passed / 1 failed (T12.0-9 the one red; - T12.0-7/-8/-11/-12 stay green); typecheck/format clean; `npm run - test:self` unchanged 4 planned mid-loop reds (certification-document ×3 → - FP-091; S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and - certification green.] - -- [x] FP-039 — T12.0-10: implement the precedence arms (test stops being - alias-only). [R2 #26; TEST-SPEC §12.0] - Add: gated-read usage-error precedence on a failing workspace with the - valid-twin comparison; the `show <unparseable>#id` masking arm; the - past-the-gate corrupt-session `resolve` arm; the within-class-2 - no-configuration arms; the configuration-error-precedes arm. Update its - H-7 entry (see FP-003's note). - [Done 2026-08-13 (`test/suite/registry/section-12.0-ii.ts`): T12.0-10 - registered with the four own-arm groups; the rename/move/baseline arms - stay cross-references to T6.4-4/T6.5-5/T6.3-4 (module header rewritten; - traceability map comment updated per FP-003's note; entry - `"T12.0-10": ["12.0"]` — the FP-016 precedent: in no TEST-SPEC 14 - staging record, so no "14"). Gated reads: a failing workspace (valid - A.mdx + src/app.ts with named unit `known` + unparseable Broken.mdx as - the premise-pinned single 14.20) and a valid twin identical in - everything the six checks consult; rows unknown-profile, code-group - `--group`, unknown-session, `show A.mdx#unspelled`, `query node - src/app.ts`, `edges --from src/app.ts#unspelled` each exit 2 with - stdout exactly the 12.7 error document ("reports no validation - findings" at H-5's protocol grain), stderr nonempty, failing/twin - documents byte-identical ("the same exit-2 errors", H-4 - product-to-itself; stderr wording left free per the module-header - operationalization note); five twin controls pin every name as - resolving. Masking: `show Broken.mdx#broken` exit 1, form-exact - findings report = exactly the one 14.20 located in Broken.mdx (the file - contains `id="broken"`, failing scrape-and-answer and unknown-id - products both ways). Past the gate: product-written audit session - corrupted by garbage overwrite; premise `resolve corrupt <no-such-item>` - exit 2 pre-corruption (T10.7-10's contract), the same argv exit 1 with - /corrupt/i post-corruption (T10.1-4's operationalization). Within class - 2: unknown command, `ids --json --json`, `show a#b#c` on invalid-config - and missing-config workspaces — exit 2, plain usage error (code and - path null, 12.7), byte-identical documents across the two states — then - `coverage no-such-profile` under invalid configuration via - expectConfigurationError (14.14, not the unknown profile). Verified: - suite file 12.0-ii 2 failed / 4 passed (T12.0-9 the pre-existing red; - T12.0-10 red-as-diagnosed at the failing-premise form-exact findings - decode — the FP-001-class gap); later arms unreached in the suite, - proven sound by direct probes against the built product: all five twin - controls exit 0; failing rows 1–3 already exit 2 (red only at the - FP-002-class empty exit-2 stdout) while rows 4–6 expose a real - precedence gap (the product gates the parse-local identity/kind/unit - checks — exit 1 where the arms demand 2); twin rows exit 2 with the - right classifications; masking exits 1 with the gated report (old - shape); the corrupt arm runs genuinely green end to end; syntax rows - 1–2 already precede configuration (identical stderr across states, red - at the missing error document) while `show a#b#c` exposes the second - real gap (the product reports the configuration error, not the - malformed value); config-error precedence already holds (red at the - empty stdout alone). Typecheck/format clean; `npm run test:self` - unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; - S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and - certification green. No certification scope: T12.0-10 sits in - CERTIFICATIONS.md's Exclusions only, which name exactly the in-test - valid-twin comparison this body carries.] - -- [x] FP-040 — T12.0-12: extend the git-less sweep. [R2 #27; TEST-SPEC - §12.0] - Add `occurrences`, `view`, `at`, `inventory`, `version`, and the - `--preview` invocations of `rename`/`move`. - [Done 2026-08-13: seven steps joined GITLESS_STEPS - (section-12.0-ii.ts) — `occurrences`, bare `view`, `at specs/A.mdx 0`, - `inventory`, `version` after the query steps (clean-domain finding-free - answers per SPEC 11.2 and the workspace-independent 12.6 report; each a - JSON-only surface accepting the sweep's uniform `--json` per T12.0-1), - plus `rename … --preview` and `move … --preview` each directly before - its real operation with identical operands (a preview succeeds exactly - when the real operation would proceed and modifies nothing, SPEC 6.6, - so each real step still runs at the state it saw before). Title - extended to the full T12.0-12 surface. Traceability unchanged - (["preamble", "12.0"]; 11.2–11.6/12.6/6.6 are carriage context with - home coverage at stage G's §11/§12.6 tests and T6.6-*; no numbered - condition asserted); no certification scope (T12.0-12 appears nowhere - in CERTIFICATIONS.md). Verified: T12.0-12 turned green → - red-as-diagnosed at the first new step (`occurrences --json` exit 2 - "unknown command" — the patch-new surface); section-12.0-ii went 2 - failed/4 passed → 3 failed/3 passed (T12.0-9/T12.0-10 keep their - pre-existing reds); later steps unreached in the suite, proven sound by - direct probes against the built product in sweep order on a replica - staging: the five reads each exit 2 unknown-command, both previews exit - 2 unknown-flag `--preview` (each failure exactly the diagnosed product - gap), while the premise build and the real rename/move at the probed - states exit 0 — every new argv valid at its insertion point. - Typecheck/format clean; `npm run test:self` unchanged 4 planned - mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped - keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] - -- [x] FP-041 — T12.2-2: add occupant-kind staleness and graph-data - unit-form arms. [R2 #28; TEST-SPEC §12.2] - `test/suite/registry/section-12.1-12.2.ts`: occupant kinds (symlink to a - byte-identical target; directory); graph-data unit forms — missing - (isolated: exactly one condition-10 unit-form finding, no per-file - finding), mismatch (isolated via refresh-then-revert), unreadable-record - (FP-032's staging → unit form alone; `build` replaces; `check` clean; - `inventory` recovers). - [Done 2026-08-13: occupant arms landed inside the per-file staleness - family between the hand-deleted and edited-source arms — the module path - occupied by a symlink to a root-level byte-identical copy (identity - through the link pinned as the staging premise: only occupant-kind - judgment can find it, the arm a link-following product wrongly passes) - and by a directory, each asserted via the existing - exactly-one-14.10-naming-the-module protocol, cleaned up between arms. - Two new families follow (the TEST-SPEC list reading: unit forms are their - own families): graph-data unit form — missing via section-13.3's - `deleteGraphData` (T13.3-2's operational definition) on the freshly - built, otherwise clean workspace; mismatch via build → text-only edit → - one refreshing read (`ids`, exit 0) → revert, the refresh premise pinned - by whole graph-data byte comparison before/after (graph data carries all - four hashes, 13.3; H-4 self-comparison carve-out, content otherwise - unread) — and unreadable record via `corruptGraphDataShapeBlind` - (FP-032's H-3 record-staging adapter), then `build` exit 0, `check` exit - 0, and `inventory` decoded through the new scoped forms.ts - `decodeInventoryRecordedDatum` (the `recorded` member alone as the - FP-001 three-state datum; S-5 DECODERS guards added — marker/empty-list/ - byte-form positives, absent-member/non-marker/fabricated-value - rejections) asserting a plain list naming specs/A.xspec.ts. Each - unit-form state asserts `assertSingleUnitFormFinding`: exactly one - condition-10 finding (no per-file finding beside, never the mismatch - form beside), concerned path exactly `.xspec` (GRAPH_DATA_AREA_PATH), - locations [], message instructing rebuilding — the T6.6-6 - operationalization, recorded in the module header. Title extended; - timeout 240s → 300s; traceability unchanged (["12.2", "14"]; - 13.3/13.4/11.6 are carriage context with home coverage at - T13.3-*/T13.4-*/T11.6-*); no certification scope (T12.2-2 sits in - CERTIFICATIONS.md prose only). Verified: suite file unchanged 3 failed / - 3 passed — T12.2-2 red-as-diagnosed at family 1's pre-existing - FP-001-class form-exact decode, new arms suite-unreached — with - soundness proven by scratch solo executions of the exact arm code plus - direct CLI probes against the built product: both occupant stagings run - clean and the product already judges occupants itself (exit 1, exactly - one old-shape 14.10 naming the module both ways — red only at the form - gap); missing/mismatch/unreadable each exit 1 with exactly one old-shape - condition-10 finding whose concerned file is `.xspec/graph.json` — a - path INSIDE the area where the arms demand the area path `.xspec` - itself, a real value gap beyond the form gap; the mismatch premise held - (refresh rewrote graph data); build-over-corrupt exit 0, check-after - clean, and `inventory` exit 2 unknown command (the patch-new surface — - the recovery assertion's diagnosed red at runJson's exit-0 gate). - Typecheck/format clean; `npm run test:self` unchanged 4 planned mid-loop - reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys - {11.2–11.6, 12.6, 12.7} → stage G), S-5 green including the new - inventory-recorded-datum guards, certification green.] - -- [x] FP-042 — T12.2-3: pin never-refreshes per state. [R2 #29; TEST-SPEC - §12.2] - Missing-arm state (graph data stays absent), isolated mismatch state, - combined per-file+unit state. - [Done 2026-08-13: T12.2-3 rebuilt as the three per-state pins on one - workspace (section-12.1-12.2.ts) — (1) T12.2-2's missing-arm staging - (fresh build, `deleteGraphData`), absence pinned as a staging premise so - the whole-root compare-around positively proves graph data STAYS absent - (`check` never rewrites it, where every refreshing read would — - T13.3-2's cite); (2) the isolated mismatch state via T12.2-2's - refresh-then-revert staging with both premise pins (graph data present - after rebuild; the refresh rewrote it — H-4 self-comparison carve-out); - (3) the edited-source-without-rebuild state, per-file + unit staleness - together (generated files compile the old source; graph data carries all - four hashes — the state 2 premise pin shows this edit class rewrites it). - Each state runs plain `check` (exit 1) and `check --json` inside one - whole-root `assertLeavesUnchanged`; the staleness report is asserted per - state — exactly one unit-form condition-10 finding (states 1–2, - T12.2-2's shared helper) / `assertAllStale` (state 3) — so each state's - reachability is positively established, never assumed. No new helpers or - adapters (S-5 untouched); traceability unchanged (["12.2"] — the - T13.3-2/T13.3-3 precedent: the condition assertions establish the state, - 14.10's primary record is T12.2-2's). Verified: suite file unchanged 3 - failed / 3 passed — T12.2-3 red-as-diagnosed at state 1's FP-001-class - form-exact decode, later arms suite-unreached — with soundness proven by - a scratch probe of the exact state sequence against the built product: - every staging premise holds (build/ids exit 0, delete leaves zero graph - entries, the refresh rewrites graph data), all three states exit 1 on - both `check` forms reporting only old-shape condition-10 findings - (concerned file `.xspec/graph.json`, INSIDE the area where the unit form - demands `.xspec` itself — the FP-041-diagnosed value gap behind the form - gap), and the whole-root diff around the invocations is empty in every - state — the byte pins themselves already hold, graph data staying absent - in state 1. Typecheck/format clean; `npm run test:self` unchanged 4 - planned mid-loop reds (certification-document ×3 → FP-091; S-1's 7 - unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification - green.] - -- [x] FP-043 — T12.5-1: extend the dispatch sweep. [R2 #30; TEST-SPEC - §12.5] - `test/suite/registry/section-12.3-12.5.ts`: add `occurrences`, `view`, - `at`, `inventory`. - [Done 2026-08-13: four dispatch arms appended between the `query nodes` - arm and the unknown-command arms (reads before the mutating rename/move), - each a minimal exit-0 probe on the existing fixture, invoked bare — the - §11 surfaces are JSON-only, one document with or without `--json` (SPEC - 11): `occurrences` decoded via the existing form-exact - `decodeOccurrencesReport` and asserted exactly `{findings: [], - occurrences: []}` (no reference spelling staged — the definitive empty - enumeration, SPEC 11.3); `view` via new scoped forms.ts - `decodeViewFilesReport` (top level `{"findings","views"}` exact, each - per-file wrapper's five members exact and present, `file` decoded, - path-byte order strict — a set; `root`/`imports`/`occurrences`/`comments` - deliberately unread, T11.4-*'s subject) asserted `{findings: [], files: - ["specs/D.mdx"]}` — the whole-domain request; `at specs/D.mdx 20` via new - forms.ts `decodeAtReport` (the full 12.7 at form: - `{"findings","resolution"}`, resolution and section-identity datums - value-or-marker never null, occurrence record-or-null) asserted exactly — - section identity specs/D.mdx#anchor, construct range {0,69}, occurrence - null (offset 20 inside "Anchor line.": inside anchor 0..69, outside - anchor.sub 30..64; offsets computed independently and matching `query - node`'s probed sourceRanges); `inventory` via the existing scoped - `decodeInventoryRecordedDatum` — recorded a plain list naming - specs/D.xspec.ts (T12.2-2's precedent; membership, companions - unpinned). Title, module header, and the T12.5-1 operationalization note - updated to the ten-command list; S-5 gains both new decoders' DECODERS - guards (positives incl. resolution/identity unavailability, byte-form - view file, containing-occurrence record, empty views; rejections: - absent/null members, widened markers, extra members at every level, - out-of-order and duplicate per-file views, null section identity). - Traceability unchanged (["12.5"]; 11.2–11.6 are dispatch context with - home coverage at stage G's T11.*-*). Verified: suite file unchanged 2 - failed / 2 passed — T12.5-1 red-as-diagnosed now at the first new arm - (`occurrences` exits 2 "unknown command": the whole §11 surface is - patch-new, so all four arms hit the same dispatch gap; T12.3-1 keeps its - pre-existing FP-002-class red), later arms suite-unreached with decode - satisfiability proven by S-5's positive controls over the same document - shapes and the fixture byte facts probed directly (build generates - specs/D.xspec.ts; D.mdx spells no reference; one discovered spec - source). Typecheck/format clean; `npm run test:self` unchanged 4 planned - mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys - {11.2–11.6, 12.6, 12.7} → stage G), S-5 (80) and certification green.] - -- [x] FP-044 — T13.3-1/T13.3-2: extend read sweeps; add the - record-discipline arm. [R2 #31; TEST-SPEC §13.3] - `test/suite/registry/section-13.3.ts`: sweeps gain `occurrences`, `view`, - `at`. T13.3-2 gains: shape-blind record corruption → refreshing reads - answer finding-free exit 0, state neither read nor replaced, `inventory` - reports `recorded` unavailable until `build`. - [Done 2026-08-13: T13.3-1's serving sweep gains the three probes inside - its leaves-unchanged block — `occurrences` (the complete record set at - identity level via new module helpers `occurrenceIdentitySummaries`/ - `assertAtAnswer`: the one d occurrence alpha→beta, finding-free), `view` - (scoped decode, exactly {findings [], files [specs/A.mdx]}), `at` offset - 30 → alpha with occurrence null — the identity/membership altitude - documented in the module header (byte-precise spans and per-file view - content are T11.3-*/T11.4-*/T11.5-*'s home; ranges still form-validated - by the S-5-guarded decoders). T13.3-2 arm A gains the same three - (definitive empty enumeration; both files; offset 20 → alpha), each - followed by the existing rewritten-exactly-as-build w0 compare; arm B's - edit now stages `<S id="added" d={["alpha"]}>` — the edited sources' - one occurrence — so `occurrences` (added→alpha) and `coverage` - (uncovered exactly [added, beta], covered exactly alpha via - [added, alpha]) answer values the stale pre-edit graph cannot produce, - `at` 75 resolves the edited-source-only identity added, `view` answers - the whole domain finding-free. New record-discipline arm on its own - workspace (alpha d=["beta"]+beta, git baseline, audit session): - `corruptGraphDataShapeBlind` (T6.6-6's staging, the H-3 record-staging - adapter), then all nine refreshing reads, each asserting the exit-0 - finding-free contentful answer, then `inventory` exit 1 with `recorded` - still explicitly unavailable (persistence per read — neither read, - repaired, nor replaced; the finding's full form stays T11.6-4's home), - then outside-graph-data byte-identity against the post-corruption - snapshot (graph-data bytes deliberately unpinned: the record's location - inside the area is unenumerated, so a conforming refresh may rewrite - non-record files around the preserved record state — module header - note); after the sweep, `build` exit 0 → `inventory` exit 0 with - `recorded` a plain list naming specs/A.xspec.ts. Traceability: T13.3-2 - gains "14" (TEST-SPEC 14's per-condition record lists it under 14.23; - the map's construction note carries that record at passage granularity). - Verified: typecheck/format clean; section-13.3 went 2 failed/2 passed → - 3 failed/1 passed — T13.3-1 falsely-green → red-as-diagnosed at its - `occurrences` probe and T13.3-2's red moved earlier to arm A's - `occurrences` probe (both exit 2 "unknown command 'occurrences'" — the - whole §11 surface is patch-new, the FP-043-diagnosed dispatch gap; - T13.3-3 keeps its FP-001-class red, T13.3-4 green). Suite-unreached - arms probe-verified directly against the built product: arm B's - review-status invalidation table, the V1 coverage/ids/show answers - (added's construct range 41..94 confirmed), and the deleted-source - sub-arm end-to-end under V1 (B's module survives the read, `check` - reports 14.10 ×4 on B's derived files, the final build removes them, A - survives); record arm: `ids` on the corrupt record answers exit 0 while - the current product rewrites the whole record — exactly the - replace-the-state behavior the arm discriminates — and `inventory` - exits 2 (unknown), the arm's first diagnosed failure if reached; - at-offsets computed mechanically (30/20/75 inside alpha/alpha/added, - outside the occurrence spans 18..24/none/59..66). `npm run test:self`: - unchanged 4 planned mid-loop reds (certification-document ×3 → FP-091; - S-1's 7 unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and - certification green (CONF-CORE 9/9, CONF-VALID 12/12, CONF-MD 8/8, - CONF-DISC 3/3; violators failing exactly as certified). No new decoder - (S-5 unchanged); T13.3-1/-2 in no certification scope.] - -- [x] FP-045 — T13.3-3: add the whole-gate arms and the never-gated - contrast. [R2 #32; TEST-SPEC §13.3] - Whole-gate: garbage journal line (14.13) and obstructed write path - (14.22) — each gated read reports it, exits 1, answers nothing, modifies - nothing. Never-gated contrast: `occurrences`/`view`/`at` answering per - SPEC 11.2 and `inventory` answering, on the same workspaces. - [Done 2026-08-13: two new workspaces in T13.3-3 (section-13.3.ts). - Journal arm: one legitimate journaled rename puts the garbage on line 2 - (T6.1-3's staging, so line naming has teeth; the 14.13 line-naming check - mirrors T6.1-3's H-4 operationalization), and the baseline commit for - `impact --base` is taken WITH the garbage line in place: 12.0 orders - baseline resolution before the gate, and per 6.3 it succeeds there — the - baseline journal is byte-identical to the current journal (append-only - prefix invariant), zero entries replay (T6.3-4's exit-2 replay-failure - arm is the garbage appended AFTER the baseline commit — TEST-SPEC's - deliberate contrast), and baseline-content validation is - source/configuration validity per T6.3-4's own "sources fail - parse/validation" arm; SPEC 13.3's naming of `impact` among the - journal-error-reporting gated reads is reachable only under this - staging (module header documents the interpretation). Obstruction arm: - after a successful build (emission under markdown.outDir) and session - create, the mdout directory is replaced by a plain file — the emit - write path mdout/specs/A.md's one offending component. Each of the six - gated reads (ids, show alpha, coverage, impact --base, review status s, - query nodes) asserts exit 1 + the form-exact findings report with - exactly {14.13: 1} (concerned path .xspec/journal, line named) / - {14.22: 1} (concerned path mdout), each inside a whole-root - assertLeavesUnchanged. Never-gated contrast on both workspaces, each - probe in its own whole-root compare: `occurrences` (exact one-record - alpha→beta identity summary, findings []), `view` (whole domain, - findings []), `at specs/A.mdx 30` (alpha, occurrence null), all exit 0 - whatever the gate state (11.2: a gate condition is no domain file's - finding), and `inventory` exit 0 with `recorded` a plain list naming - specs/A.xspec.ts (11.6). Verified: typecheck/format clean; - section-13.3 unchanged 3 failed / 1 passed (T13.3-3 red at the same - first FP-001-class decode; new arms suite-unreached); guarded solo runs - of each new arm against the built product fail exactly as diagnosed — - journal arm at the first gated probe's form-exact decode (the product - already gates the five non-impact reads with old-shape 14.13 naming - line 2, while `impact --base` exits 2 treating the baseline's own - journal state as a 6.3 failure: the diagnosed precedence gap), and - obstruction arm at the first probe's exit assertion (the product - answers all six reads exit 0 from the obstructed workspace — the - gates-on-source-validity-alone product the arm discriminates; its - `build`/`check` crash exit 70 ENOTDIR on the same staging); the - never-gated surfaces exit 2 "unknown command" (the patch-new §11 - dispatch gap). Staging premises probe-verified (rename → one-line - journal; build emits mdout/specs/A.md preserving workspace-relative - paths; both `review create`s succeed). `npm run test:self`: unchanged 4 - planned mid-loop reds (certification-document ×3 → FP-091; S-1's 7 - unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification - green. Traceability unchanged (["13.3"] — T13.3-3 sits in TEST-SPEC - 14's per-condition record for neither 14.13 nor 14.22 (T6.1-3/T13.4-6 - are its homes), matching the landed convention that "14" rides that - record); no new decoder (S-5 unchanged); T13.3-3 in no certification - scope (Exclusions-shared machinery only).] - -- [x] FP-046 — T13.4-6: add plain-file occupant and finding-cardinality - arms. [R2 #33; TEST-SPEC §13.4] - `test/suite/registry/section-13.4.ts`: occupants — a `build` write-path - directory component; a first-emission `outDir` component. Cardinality — - one component refusing two writes → one finding; two components → two - findings, via `check`. - [Done 2026-08-13: four first-emission workspaces under the existing - OUT_CONFIG (outDir "out"; occupant staged in the declaration, so no move - operand is involved — the destination-side contrast reporting - `refused-invalid-destination` stays T6.5-4's, per TEST-SPEC's - parenthetical): plain file at the outDir component `out` and, separately, - at the deeper emit-path component `out/specs` (out a real directory — - discriminates vet-only-the-outDir products), each asserting `build` and - `check`; cardinality via `check` where TEST-SPEC pins it — A+B both - emitting under occupied `out` → exactly one 14.22, and nested - specs/one/A.mdx + specs/two/B.mdx with plain files at `out/specs/one` + - `out/specs/two` → exactly two. All probes ride new module helpers - `assertObstructionFindings`/`expectObstructionReport` (exit 1, form-exact - 12.7 findings report, exactly one condition-22 finding per staged - offending component with the component as its concerned path per SPEC - 14.22's cardinality rule — per-index compare, sound because the pinned - 12.7 order among equal-code empty-location findings is concerned-path - byte order; build-side sets exact, check-side tolerating only 14.10 - beside; every probe in a whole-root assertLeavesUnchanged); the existing - write-path-symlink arm was refactored onto the same helpers, gaining the - previously missing concerned-path assertion. No new adapter (S-5 - unchanged); traceability already ["13.4","14"] (T13.4-6 is 14.22's - primary); no certification scope. Verified: typecheck/format clean; - suite section-13.4 unchanged 1 failed / 5 passed — T13.4-6 red at the - symlink arm's first FP-001-class form-exact decode, new arms - suite-unreached; peeled solo runs against the built product fail each - new arm exactly as diagnosed at its first probe's exit-code assertion - (the product vets symlink components only and crashes exit 70 ENOTDIR on - plain-file occupants in all four stagings — build and check alike; the - deeper-component build even writes modules before crashing, the - modify-while-refusing gap); occupant-free control twins build exit 0 - writing exactly the obstructed emit paths (staging premise); a - fake-product control matrix on the two-components arm proved the helper - green path and teeth (conforming report passes; a 14.10 beside passes; - one-finding, per-write-duplicate, out-of-order, and extra-14.20 reports - each fail at the intended assertion). `npm run test:self`: unchanged 4 - planned mid-loop reds (certification-document ×3 → FP-091; S-1's 7 - unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification - green.] - -- [x] FP-047 — T13.5-1: add the seam-neutrality arm. [R2 #34; TEST-SPEC - §13.5] - `test/suite/registry/section-13.5.ts`: held-then-released final workspace - state byte-identical to the same operation without `--test-hold` on an - identical twin. - [Done 2026-08-13: one identical twin workspace (CORE_DECL), scoped around - the five held arms alone, replays the held workspace's exact command - sequence — staging `build` and the `review status` item lookup included — - with the seam flag alone removed, and after each held-then-released arm - the two whole trees are compared byte-identically via - `assertDirectoriesEqual` with no exclusions (sources, journal, sessions, - derived files, graph data; H-4 product-to-itself, H-6 across directories; - hold paths already live outside the root). Per-arm compares make the twin - byte-identical at each next arm's start, so every arm runs "the same - operation on an identical twin workspace"; the sequence equality — reads - included — is exactly what §VIOL-CORE-CHATTYREADS's passing analysis - leans on (its appends land byte-identically on both sides), recorded as a - header staging constraint. Arms 4/5 pass each side its own workspace's - reported item ID (same operation by item scope, never an assumed - cross-directory ID equality); `heldArm` gained an optional `twinArgv`. - Title extended; traceability unchanged (["13.5"]; no numbered condition); - no new adapter (S-5 unchanged); no certification-scope change. NOT red - against this repo's product: suite section-13.5 7/7 — the product is - already seam-neutral, consistent with T13.5-6's passing cross-directory - tree compare; the pass is proven genuine by a teeth probe (twin's arm-2 - rename diverted a→a3 fails exactly at the new compare, diagnosing all 6 - divergent files across sources, journal, graph data, and generated - modules; probe reverted). Certification: CONF-CORE conformer 9/9 with the - new arm; violators exact — CHATTYREADS fails exactly T6.1-1+T13.4-5 while - passing T13.5-1 (the sequence staging's proof), EARLYWRITE fails exactly - T13.5-1 (still at the earlier while-held compare) +T13.5-4, NOLOCK/ - STALELOCK/PARTIALWRITE/PERSISTREADS exact. Typecheck/format clean; - `npm run test:self` unchanged 4 planned mid-loop reds - (certification-document ×3 → FP-091; S-1's 7 unmapped keys {11.2–11.6, - 12.6, 12.7} → stage G).] - -- [x] FP-048 — T14-4: extend the reporter matrix. [R2 #35; TEST-SPEC §14] - `test/suite/registry/section-14.ts`: 14.21 by `check` alone beside gate - findings; the 14.23 row (`inventory` + previews only, `check` as 14.10 - unit form, `build`/refreshing reads never); 14.14 never `version`; - 14.13/14.22 by gated reads yet accompanying no `occurrences`/`view`/`at` - answer; every other condition accompanying `occurrences`/`view`/`at` - answers per domain (all three for spec-source stagings; `occurrences` - alone for 14.7/14.11/14.18). - [Done 2026-08-13: all five rows landed, membership-only per the module - header's matrix discipline (depth stays with T10.1-5, T6.6-6, T11.6-4, - T12.2-2, T13.3-2/-3, T11.2-5/-6, T12.6-1/-2). (1) The 14.21 block gains - the failing-workspace half — source edited to 14.1 after the corrupt - session: `build` exactly {14.1}, `check` non-14.10 exactly {14.1, 14.21}, - `review status bad --json` and `review list --json` each exit 1 with the - gate report exactly {14.1}, no 14.21 beside. (2) New 14.23 block: valid - build, then `corruptGraphDataShapeBlind` (T6.6-6's shared staging); - `inventory` exit 1 exactly {14.23} via a new scoped form-exact adapter - `decodeInventoryFindings` (forms.ts, the decodeInventoryRecordedDatum - pattern — pinned `findings` member literally decoded, every other member - unread; S-5 DECODERS entry with conforming/old-shape/absent/null cases); - `rename specs/a.mdx a1 a2 --preview --json` exit 1 exactly {14.23} via - decodePreviewReport; `check` exactly {14.10: 1} (the unit form alone — - never 14.23, no per-file finding beside on the clean workspace, no - nonStale set-aside); `query nodes` exit 0 (reads leave the record - unconsulted); `build` exit 0 then `check` exit 0 (the rebuild replaces - the record). (3) `version` under the 14.14 workspace: exit 0 with a - single JSON document (12.6 JSON-only) — never the configuration error. - (4)+(5) Every sweep entry now carries an `answers` classification: - spec-source stagings run `occurrences`/`view`/`at <file> 0` (offset 0 — - resolution is total, 11.5), code-source stagings (14.7/14.11/14.18) - `occurrences` alone, each answer decoded through the form-exact 12.7 - document decoders at exit 1 with the staged condition counted exactly - like the build side (these surfaces never report 14.10); the - no-domain-file entries (14.13/14.22) instead probe `query nodes` — exit 1, - findings-only report, exactly the staged finding — while all three - surfaces answer finding-free at exit 0 over the staged valid spec source. - Title extended to the full entry; traceability unchanged (["14"] — 10.1/ - 11.x/12.6/13.3/6.6 are carriage context with home coverage at their own - tests, the FP-015/FP-018 precedent); no certification scope (T14-4 sits - in CERTIFICATIONS.md's not-to-certify residue). Verified: typecheck/ - format clean; S-5 82/82 green incl. the new decoder's guards; suite - section-14 unchanged 5 failed / 0 passed — every test red at its first - FP-001-class form-exact decode, new arms suite-unreached; direct probes - against the built product prove every new staging sound and every new - arm red-as-diagnosed: the §11/12.6 surfaces and `--preview` are unknown - commands/flags on this pre-patch product (exit 2 → diagnosed exit - failures: inventory/preview/version/occurrences/view/at), the - failing-side 14.21 commands report old-shape findings with conforming - membership except `review status bad` which reports the corrupt session - instead of the gate findings (a real behavioral gap the arm - discriminates), the 14.22 staging's `query nodes` answers exit 0 instead - of gating (the T13.3-3-diagnosed gate gap), the 14.13 gated read exits 1 - with the old-shape 14.13 naming the line, and the 14.23 staging's - `check` reports exactly one old-shape 14.10 while `query nodes`/`build`/ - `check`-after exit 0 as the arms demand. `npm run test:self`: unchanged - 4 planned mid-loop reds (certification-document ×3 → FP-091; S-1's 7 - unmapped keys {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification - green.] - -## Stage G — §§10–14 missing tests (new-test convention applies) - -CONF-AVAIL staging constraints (CERTIFICATIONS.md, binds the 11.x bodies -below): in-scope stagings drive only the enumerated surface — never `at`; -T11.2-4's record observations ride `occurrences`/`view`; T11.4-1 stages no -undefined datum. The six in-scope tests are marked (CONF-AVAIL) — they must -certify against FP-091's fixtures once those land. - -- [x] FP-049 — Implement T10.1-5: failing-workspace gate precedence over - session corruption, with the `check`-reports-14.21-beside-gate-findings - discriminating pair. [R2 #1; TEST-SPEC §10.1] Registry - `section-10.1.ts`; map `"10.1"` (+ `"14"`). - [Done 2026-08-13: registered in section-10.1.ts with traceability - ["10.1","14"]. One workspace in TEST-SPEC's staging order — valid build, - audit session `cor`, T10.1-4's shape-independent garbage-bytes corruption, - then specs/B.mdx overwritten to a childless `<S>` (exactly one 14.1, no - masking interplay). A `build --json` gate-reference probe pins the gate's - findings as exactly {14.1: 1} located in specs/B.mdx — the exact count - doubling as condition 21's not-by-build half — then seven gated probes - (`status`, `next`, `show`/`resolve --status updated`/`split` with an item - ID no session ever held — judged only against session content, never - reached, 12.0 — `export`, and `review list`) each assert exit 1, the - form-exact 12.7 findings report holding exactly {14.1: 1} in specs/B.mdx - (the exact multiset realizes "no condition-21 finding beside them"; the - one-member decode realizes list's whole-report replacement, 10.7), inside - whole-root modifies-nothing compares; `check` asserts 14.21 present and - concerning `.xspec/reviews/cor.json` via the 12.7 path member together - with the 14.1 (presence-based beside them — 14.10 detectability is - T14-4's, the T13.3-3 precedent); a final byte-compare restates the - corrupt session untouched across the sweep. No certification scope - (CERTIFICATIONS.md Exclusions: T10.1-5 carries its own in-test - check-vs-subcommand contrast). Verified: typecheck/format clean; suite - section-10.1 went 3 failed/1 passed → 4 failed/1 passed — T10.1-5 - red-as-diagnosed at the first arm's FP-001-class form-exact decode - ("expected no member \"condition\""); direct probes against the built - product prove every arm sound and the deeper diagnosed gap real: the six - naming subcommands open the corrupt session first and report the old-shape - 14.21 instead of the gate's findings (exactly the TEST-SPEC-named defect), - while `list` gates correctly and `check` reports both conditions with - `file` naming the session path, everything exit 1, nothing modified, - cor.json bytes untouched. `npm run test:self`: unchanged 4 planned - mid-loop reds (certification-document ×3 → FP-091; S-1's 7 unmapped keys - {11.2–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] - -- [x] FP-050 — Implement T11.2-1: parse-local structure and per-file - masking with no writes. [R2 #2; TEST-SPEC §11.2] New registry module(s) - for §11.2 (+ suite wrapper, index import); map `"11.2"`. - [Done 2026-08-13: registered in the new registry module section-11.2.ts - (SUITE-52; wrapper + index spread) with traceability ["11.2"] (T11.2-1 in - no TEST-SPEC 14 staging record — the FP-016 precedent; no certification - scope — CERTIFICATIONS.md's Exclusions carry only its answer-side no-write - compares through the machinery VIOL-CORE-CHATTYREADS certifies). One - workspace: A parseable with findings of both levels — `d={"nosuch"}` - (14.5) and the `d={"top"}` self-cycle (14.9), the latter's spelling - RESOLVING and recording its depends occurrence (positions survive - findings, 11.2/5.7) — beside the dup pair (14.3), one-segment `ha#sh` - (14.4), unknown prop (14.17), and `<div>` (14.16); B unparseable (14.20); - C finding-free; a multi-byte prefix shifts every A offset (1.7). The - `build --json` gate reference pins staging integrity: exit 1, exactly - {14.3,14.4,14.5,14.9,14.16,14.17 ×1 located in A; 14.20 ×1 in B} - (file-granular; range precision is T14-8's). Bare `view` (JSON-only: - entire stdout one document, no `--json`) exits 1 with the same finding - multiset — B's 14.20 accompanying — views exactly [A, C] (B contributes - no view): A's full positional tree byte-exact over the pinned projection - (identity three-state per 11.2 — dup bearers and ha#sh unavailable, - top/top.kid/gone/solo defined; construct ranges; raw attribute entries - name/range/text, unknown prop included; the div gets no node), comment - ranges, and the complete two-record occurrence enumeration (5.7 spans: - the d string literal; the whole embedding container); C's view complete, - empty lists as []. Tag-range decompositions and interpreted tags/coverage - stay outside the projection (T11.4-1, T11.2-2/T11.4-3 homes — the form - decode still validates their forms). Failing-side `occurrences` (same - findings, same records, exit 1) and `at C 0` (finding-free, exit 0, root - resolution — the per-file domain contrast) ride per T11.2-6's stated - delegation; every invocation (gate build included) sits in a whole-root - assertLeavesUnchanged compare — never-built workspace, so any graph-data - or derived write surfaces. New form-exact machinery: full - `decodeViewReport` in adapters/forms.ts (node/attribute/import/ - occurrence/comment forms, `--text` conditional presence, identity/text - never null, attribute text byteLength = range length, document orders and - per-file record-file equality enforced) with ViewNode/FileView/ViewReport - model types and COVERAGE_ATTRIBUTE_VALUES; S-5 gains two DECODERS entries - (with/without `--text`) — 86/86 green. Verified: typecheck/format clean; - suite section-11.2 red-as-diagnosed at the first arm's FP-001-class - form-exact decode ("expected no member \"condition\"" on the gate build; - view/occurrences/at are unknown commands on this pre-patch product — - further diagnosed exit failures once that closes); a direct probe of the - staged fixture against the built product returned exactly the expected - old-shape condition multiset; a scratch conforming fake product (deriving - every range independently from the workspace bytes) ran the registered - body green end-to-end, and three deviation fakes (cycle occurrence - dropped; masked B served a view entry; view writing graph data) each - failed diagnosed — expectations satisfiable, assertions toothed. `npm run - test:self`: 4 planned mid-loop reds (certification-document ×3 → FP-091; - S-1's unmapped keys narrowed 7 → 6, now {11.3, 11.4, 11.5, 11.6, 12.6, - 12.7} → stage G), S-5 and certification green.] -- [x] FP-051 — Implement T11.2-2 (CONF-AVAIL): spelled-identity / - interpreted-data definedness matrix. [R2 #2, R3 gap 1, VERIFY; TEST-SPEC - §11.2] - [Done 2026-08-13: registered in section-11.2.ts (SUITE-52) with - traceability ["11.2"] (no TEST-SPEC 14 staging record — the FP-050 - precedent). CONF-AVAIL in scope (VIOL-AVAIL-NULLMARKER and VIOL-AVAIL-OMIT - certify it; the fixture family lands with FP-091), and its scope - constraint is honored: NO gate-reference `build` — CERTIFICATIONS.md pins - every command an in-scope test drives to the enumerated `view`/ - `occurrences` surface — so staging integrity rides the `view` answer's own - exact findings multiset, the staged conditions drawn from the scope's - stated set. One file (specs/M.mdx, multi-byte prefix), bare `view`, exit 1, - full tree projection (identity/range/raw attribute entries per node PLUS - this test's tags/coverage datums; `datumLeaf` defaults pin the absent-prop - defaults — tags [] never null, coverage "required" — on every propless - section; root the stated null/null): solo defined (+`coverage="none"` as - the defined non-default value); repeated agreeing (`ragree` x2) and - disagreeing (`rone`/`rtwo`), braced `id={"x"}`, valueless `id` each - unavailable (14.17, never 14.1); no-`id` section (14.1) with child - `orphan` (14.2 masked) AND grandchild `orphan.deep` — the deep arm - discriminates a product checking only the immediate parent's spelling; - duplicate `x` pair both unavailable (one 14.3 locating both) with `x.y` - defined beneath one (defined without defined prefixes); `ha#sh` (14.4) - with structurally-consistent child `ha#sh.kid` (own 14.4 — extending a - malformed identity cannot avoid its segments; deliberately no 14.2 - anywhere, a condition outside CONF-AVAIL's stated set); unique `z` defined - beside braced `id={"z"}` (contests nothing — a value-reading product - fails the tree compare AND the count map via a second 14.3); tags matrix - `tr`/`tm`/`ti` (repeated/braced/invalid tag `bad#tag` 14.4) and coverage - matrix `cr`/`cm`/`ci` (repeated-agreeing/valueless/`"maybe"`) each - interpreted-unavailable with raw spellings listed and identities STAYING - defined (tags/coverage invalidity never undefines identity). Exact - findings {14.1:1, 14.3:1, 14.4:3, 14.17:10 — one per afflicted element}, - every finding located in M (file granularity; range precision T14-8's); - imports/occurrences/comments []. Verified: typecheck/format clean; suite - section-11.2 red-as-diagnosed — T11.2-2 fails at the exit assertion - (`view` unknown command, exit 2, the pre-patch product gap); scratch - probe: a conforming fake re-deriving the 11.2 rules independently from the - workspace bytes ran the registered body green end-to-end (expectations - satisfiable, findings order accepted), and four deviation fakes each - failed diagnosed — markers-as-null at the identity-datum decode (exactly - VIOL-AVAIL-NULLMARKER's certified failure), take-last-value and - no-inheritance at the tree compare, invalid-forms-contest at the count - map (assertions toothed). `npm run test:self`: unchanged 4 planned - mid-loop reds (certification-document x3 → FP-091; S-1's 6 unmapped keys - {11.3–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [x] FP-052 — Implement T11.2-3: invalid paths (Linux leg). [R2 #2; - TEST-SPEC §11.2] - [Done 2026-08-13: registered in section-11.2.ts (SUITE-52) with - traceability ["11.2"] (no TEST-SPEC 14 staging record — the FP-050/051 - precedent; 12.0/12.7/5.7/1.5 are context with home coverage at T12.0-13/ - T12.7-1/T11.3-1). NOT in CONF-AVAIL scope (its staging — `#` paths, - non-UTF-8 path, a code group — lies outside that scope by construction), - so the gate `build --json` rides staging integrity: exit 1 with EXACTLY - the 14.19 multiset (every file's content deliberately condition-free, so - later identity unavailability is attributable to the paths alone), each - finding pinned as {code "invalid-source-path", locations [], concerned - path} in the 12.7 order, wrapped in a whole-root snapshot compare. One - workspace: valid `specs/OK.mdx` (the defined-side contrast and reference - target), `specs/a#b.mdx` (nested pa > pa.kid with a tags attribute), - Linux-staged `specs/b<0xFF>.mdx` (raw-byte filename via workspace.file; - the T1.5-2 arm-gating precedent for the entry's leg note — the `#` arms - run everywhere, every expectation parameterized on the staging, no test - skip, H-9), and `src/co#de.ts` (multi-byte comment prefix; one - text(SPEC.ok) call inside named unit useText, one bare top-level marker). - Answers: bare `view` → exit 1, per-file views [OK, a#b, b<0xFF>] in path- - byte order with the non-UTF-8 `file` member as the marked byte form - composed from the staging bytes, findings EXACTLY the two spec-path 14.19s - (the code source's concerns no domain file — the accompanies-and-no-other - discrimination), trees pinned via T11.2-1's projection (OK defined; every - invalid-path node identity, root included, the marker); bare `occurrences` - → exit 1, all three 14.19s, enumeration exactly the code source's two - records ({file, range, kind, target} present, source exactly the marker); - `at specs/a#b.mdx` at 0 and at the kid offset → exit 1, exactly its own - 14.19, resolution root/innermost with identity the marker. "No identity - over an invalid path is ever emitted" realized as exact-value pinning of - every identity datum in every captured document. Verified: typecheck/ - format clean; suite section-11.2 red-as-diagnosed — T11.2-3 fails at the - gate build's FP-001-class form-exact decode ("expected no member - \"condition\""; the pre-patch product does fire exactly 3x 14.19 in the - old shape on a direct probe, the old order matching the pinned one, and - view/occurrences/at stay unknown commands); scratch probe: a conforming - fake re-deriving every answer from the workspace bytes (own mini-parser, - byte-wise discovery, hex from real dirent bytes) ran the registered body - green end-to-end, and five deviation fakes each failed diagnosed — - null-for-marker at the form decode (VIOL-AVAIL-NULLMARKER's class), - identities-emitted-over-invalid-paths at the a#b tree compare, - all-findings-attached at the view domain compare, lossy-plain-string - non-UTF-8 path at the build projection, source-undefined-records-dropped - at the enumeration compare. `npm run test:self`: unchanged 4 planned - mid-loop reds (certification-document x3 → FP-091; S-1's 6 unmapped keys - {11.3–11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [x] FP-053 — Implement T11.2-4 (CONF-AVAIL): resolution and expanded-text - poisoning; record observations ride `occurrences`/`view`. [R2 #2, R3 gap - 1, VERIFY; TEST-SPEC §11.2] - [Done 2026-08-13: registered in section-11.2.ts (SUITE-52) with - traceability ["11.2"] (no TEST-SPEC 14 staging record — the FP-050..052 - precedent; 11.3/11.4/5.7/1.6/3/12.7 are context with home coverage at - T11.3-1/T11.4-5/T5.7-*/T12.7-1). CONF-AVAIL in scope (VIOL-AVAIL- - NULLMARKER and VIOL-AVAIL-OMIT certify it; the fixture family lands with - FP-091); scope constraints honored: the whole entry drives ONLY bare - `view` (with/without --text) and bare `occurrences` — no gate `build`, no - `at`, no `--file` — staging integrity riding each answer's own exact - findings multiset, conditions drawn from the scope's stated set. Four - stagings: (1) specs/R.mdx — duplicate `a` bearers with unique `a.b` - beneath the first; `d={"a.b"}` on the second bearer and `{text("a.b")}` - in an id-less section each resolve and record with `source` EXACTLY the - marker (file/range/kind/target present) — pinned as the complete - two-record enumeration via BOTH surfaces (never a picked bearer, never a - dropped record, never an unavailable target — the form decode also admits - only plain-string targets) — while `d={"a"}` records none, its 14.5 - located within the opening tag spelling it; findings exactly {14.1, 14.3 - locating BOTH bearers, 14.5}, the view tree positioning every enclosing - construct with identities per 11.2. (2) chain CH-A embeds CH-B embeds - CH-C with `{text("nosuch")}` in C: bare `view --text` — exactly one 14.6, - its location's range EXACTLY the braced container (SPEC 14 pins the - embedding form); top/mid/deep own+subtree text EXACTLY the marker, - sibling side (expansion inserted) and ok byte-exact, every root's own - text defined beside its poisoned subtree text; imports/occurrences/ - comments pinned whole. (3) separately staged self-embedding cycle CY.mdx: - `{text("self")}` resolves and records (occurrence pinned), exactly one - 14.9 located at the participating spelling, self poisoned, calm and the - root's own text defined byte-exact. (4) removal-by-form: IMP.mdx with an - unused-binding import of GONE.xspec plus a stray <div> (14.16, located, - preserved byte-for-byte in the enclosing text, no view node); after - fsp.rm(GONE.mdx) the SAME pinned tree asserts text values byte-identical - (the import removed by form) while the import entry's target flips to the - marker and 14.15 (located at the declaration) joins 14.16 — exact counts - both sides, exit 1 everywhere. Expected text values hand-derived per the - rules of 3 (derivation comments beside each constant), composed from the - staged parts. Verified: typecheck/format clean; suite section-11.2 - red-as-diagnosed — T11.2-4 fails at the first arm's exit assertion - (`view` unknown command, exit 2, the pre-patch product gap); scratch - probe: a conforming fake re-deriving every answer from the workspace - bytes (own mini-parser, line-attribution rules-of-3 renderer with - expansion-stack cycle detection) ran the registered body green - end-to-end, and six deviation fakes each failed diagnosed — - null-for-marker at the form decode (VIOL-AVAIL-NULLMARKER's class), - picked-bearer and dropped-record at the enumeration compare, - partial-expansion at the poisoned-tree compare, remove-imports-by- - resolution at the after-deletion pinned tree, ambiguous-ref-resolves at - the count map; direct `build --json` probes of all four stagings against - the built product confirm the staged conditions fire in a real parser - (14.3+14.1; 14.6; 14.9; 14.16 then 14.15+14.16 — no 14.20 anywhere; the - absent 14.5 is exactly the pre-patch resolution-semantics gap the arm - discriminates). `npm run test:self`: unchanged 4 planned mid-loop reds - (certification-document x3 → FP-091; S-1's 6 unmapped keys {11.3–11.6, - 12.6, 12.7} → stage G), S-5 and certification green.] -- [x] FP-054 — Implement T11.2-5: domain/findings/exit discipline. [R2 #2; - TEST-SPEC §11.2] - [Done 2026-08-13: registered in section-11.2.ts (SUITE-52) with - traceability ["11.2"] (no TEST-SPEC 14 staging record — the FP-050..053 - precedent; T14-4's and T11.5-2's citations are cross-references TO it; - 11.3–11.5/12.0/12.7 context with home coverage at T11.3-*/T11.4-*/ - T11.5-*/T12.0-13/T12.7-3). NOT in CONF-AVAIL scope — CERTIFICATIONS.md - lists T11.2-5 expressly under Exclusions — so gate `build`, `at`, and - `--file` all free. Two workspaces. (1) T11.2-1's A/B/C reused beside a - discovered reference-free src/app.ts under a spec+code config (the - wrong-kind operand; adds no finding): gate `build --json` pins the exact - T11.2-1 multiset; `view C` → exit 0, findings [], C's complete view (the - domain is the requested files while A/B stay invalid); `view A` → exit 1, - findings EXACTLY A's six of both levels all located in A (B's 14.20 - excluded by exactness), full answer still emitted (views [A]: tree, - comments, occurrences, imports pinned); then the five - argument-checks-precede-answering arms on the failing workspace — unknown - `view` operand, wrong-kind `view src/app.ts`, outside-root `occurrences - --file ../…`, malformed `occurrences --to specs/A.mdx#a..b` (empty - segment), out-of-range `at specs/A.mdx <len+1>` — each exit 2 via a new - expectAvailabilityUsageError (JSON-only surfaces: bare invocation, single - 12.7 error document as entire stdout, stderr message present; per-surface - matrices stay at T11.3-2/3, T11.4-2, T11.5-2). (2) cycle pair D/E (mutual - external `d` references — the external form forces mutual imports, so the - staged set is EXACTLY two 14.9s: dependency cycle + spec import cycle, - each jointly violated) beside finding-free C: gate pins {14.9: 2} with - each finding's full path — two locations, one per file, windows the - import declarations resp. the opening tags (disjoint ordered windows pin - the 12.7 finding order import-first); `view D` and `view E` each assert - both findings WHOLE (the out-of-domain participant's location included) - plus the participant's complete view (identities defined — cycle never - undefines; import entry resolved; the resolving `d` occurrence recorded); - `view C` → exit 0, findings [] (no participant in domain). Both - workspaces wrapped in whole-root snapshot compares (hygiene; no-write - contract stays T11.2-1/-6's). Verified: typecheck/format clean; suite - section-11.2 red-as-diagnosed — T11.2-5 fails at the ws1 gate build's - FP-001-class form-exact decode ("expected no member \"condition\""); - scratch probe: a conforming fake re-deriving every answer from the - workspace bytes (own config parse, glob discovery, MDX-lite parse with - byte offsets, 11.2 definedness, resolution/occurrences, Tarjan SCC over - contains+depends+embeds and over the import graph, 12.7 forms and order, - per-domain attachment, argument checks) ran the registered body green - end-to-end, and five deviation fakes each failed diagnosed — attach-all - at `view C`'s exit-0 (domain discipline), truncate-joint at the - whole-cycle location count (accompanies-whole), answer-past-usage at the - unknown-file exit-2 (precedence), withhold-answer at the views-[A] pin - (full answer), exit-zero at `view A`'s exit-1; direct `build --json` - probes against the built product confirm both stagings fire in a real - parser — ws1 exactly the staged multiset per home file, ws2 exactly two - 14.9s whose old-shape messages name the import cycle and the dependency - cycle paths (and whose import-declaration location 45..70 independently - corroborates the ByteFixture arithmetic); view/occurrences/at stay - unknown commands (exit 2) pre-patch. `npm run test:self`: unchanged 4 - planned mid-loop reds (certification-document x3 → FP-091; S-1's 6 - unmapped keys {11.3–11.6, 12.6, 12.7} → stage G), S-5 and certification - green.] -- [x] FP-055 — Implement T11.2-6: never-stale + gate-findings-never-attach. - [R2 #2; TEST-SPEC §11.2] - [Done 2026-08-14: registered in section-11.2.ts (SUITE-52) with - traceability ["11.2"] (not in TEST-SPEC 14's staging record — 14.13's - home is T6.1-3/T13.4-6, 14.22's T13.4-6; T14-4's and T13.3-3's citations - are cross-references TO it). NOT in CONF-AVAIL scope — expressly an - Exclusions entry (its answer-side no-write compares lean on the - compare-around machinery certified via VIOL-CORE-CHATTYREADS). The - entry's delegations honored: passing-side refresh participation stays - T13.3-2's sweep, failing-side answering T11.2-1's, gated-read breadth - over these fixtures T13.3-3's whole-gate arms (already landed pre-plan), - and the occurrences/at finding-free contrast T13.3-3's never-gated sweep - + T14-4's availability rows — this test owns the two fixtures and the - view never-attach arm with the build/check surfacing. Fixture 1: passing - build, then one garbage line at .xspec/journal (T12.2-2-family-7/T14-4 - staging) — `build --json` exactly {14.13: 1} concerning .xspec/journal, - failing build modifies nothing; `check --json` the same counted over - non-14.10 findings (the T12.2-2 set-aside: the journal feeds canonical - identities, 5.4, so graph-data verifiability beside an unreadable - journal is underdetermined); `view specs/C.mdx` (module C fixture - reused) → findings [], views exactly [C] with C_TREE pinned, - imports/occurrences/comments [], exit 0. Fixture 2: passing build under - `markdown.outDir: "mdout"` (premise-checked: mdout/ dir, emitted - mdout/specs/C.md — T13.3-3's arm-2 discipline), then the outDir - directory replaced by a plain file — `build --json` exactly {14.22: 1} - concerning `mdout`, refusal before any write; `check --json` exactly - {14.10: 1, 14.22: 1} (valid sources make the swap-deleted emission - DEFINITE per-file staleness — the T12.2-2 exactness position — pinning - the swap's whole fallout; paths pinned: mdout, mdout/specs/C.md); `view` - of the very file whose emission path is obstructed → finding-free - complete exit 0. Every invocation under whole-root snapshot compares. - Verified: typecheck/format clean; suite section-11.2 red-as-diagnosed — - T11.2-6 fails at the fixture-1 gate build's FP-001-class form-exact - decode ("expected no member \"condition\""); direct probes against the - built product confirm the stagings fire in the real product (fixture 1: - build/check exit 1 with exactly one old-shape 14.13 naming - .xspec/journal and NOTHING stale beside on the pre-built workspace, - everything unmodified; fixture 2: premise build emits mdout/specs/C.md, - the obstructed build/check exit 70 — the pre-patch vets-no-components - gap FP-018 also observed — and `view` stays an unknown command, exit 2); - scratch run of the registered body against a conforming fake - (re-deriving journal/obstruction/staleness findings and the C view from - workspace bytes) green end-to-end, five deviation fakes each failing - diagnosed — attach-gate at the view arm's exit-0, drop-path at the - 14.22 concerned-path pin, answer-stale at the C_TREE compare (current - sources, not cache), write-on-view at the whole-root compare, - miss-stale at check's exact count map. `npm run test:self`: unchanged 4 - planned mid-loop reds (certification-document x3 → FP-091; S-1's 6 - unmapped keys {11.3–11.6, 12.6, 12.7} → stage G), S-5 and certification - green.] - -- [x] FP-056 — Implement T11.3-1: `occurrences` enumeration in the - form-exact 12.7 record form. [R2 #3; TEST-SPEC §11.3] New §11.3 registry - module; map `"11.3"`. Uses FP-001's literal decode. - [Done 2026-08-14: registered in new section-11.3.ts (SUITE-53; wrapper - section-11.3.test.ts; spread into registry/index.ts) with traceability - ["11.3"] (no TEST-SPEC 14 staging record — the FP-050..055 precedent; - 5.7/11.2/12.7 are context with home coverage at T5.7-*/T11.2-3/-4/ - T12.7-1); no certification scope. The entry's fixtures are imported, - never copied: section-5.7.ts and section-11.2.ts now export their staging - constants and expectation tables (export-only edits, bodies untouched; - the T5.7-1/T5.7-4 unit tables carry a stated order contract — listed in - occurrence order — that this test expands by position). Per fixture, the - COMPLETE record sequence is asserted PER INDEX in occurrence order - through bare `occurrences` (JSON-only; decodeOccurrencesReport enforces - the exact five-member 12.7 record form, the never-null source datum, and - the 5.7 comparator): T5.7-1's eleven records as identity-level tuples - (both duplicate pairs adjacent — T5.7-1 pins the same multiset - order-free, this test adds the order), T5.7-2's six with byte-precise - own ranges, T5.7-3's six via full-record equality (every 5.7 datum - byte-precise), T5.7-4's three resolving spellings (exit 1, staging - integrity as the exact {14.5,14.6,14.7,14.8} count map); the - unavailability arms restage T11.2-3's code source (OK.mdx + src/co#de.ts: - findings exactly one 14.19 pinned {code invalid-source-path, locations - [], path src/co#de.ts}, enumeration exactly CS_EXPECTED_OCCURRENCES) and - T11.2-4's resolution matrix (R.mdx: counts {14.1,14.3,14.5}, enumeration - exactly R_EXPECTED_OCCURRENCES) — `source` exactly the marker, - file/range/kind/target present, never a picked bearer, never a dropped - record. The body re-earns every imported claim before any product - invocation: slice self-checks over all claimed ranges (SPAN/ORD/CS/R) - plus claimed-sequence sortedness checks under the pinned comparator. - Verified: typecheck/format clean; scratch probes (deleted): the - T5.7-1/T5.7-4 tuple orders proven against mechanically derived spelling - byte positions, and against the built product the six stagings fire as - diagnosed (arms 1–3 build exit 0; arm 4 exactly {14.5,14.6,14.7,14.8} - old-shape; arm 5 exactly one 14.19 naming co#de.ts; arm 6 {14.1,14.3} — - the absent 14.5 is FP-053's diagnosed pre-patch resolution gap); suite - section-11.3 red-as-diagnosed at the first arm (`occurrences` unknown - command, exit 2, the pre-patch product gap; every self-check passes - first); sections 5.7/11.2 unchanged (4 resp. 6 diagnosed reds). `npm run - test:self`: 4 planned mid-loop reds with S-1's unmapped set narrowed - 6 → 5 keys, exactly {11.4, 11.5, 11.6, 12.6, 12.7} ("11.3" now mapped; - certification-document ×3 → FP-091), S-5 and certification green.] -- [x] FP-057 — Implement T11.3-2: `--file` set restriction. [R2 #3; - TEST-SPEC §11.3] - [Done 2026-08-14: registered in section-11.3.ts (SUITE-53) with - traceability ["11.3"] (no TEST-SPEC 14 staging record — the FP-056 - precedent; 11.1/11.2/12.0 are context with home coverage at - T11-*/T11.2-*/T12.0-*); no certification scope (CERTIFICATIONS.md - Exclusions name T11.3-2's matrix explicitly; CONF-AVAIL's in-scope set - excludes it). Two self-owned fixtures. (1) A failing spec+code workspace — - one 14.5 in specs/apple.mdx (beside a resolving reference INTO the - excluded file and a local embedding), one 14.3 in specs/beta.mdx (beside - a resolving local `d`), one 14.8 in src/app.ts (string-form `text` beside - a resolving marker), plus an UNDISCOVERED unparseable decoy docs/note.mdx - in no configured group — gate `build --json` pins the exact multiset and - per-file homes first; then, inside one whole-root modifies-nothing - compare: `--file "**/ap*"` (one glob admitting spec and code alike) → - exit 1, findings exactly {14.5,14.8} located in the admitted files (never - beta's 14.3, never a phantom 14.5 for the cross-boundary reference — - resolution is workspace-wide, the domain restricts consultation), records - exactly the admitted files' three tuples per index; complementary literal - glob `specs/beta.mdx` flips the domain (exactly {14.3}, exactly beta's - record); empty-set arms `docs/*.mdx` (matches the on-disk decoy, no - DISCOVERED file — a filesystem-globbing product surfaces the decoy's - 14.20) and `nosuch/**/*.mdx` → each `{"findings":[],"occurrences":[]}` - exit 0, no unknown-file usage error, whatever findings the workspace - carries; outside-root arms `../elsewhere/**/*.mdx` and - `specs/../../evil/*.mdx` → exit 2 via T11.2-5's usage-error protocol - (single 12.7 error document, stderr message), newly exported from - section-11.2.ts (export-only edit) for the per-surface matrices. (2) A - valid conjunction workspace (P→x, P→y, Q→x): `--file specs/P.mdx` alone → - P's two records; `--to specs/T.mdx#x` alone → the two x-targeting - records; both → exactly the one-record intersection — each filter alone - admits more, so union or either-alone fails the exact per-index compares; - all exit 0 finding-free after the buildOk premise. Verified: - typecheck/format clean; suite section-11.3 red-as-diagnosed — T11.3-2 - fails at the gate build's FP-001-class form-exact decode ("expected no - member \"condition\"", the 14.5 leading), T11.3-1's known red unchanged; - direct probes against the built product prove the stagings (ws1 build - exit 1 with exactly the three old-shape findings in the right files, - BETA.far resolving, decoy contributing nothing; ws2 build exit 0 with - exactly the three depends edges; `occurrences` still unknown, exit 2 — - the pre-patch gap); scratch run (deleted) of the registered body against - a conforming fake deriving ranges from workspace bytes green end-to-end, - six deviation fakes each failing at exactly the targeted arm — - ignore-`--file` and whole-workspace-findings at the subset-domain - assertions, unknown-file-error and filesystem-globbing at the empty-set - arm, union at the conjunction's intersection compare, no-escape-check at - the outside-root exit-2 arm. `npm run test:self`: unchanged 4 planned - mid-loop reds (certification-document ×3 → FP-091; S-1's 5 unmapped keys - {11.4, 11.5, 11.6, 12.6, 12.7} → stage G), S-5 and certification green; - sections 5.7/11.2 unchanged (4 resp. 6 diagnosed reds).] -- [x] FP-058 — Implement T11.3-3: `--to` syntactic acceptance / malformed - spellings. [R2 #3; TEST-SPEC §11.3] - [Done 2026-08-14: registered in section-11.3.ts (SUITE-53) with - traceability ["11.3"] (no TEST-SPEC 14 staging record — the FP-056/057 - precedent; 1.4/1.5/12.0/12.7 are context with home coverage at - T1.4-*/T1.5-*/T12.0-*/T12.7-*); no certification scope (Exclusions name - T11.3-2/3's matrices). Two self-owned fixtures. (1) The acceptance ground - (failing on purpose; specs-only config): OK.mdx holds the domain's ONE - resolving occurrence (`use`→`ok`) so every empty selection is provably the - filter's doing (pinned by a bare-enumeration staging arm); the three - non-resolving grounds each carry a spelling a mis-implemented product - would resolve INTO — broken.mdx (14.20; sibling `hidden` + - `hiddenUse d={"hidden"}` complete before the unclosed final tag, so an - error-recovering product serves the record), dup.mdx (14.3 twin pair + - `watcher d={"twin"}` → 14.5, so a winner-picking product serves it), and - the undiscovered docs/other.mdx (valid `x` + `xuse d={"x"}` in no group, - so a filesystem-resolving product serves it). Gate `build --json` pins - {14.20,14.3,14.5} with homes; the five accepted arms (`path#id` nosuch, - bare-path no file, undiscovered, masked, undefined bearer) each assert - exit 1 — never an error, the T12.0-9 partition's stated exception — with - the domain's findings as the exact multiset and occurrences exactly []; - the six malformed arms (two `#`, `#ok` empty path, `ok..use` empty - segment, `ok use` whitespace, `then` forbidden, trailing `OK.mdx#`) ride - T11.2-5's exported usage-error protocol on the same failing workspace, - each defect spelled over the DISCOVERED OK.mdx path where the form allows - (TEST-SPEC's `a#b..c`/`a#then`/`a.mdx#` give classes, not byte-exact - operands — the FP-018 precedent) so a resolve-first product answers and - fails the exit; all under one whole-root modifies-nothing compare. (2) - The exact-selection ground (valid): BASE (top ⊃ top.sub) + USE staging - four records (useTop's d AND embedding → top, useSub → top.sub, useRoot's - module-form d={BASE} → root), all four pinned bare first; `--to #top` → - exactly the two top-targeting records (both kinds, never the - descendant's, never the root's), `--to #top.sub` → exactly the - descendant's own, bare `--to specs/BASE.mdx` → exactly the module-form - root record (T2.2-2), never the file's section-targeted ones. Verified: - typecheck/format clean; suite section-11.3 red-as-diagnosed — T11.3-3 - fails at the gate build's FP-001-class form-exact decode ("expected no - member \"column\""), T11.3-1/2's known reds unchanged; direct probes - against the built product prove the stagings (ws1 build exit 1 with - old-shape 14.20-in-broken + 14.3-in-dup — the absent 14.5 is FP-053's - diagnosed pre-patch resolution gap, same as T11.2-4's; ws2 build exit 0 - with exactly the four staged dependency edges incl. the module-form root - edge; `occurrences` still unknown, exit 2 — the pre-patch gap); scratch - run (deleted) of the registered body against a conforming fake deriving - findings/records/selection from workspace bytes green end-to-end, ten - deviation fakes each failing at exactly the targeted arm — ignore-to, - unknown-node-error, and findings-follow-to at the accepted arms, - lenient-spelling at the malformed protocol, serve-picked/recover-masked/ - fs-resolve at their non-resolving arms, prefix-select and depends-only at - the `#top` exact compare, file-select at the bare-path compare. `npm run - test:self`: unchanged 4 planned mid-loop reds (certification-document ×3 - → FP-091; S-1's 5 unmapped keys {11.4, 11.5, 11.6, 12.6, 12.7} → stage - G), S-5 and certification green.] -- [x] FP-059 — Implement T11.3-4 (CONF-AVAIL): definitive emptiness. - [R2 #3, R3 gap 1 (VIOL-AVAIL-NOFILE certifies exactly this), VERIFY; - TEST-SPEC §11.3] - [Done 2026-08-14: registered in section-11.3.ts (SUITE-53) with - traceability ["11.3"] (no numbered condition asserted — finding-free - everywhere). IN CONF-AVAIL scope (VIOL-AVAIL-NOFILE certifies exactly it; - fixtures land with FP-091), so the body obeys the scope's staging - constraints exactly: spec-only workspace (imports + `d` + embedding), NO - gate-reference `build` — the validity premise rides arm 1's own empty - findings member (no `--file` → the whole discovered set's findings - accompany, SPEC 11.2/11.3) — no snapshot compare (graph-data/refresh - behavior expressly out of scope; both states valid, so a conforming - product may refresh), and exactly two `occurrences` answers, both the - empty enumeration `{"findings":[],"occurrences":[]}` exit 0 (the - datum-form violators' stated passing ground). One workspace, one X - (specs/target.mdx#tgt), evolved between the arms per the entry's single - narrative: arm 1 bare `--to X` on a nonempty other-target ground - (teammate.mdx's local `d` + embedding → its own `mate`; X's defining - spelling unreferenced) → absolute emptiness — an ignore-`--to`, - enumerate-the-domain, definition-as-record, or error-on-empty product - fails here; then holder.mdx (import + `d={TGT.tgt}`, the workspace's ONE - resolving occurrence of X) is staged and arm 2 runs `--to X --file - "specs/t*.mdx"` — the glob admits exactly the nonempty {target, teammate} - domain away from holder → still empty, finding-free, exit 0. The - restricted arm carries NO in-test positive control by design (holder lies - outside every domain the test observes — CERTIFICATIONS' stated hazard, - certified through VIOL-AVAIL-NOFILE, whose whole-set enumeration serves - holder's record exactly when successfully staged). Verified: - typecheck/format clean; direct probes against the built product prove the - staging (both states build exit 0 with findings []; the only edge into - `tgt` is holder#user's depends edge; `occurrences` still unknown, exit 2 - — the pre-patch gap); suite section-11.3 red-as-diagnosed — T11.3-4 fails - at arm 1's runJson (exit 2 unknown command), siblings' known reds - unchanged (file 4 failed / 0 passed); scratch run (deleted) of the - registered body: conforming fake (deriving records from workspace bytes) - green end-to-end, the nofile deviation failing exactly at arm 2's - exact-empty enumeration compare (the certified diagnosis), ignore-to / - error-on-empty / definition-as-record each failing at arm 1's targeted - assertion. `npm run test:self`: unchanged 4 planned mid-loop reds - (certification-document ×3 → FP-091, its in-scope-registry gap narrowed - to exactly {T11.4-1, T11.4-3, T11.4-4}; S-1's 5 unmapped keys {11.4, - 11.5, 11.6, 12.6, 12.7} → stage G), S-5 and certification green.] - -- [x] FP-060 — Implement T11.4-1 (CONF-AVAIL): whole-domain views and - positional tree with tag-range decomposition byte-asserted; stages no - undefined datum. [R2 #4, R3 gap 1, VERIFY; TEST-SPEC §11.4] New §11.4 - registry module; map `"11.4"`. - [Done 2026-08-14: new module test/suite/registry/section-11.4.ts - (SUITE-54) + wrapper section-11.4.test.ts, spread into registry/index.ts; - traceability "T11.4-1": ["11.4"] (the T11.2-*/T11.3-* precedent: the - answer-borne condition counts are staging integrity, 14.16's primary - coverage stays T2.7-1's). IN CONF-AVAIL scope, so the body obeys the - scope's constraints exactly: spec-only workspace, ONE command — the bare - whole-domain `view` — NO gate-reference `build` (the validity premise - rides the answer's own findings member), no snapshot compare, and NO - undefined datum staged: every identity spelled, well-formed, conformant - against its POSITIONAL parent, unique — the marker-free ground - VIOL-AVAIL-NULLMARKER's passing side stands on, while the stated `null`s - the answers do carry (root tags/coverage; self-closing/root decomposition - members) are exactly what decodeViewReport rejects when omitted - (VIOL-AVAIL-OMIT's certified failure; fixtures land with FP-091). One - workspace, three files, whole document asserted: specs/Zebra.mdx (0x5A) < - specs/alpha.mdx (0x61) < specs/sub/leaf.mdx (0x73) pins byte order - against case-folding/locale collation and completeness (leaf is - section-less — root-only view, a views-only-sectioned-files product - drops it); Zebra pins the finding-free tree — paired sections at three - depths, self-closing leaves at depths two and three, two top-level - sections — per node construct range + decomposition (opening AND closing - for paired, opening only = construct range for self-closing, neither for - the root, whose range is the whole file) byte-asserted via the - running-offset builder behind a multi-byte prefix, projection {identity, - range, opening, closing, children} (attributes/tags/coverage VALUES stay - T11.4-3's; the form-exact decode already enforces their presence/forms); - alpha pins invalid-element parenting — wrap ⊃ wrap.mid ⊃ <div> ⊃ - wrap.mid.inner parents to wrap.mid (INNERMOST, never wrap/root) and a - top-level <em> ⊃ free parents to the root — with exactly the two 14.16 - findings (no phantom 14.2; the chain conditions read the same enclosure, - so every identity stays a defined plain string), each located within its - own element's whole-construct window (div's ordered before em's, 12.7), - exit 1 with the full answer; per-file imports/occurrences/comments [] - (nothing staged). Verified: typecheck/format clean; suite section-11.4 - red-as-diagnosed against the stub (HarnessAssertionError at the bare - `view`: exit 2 "unknown command 'view'" where 1 is demanded — the whole - §11 surface is patch-new); scratch run (deleted) of the registered body: - conforming fake (deriving views/findings from workspace bytes) green - end-to-end, ten deviation fakes each failing at the targeted assertion — - outermost/rootparent/elementnode/noclosing/chainbreak at the tree - compare, casefold at the decode's byte-order rejection, skipempty at the - whole-domain file-list compare, omitroot at the decode's - null-is-never-omission rejection (the OMIT anchor), utf16 at the - finding's byte window, misjudge (phantom 14.2) at the exact findings - count. `npm run test:self`: the 4 planned mid-loop reds narrowed as - scheduled — certification-document ×3 → FP-091, its in-scope-registry - gap now exactly {T11.4-3, T11.4-4}; S-1's unmapped keys now exactly - {11.5, 11.6, 12.6, 12.7} → stage G — S-5 and certification green - (certification.test.ts 17/17).] -- [x] FP-061 — Implement T11.4-2: operands-vs-restriction. [R2 #4; - TEST-SPEC §11.4] - [Done 2026-08-14: T11.4-2 appended to test/suite/registry/section-11.4.ts - (module header extended); traceability "T11.4-2": ["11.4"] (the T11.3-2 - precedent: gate condition counts are staging integrity, no "14"; - 12.0/7 parentheticals context). NOT in CONF-AVAIL scope — a named - Exclusions entry (the domain-and-exit matrices) — so unlike T11.4-1 it - drives the gate-reference `build --json` (staging integrity: exactly one - 14.3 in specs/bad.mdx, one 14.8 in src/app.ts — the T11.3-2 staging - mirror — specs/dup.mdx finding-free, docs/note.mdx an on-disk unparseable - decoy in NO configured group) and wraps the whole sweep in one - assertLeavesUnchanged. Arms: operands assert membership via T11.2-5's - exported usage-error protocol — a file existing nowhere, the on-disk - undiscovered decoy (a filesystem-resolving product accepts it), and the - discovered code source as wrong-kind (its own 14.8 notwithstanding — - checks precede answering), each exit 2 with the single 12.7 error - document; `--file` restricts — the decoy glob, a nothing-at-all glob, and - the SAME `src/app.ts` spelling that just erred as an operand each answer - `{"findings": [], "views": []}` exit 0 (the code arm is the sharp half: - a product reusing 11.3's spec-and-code-alike filter carries the staged - 14.8 and exits 1); combining operand with `--file`, each part - individually valid, exit 2; `view specs/dup.mdx specs/dup.mdx` → ONE - view, findings [], exit 0 on the failing workspace (domain = requested - files; positive control that empty answers are the filter's doing), the - view's substance pinned at identity level (root + `#solo`; ranges/ - attributes stay T11.4-1/-3's). Verified: typecheck/format clean; staging - premise probed against the built product (`build --json` reports exactly - the one 14.3 in bad.mdx and one 14.8 in app.ts, old shape); suite - section-11.4 red-as-diagnosed (T11.4-2 fails at the gate's FP-001-class - form-exact findings decode — "expected no member \"condition\"" — arms - unreached until that product gap closes; T11.4-1 unchanged at `view` - unknown-command); scratch run (deleted) of the registered body: - conforming fake (deriving findings/views from workspace bytes) green - end-to-end, seven deviation fakes each failing at the targeted - assertion — fsoperand/wrongkind at the operand exit-2 arms, fsglob/ - codeglob/wsfindings at the empty-answer arms, combine at the combining - exit-2 arm, dupview at the decode's strictly-ascending views rejection. - `npm run test:self`: unchanged 4 planned mid-loop reds - (certification-document ×3 → FP-091, its in-scope-registry gap still - exactly {T11.4-3, T11.4-4}; S-1's unmapped keys exactly {11.5, 11.6, - 12.6, 12.7} → stage G), S-5 and certification green.] -- [x] FP-062 — Implement T11.4-3 (CONF-AVAIL): raw attributes and per-node - data with stated-`null` root `tags`/`coverage`. [R2 #4, R3 gap 1, VERIFY; - TEST-SPEC §11.4] - [Done 2026-08-14: T11.4-3 registered in section-11.4.ts (wrapper - auto-declares; traceability `"T11.4-3": ["11.4"]` — T11.4-3 is in no - TEST-SPEC 14 staging record, the T11.2-2 precedent). One workspace, two - files, two invocations, inside CONF-AVAIL's staging constraints - (spec-only `.mdx` workspace, no `build` gate, no snapshot compare, - `view` alone — bare + one `<file>` operand — staged conditions 14.17 - only, within the scope's stated set): specs/attrs.mdx stages the - five-attribute tag `<S id="dup" id="dup" note="mystery" {...extras} - tags>` plus `<S id="cov" coverage={"none"}>`; the bare view asserts - every raw attribute entry {name, range, text} byte-exactly in tag order - (the repeated id's BOTH entries; the spread's name the stated null, its - text the whole braced construct; the valueless bare-name tags), exactly - five located 14.17 beside the view (inclusion by form, never an - omission; no 14.1/14.16/14.2/14.3 beside), and per-node - identity/tags/coverage in every legitimate state (unavailable: the - repeated-id identity, the valueless tags, the braced coverage; plain: - cov/ok identities, [] and ["solo"] tags, the absent-prop default - "required" on the spread-bearing tag and "none"; the roots' stated - nulls); the finding-free specs/clean.mdx named as an operand exits 0 - with root tags/coverage null — the no-finding, no-exit-1 root arm - VIOL-AVAIL-OMIT's note names. Verified: typecheck/format clean; suite - red-as-diagnosed at the first invocation (`view` unknown command, exit - 2 — the same product gap as T11.4-1); scratch run (deleted) of the - registered body against a workspace-deriving conforming fake green - end-to-end, nine deviation fakes each failing at the targeted - assertion — nullmarker at the decode's never-null identity, omit at the - decode's absent finding-`path` member, dropinvalid/spreadname at the - attributes compare, takefirst/readbraced/nofinding at the exact 14.17 - count, rootmarker at the root-null tree compare, cleanmarker - (operand-invocation-only misbehavior) at the exit-0 assertion. - `npm run test:self`: unchanged 4 planned mid-loop reds, the - certification-document in-scope-registry gap narrowed exactly - {T11.4-3, T11.4-4} → {T11.4-4} (rest → FP-091; S-1's unmapped keys - {11.5, 11.6, 12.6, 12.7} → stage G); S-5, S-7, and certification - green.] -- [x] FP-063 — Implement T11.4-4 (CONF-AVAIL): imports datum. [R2 #4, R3 - gap 1, VERIFY; TEST-SPEC §11.4] - [Done 2026-08-14: T11.4-4 registered in section-11.4.ts (wrapper - auto-declares; traceability `"T11.4-4": ["11.4"]` — T11.4-4 is in no - TEST-SPEC 14 staging record, the T11.4-3 precedent). One workspace, two - files, one bare `view`, inside CONF-AVAIL's staging constraints - (spec-only `.mdx` workspace, no `build` gate, no snapshot compare, - `view` alone; staged condition 14.15 within the scope's stated set): - specs/imports.mdx opens with the six-declaration matrix, one per line - (the §2.1 byte-window discipline), the valid first declaration's - multi-byte identifier `BÄSE` (Ä: 2 bytes) shifting every later byte - offset off code-point/UTF-16 counts; then the side-effect-only, - named-only (`{ part }`), and namespace-only (`* as ns`) forms each with - the same valid resolving specifier, the valid-form default import of the - undiscovered `./typo.xspec`, and the bare specifier `base.xspec` (a - suffix-keyed resolver bait). Asserted as ONE exact six-entry imports - compare: every declaration, valid and invalid, with its byte-exact range; - `name` the default binding's identifier ("BÄSE"/"TYPO"/"BARE") or the - stated null for the three no-default forms (never the marker — decode - rejects; never `part`/`ns`); `target` turning on specifier form and - discovery ALONE — the invalid binding forms carry the plain - "specs/base.mdx" (name null beside a defined target) while typo/bare are - the literal marker, never null (decode rejects — VIOL-AVAIL-NULLMARKER's - certified failure names exactly these two). Exactly five located 14.15 - beside the view (one per invalid declaration, each within its own - declaration's end-widened window, order decode-pinned), exit 1 with the - full answer; both root-only trees byte-asserted, prose-only - specs/base.mdx viewed with imports []. Verified: typecheck/format clean; - staging premise probed against the built product (`build --json` reports - exactly the five 14.15 in imports.mdx, old shape, at byte-exactly the - ByteFixture declaration ranges — the Ä shift visible); suite - red-as-diagnosed at the one invocation (`view` unknown command, exit 2 — - the same product gap as T11.4-1); scratch run (deleted) of the registered - body: workspace-deriving conforming fake green end-to-end, eight - deviation fakes each failing at the targeted assertion — nullmarker at - the decode's never-null target, omitname/omittags at the decode's - absent-member rejections, namedname/collapse at the imports compare, - dropfinding at the exact 14.15 count, cpranges at the finding-window - byte assertion, exit0 at the exit-1 arm. `npm run test:self`: planned - mid-loop reds narrowed 4 → 3 exactly as predicted — the - certification-document in-scope-registry gate went GREEN (gap {T11.4-4} - → {}), its two fixture-manifest gates stay red → FP-091; S-1's unmapped - keys exactly {11.5, 11.6, 12.6, 12.7} → stage G; S-5, S-7, and - certification green.] -- [x] FP-064 — Implement T11.4-5: `--text` expansion domain. [R2 #4; - TEST-SPEC §11.4] - [Done 2026-08-14: T11.4-5 registered in section-11.4.ts (wrapper - auto-declares; traceability `"T11.4-5": ["11.4"]` — in no TEST-SPEC 14 - staging record, the T11.4-3/-4 precedent; NOT in CONF-AVAIL scope — the - Exclusions name its consultation-domain negatives — so unlike its - siblings it drives a `build --json` staging gate per workspace, the - T11.4-2 precedent). Four workspaces, six views: (1) the chain A→B→C with - X beyond the boundary — A embeds B#b, B holds an unresolved `d={"ghost"}` - (14.5) and embeds C#c, C's `{text(X.dup)}` names X's duplicate pair - (gate-proven staged) so the spelling records no occurrence (14.6, located - exactly at the braced container) and X is never consulted: `view - specs/A.mdx --text` carries exactly {14.5, 14.6} — deep findings in - consulted-never-requested files, X's 14.3 accompanying NOTHING — views - [A] alone with alpha poisoned, the embedding-free sibling and root own - text byte-exact, the resolved embedding's record and import entry - pinned; without `--text` the same request consults A alone — findings - [], exit 0, tree/imports/occurrences flag-independent (the decode - rejects text members absent the flag); (2) entry→loop where loop#l1 - self-embeds — the one 14.9 located at the participating container in - consulted-never-requested loop.mdx, entry's reaching values poisoned; - (3) main→gone (unparseable): the spelling into the masked file records - nothing (occurrences []), `view specs/main.mdx --text` carries exactly - main's own 14.6 — never the 14.20 — while requesting gone too attaches - the 14.20 and gone still contributes NO view (views stay [main]), the - import entry's target the plain path both times (discovery, not - parseability); (4) `specs/vi#ew.mdx` requested as a bare operand (`#` - has no delimiter role, 12.0) keeps its full view — every identity the - marker, text values plain and byte-exact (expansion definedness turns on - occurrence-recording spellings alone), the 14.19 with locations [] and - the file as concerned path. Verified: typecheck/format clean; every - hand-derived text constant and byte range probed byte-identical against - the built product on passing twins (`query node` own/subtree text; - scratch probe, deleted), and every staged finding multiset probed - against the failing stagings — the current product picks a winner among - X's duplicate bearers (no 14.6), reports one-location 14.3, a wide 14.9 - location, a narrow 14.6 range, and old-shape findings, all diagnosed - product gaps the certified T11.2-4 semantics pin; suite red-as-diagnosed - at the chain gate's form-exact findings decode ("expected no member - \"condition\"" — the FP-001-class gap; the `view` surface itself is the - T11.4-1-class unknown-command gap behind it), section-11.4 now 5 failed - / 0 passed. `npm run test:self`: unchanged 3 planned mid-loop reds - (certification-document fixture-manifest ×2 → FP-091; S-1's unmapped - keys exactly {11.5, 11.6, 12.6, 12.7} → stage G); S-5, S-7, and - certification green.] -- [x] FP-065 — Implement T11.4-6: byte classification reproducing compiled - Markdown via the P-2 oracle (`test/helpers/oracles/markdown.ts`). - [R2 #4; TEST-SPEC §11.4] - [Done 2026-08-14: T11.4-6 registered in section-11.4.ts (wrapper - auto-declares; traceability `"T11.4-6": ["11.4"]` — in no TEST-SPEC 14 - staging record, the T11.4-5 precedent; NOT in CONF-AVAIL scope: its - emission loop needs the `markdown` configuration, expressly outside that - scope's workspaces, so the gate-reference `build` and emitted-file reads - are free). Two workspaces. (1) The finding-free emission loop: - specs/host.mdx (import, paired/self-closing sections with `tags`+`d` - props, single- and multi-line comments, external + local embeddings, a - CRLF among LF terminators, multi-byte offsets) beside embedding target - specs/parts.mdx (own local embedding — the expansion chains two levels); - after the `build` gate (exit 0, emitting both .md files), one bare `view` - (exit 0, findings []) byte-asserted whole — trees with decomposition and - attribute entries, imports, occurrences (the `d` reference spanning its - string literal inside the tag; both embedding containers spanning their - whole {text(...)} expressions), comments — then the classification: - module helper `assembleAnnotationSpans` builds every annotation span from - the DECODED view alone (tag decompositions, imports, comments, - embeds-occurrence containers), asserting attribute ranges inside their - opening tag and the depends occurrence inside a tag span (subsumed - annotation bytes) and the spans disjoint/in-bounds/non-empty, compared - exactly to the staged span set; then `reproduceMarkdown` (byte-slices the - staged source at the view's spans, feeds the S-6-vetted P-2 oracle, - expansions = contribution-derived subtree-text constants per SPEC 1.6/3) - must byte-equal BOTH emitted files via assertFileBytes. A fixture - self-check proves oracle(staged spans) === the hand-derived expected .md - constants before any product invocation. (2) The imperfect file: - specs/imp.mdx stages exactly {14.6: a `{text("ghost")}` no-occurrence - spelling; 14.16: `<em>stray content</em>`} beside a valid import, - comment, and resolving embedding into specs/tgt.mdx, gate-pinned; `view - specs/imp.mdx` (exit 1) asserts the em contributes NO tree node and - ghost NO occurrence record, the 14.6's one location EXACTLY the full - braced container (assertUnresolvedEmbedding — the T14-8 pin that keeps - the classification exact), the 14.16 located within the em's construct - window; the classification is re-assembled from the view PLUS the - decoded 14.6 finding's range and compared exactly to the staged spans — - view plus findings position every removable construct, the em's bytes in - no span (content by form, SPEC 11.2). Verified: typecheck/format clean; - scratch probe (deleted) against the built product — W1 builds exit 0 - with BOTH emitted files byte-identical to the hand-derived expectations - and all three expansion constants equal to `query node` subtree text - (the contribution derivation proven against the real §3 machinery), W2 - stages exactly the {14.6, 14.16} multiset with the 14.16 in-window while - the current product locates the 14.6 at the string literal [130, 137) - instead of the full container [124, 139) — the diagnosed FP-064-class - range gap the amended SPEC 14 pins; suite red-as-diagnosed at arm 1's - first `view` invocation (unknown command, exit 2 — the T11.4-1-class - gap; the fixture self-checks and the staging build pass before it), - section-11.4 now 6 failed / 0 passed. `npm run test:self`: unchanged 3 - planned mid-loop reds (certification-document fixture-manifest ×2 → - FP-091; S-1's unmapped keys exactly {11.5, 11.6, 12.6, 12.7} → stage G); - S-5, S-7, and certification green.] - -- [x] FP-066 — Implement T11.5-1: total `at` resolution incl. EOF offset - and derivability from view data. [R2 #5; TEST-SPEC §11.5] New §11.5 - registry module; map `"11.5"`. - [Done 2026-08-14: T11.5-1 registered in new section-11.5.ts (SUITE-55; - wrapper section-11.5.test.ts auto-declares; traceability - `"T11.5-1": ["11.5"]` — no numbered condition asserted, the T11.4-* - precedent; in NO certification scope: CONF-AVAIL's scope statement - expressly excludes `at`). One workspace: specs/total.mdx (295 bytes — - two resolving imports, a top-level and an in-section comment, sections - a ⊃ a.b ⊃ a.b.c beside top-level z, prose before/inside/between - constructs, multi-byte é/è/— shifting every later offset, SPEC 1.7) - beside prose-only specs/base.mdx, composed by the running-offset - builder. Twelve pointwise arms against precomputed constants (the P-12 - anchor CERTIFICATIONS.md names): offsets inside an import, both - comments, deep content, between-section prose, opening tags (a.b's, - a.b.c's — the innermost containing construct, never the parent), - closing tags (a's past a.b's close, z's), a.b's post-child tail - content, and the EOF caret (byte length → root) each pinned to the - exact {identity, range} resolution, occurrence null, findings [], exit - 0 through the form-exact decodeAtReport; byte length + 1 → exit 2 via - expectAvailabilityUsageError (section-11.2's shared T11.2-5 protocol). - Derivability: `view specs/total.mdx` first anchored byte-exactly - against the fixture (tree projection {identity, range, children}, both - import entries, both comment ranges, occurrences [], findings []), - then for EVERY offset 0..295 `at` must equal the exported comparator - `resolveAtFromView` over the DECODED view — a containment descent - realizing innermost resolution, the root-where-none rule, and the EOF - rule in one shape, plus the containing-occurrence pick (null here; - occurrence containment is T11.5-3's subject) — non-circular via the - anchor. FP-088's P-12 imports the comparator from the module. Verified: - typecheck/format clean; fixture self-checks (17 slice checks, a - composed-length pin, comparator ≡ every hand-stated arm on the - precomputed tree) pass before any product invocation; a scratch - cross-validation (deleted) proved the comparator ≡ an independent - brute-force narrowest-containing-range implementation over all 296 - offsets with boundary spot checks (start-inclusive/end-exclusive at - every construct edge, EOF → root); staging probed finding-free against - the built product (`build --json` findings [], `ids` exactly - a/a.b/a.b.c/z); suite red-as-diagnosed at the first arm's `at` - invocation (unknown command, exit 2 — the T11.4-1-class gap; every - self-check passes before it), timeoutMs 360_000 for the - ~310-invocation sweep. `npm run test:self`: the 3 planned mid-loop - reds — certification-document fixture-manifest ×2 → FP-091; S-1's - unmapped keys narrowed {11.5, 11.6, 12.6, 12.7} → exactly {11.6, 12.6, - 12.7} (stage G); S-5, S-7, and certification green.] -- [x] FP-067 — Implement T11.5-2: offset spelling matrix. [R2 #5; TEST-SPEC - §11.5] - [Done 2026-08-14: T11.5-2 registered in section-11.5.ts on the T11.4-2 - matrix ground (SPEC_AND_CODE_CONFIG; finding-free specs/ok.mdx whose one - section `sept` opens at byte 6 behind a multi-byte prose head — offset 7 - inside its opening tag, offset 0 in the prose, both containments - fixture-self-checked, so `007`-as-7 vs as-0 has teeth; specs/bad.mdx with - exactly one 14.3; src/app.ts with exactly one 14.8; undiscovered - docs/note.mdx decoy), all inside one whole-root assertLeavesUnchanged. - Arms: gate `build --json` pinning exactly {14.3, 14.8} located; - `at ok.mdx 007` and `7` each exit 0, findings [], resolution byte-exactly - the precomputed sept {identity, range} with occurrence null (leading - zeros, ASCII decimal); the six rejected spellings `+7`/`-1`/`" 7"`/ - `"7 "`/`0x7`/`""` each exit 2 via expectAvailabilityUsageError (T11.2-5 - protocol) on the finding-free file AND identically on the finding-laden - bad.mdx (checks precede answering); membership/wrong-kind as T11.4-2 — - nowhere-file, on-disk undiscovered decoy, discovered code source (its own - 14.8 notwithstanding) each exit 2; control `at bad.mdx 0` exit 1 with the - full answer (root resolution complete, exactly the one located 14.3). - Traceability "T11.5-2": ["11.5"] (the T11.4-2 precedent: gate staging - integrity adds no "14"); expressly in CERTIFICATIONS.md's Exclusions, so - no fixture scope. Verified: typecheck/format clean; probes against the - built product prove the staging (build --json reports exactly the two - old-shape findings in the right files; ok.mdx finding-free) and the argv - mechanics (empty-string operand passes through; `at` is unknown-command - exit 2, stdout empty, stderr message — so the exit-2 arms fail diagnosed - at the error-document decode until the surface exists); suite: - T11.5-2 red-as-diagnosed at the gate's FP-001-class form-exact decode - ("expected no member \"condition\""), every fixture self-check passing - before it, T11.5-1 unchanged; `npm run test:self` unchanged 3 planned - mid-loop reds (certification-document ×2 → FP-091; S-1 unmapped keys - {11.6, 12.6, 12.7} → stage G), S-5 and certification green.] -- [x] FP-068 — Implement T11.5-3: occurrence containment ends and imperfect - files. [R2 #5; TEST-SPEC §11.5] - [Done 2026-08-14: T11.5-3 registered in section-11.5.ts. One workspace - (SPECS_ONLY_CONFIG), one whole-root assertLeavesUnchanged: finding-free - specs/occ.mdx (multi-byte prose head; blank-line-separated import binding - CIBLE — load-bearing: MDX block grammar makes a paragraph-glued import - prose, see FP-094; section `host` bearing `d={CIBLE.but}` and embedding - `{text(CIBLE.but)}`, both resolving into specs/cible.mdx#but) plus - unparseable specs/casse.mdx (14.20) and, staged where file names are byte - strings (Linux, the T11.2-3 precedent), non-UTF-8-named - specs/nu<0xFF>.mdx (14.19). Arms: gate `build --json` pinning exactly - {14.20 located, 14.19 code/locations-[]/marked-byte path}; eight - containment offsets — each occurrence's start and end−1 → the full 12.7 - record (file, byte-exact range, kind depends/embeds, source node - {identity, range} = host, resolved target), its end and start−1 → none - (start-inclusive, end-exclusive, 1.7), every answer findings [] exit 0 - (per-file domain on the failing workspace), section pinned to host - throughout, arm-table containment fixture-self-checked; `at casse.mdx` at - 0 AND the EOF caret → resolution exactly the unavailability marker (no - root fallback bypasses the mask) beside exactly the located 14.20, exit - 1; four non-UTF-8 spellings — exact path bytes as raw argv (the widened - expectAvailabilityUsageError now takes ArgvValue, runProduct-backed, the - T6.5-5 trampoline), lossy U+FFFD, marked-byte-form JSON round-trip, - percent-encoded — each exit 2 via the T11.2-5 protocol, and - `view --file specs/nu*.mdx` (byte-wise glob) as the one route to its - positions: exit 1, exactly the 14.19 projection, one view with `file` in - marked byte form and the tree byte-exact, every identity unavailable - (projectResolution reuse). Traceability "T11.5-3": ["11.5"] (the §11 - precedent: accompanying-finding assertions add no "14"); expressly - outside CONF-AVAIL ("no in-scope staging drives `at`"), no fixture scope. - Verified: typecheck/format clean; probes against the built product prove - the staging (build --json exactly the two old-shape findings in the right - files, occ/cible contributing nothing — resolution proven by a control - probe where the unseparated import left CIBLE unbound and 14.8 fired at - both spellings) and the channel (`at` unknown-command exit 2 on every - spelling incl. the sh-trampoline raw-bytes leg; `view` likewise); suite: - T11.5-3 red-as-diagnosed at the gate's FP-001-class form-exact decode - ("expected no member \"column\""), every fixture self-check passing - before it, T11.5-1/T11.5-2 unchanged; `npm run test:self` unchanged 3 - planned mid-loop reds (certification-document ×2 → FP-091; S-1 unmapped - keys {11.6, 12.6, 12.7} → stage G), S-5, S-7, and certification green.] - -- [x] FP-069 — Implement T11.6-1: `inventory` anchoring byte-exact, incl. - the E-6 drive-mismatch arm (Linux side; the Windows-subset arm is - FP-093). [R2 #6; TEST-SPEC §11.6, E-6] New §11.6 registry module; map - `"11.6"`. - [Done 2026-08-14: T11.6-1 registered in the new section-11.6.ts - (SUITE-56; wrapper + manifest spread), decoding through a new scoped - form-exact decoder `decodeInventoryAnchoring` (forms.ts/model.ts — the - `decodeInventoryRecordedDatum` scoping precedent: exactly `root` and - `config` as 12.7 path values, every other member unread until FP-070+ - pin the full form) plus the existing `decodeInventoryFindings`; every - arm asserts exit 0 with findings [] (complete finding-free answer, - 12.0/11.6). Arms: workspace root — `.` / `xspec.config.ts`, flag-less - AND `--json` against the same byte-exact expectation (the §11 JSON-only - same-information parity); nested `a/b` — `../..` / - `../../xspec.config.ts` (upward search); sibling and deeper-sibling - directories beside the builder's `work/` root with relative `--config` - — `../work/…` and `../../work/…` (multi-`..` ascent then descent, - joined `/`); the same deep cwd with an ABSOLUTE `--config` spelling — - anchoring unchanged (pure invocation input: cwd + identified file, - never an argument echo); E-6 Linux side — cwd in an unrelated temp tree - (nearest common ancestor outside both trees, the closest Linux staging - to a cross-drive invocation): still the pure relative - ascent-then-descent form, no absolute form ever appears, expectation - computed by a harness-side implementation of 11.6's own spelling rule - over the realpath'd pair (fixed-vector + shape self-checks before any - product invocation), invocation repeated with byte-identical stdout - (deterministic per invocation, 12.0; product-to-itself, H-4). - Traceability "T11.6-1": ["11.6"] (12.0/E-6 carriage context, the §11 - precedent); expressly in CERTIFICATIONS.md's Exclusions ("`inventory` - and `version`"), no fixture scope. Verified: typecheck/format clean; - suite red-as-diagnosed at the first arm's exit-0 assert (the stub has - no `inventory`: unknown-command exit 2); soundness proven by a scratch - mock product implementing the anchoring independently via - path.relative (body green — the two independent rule implementations - agree); teeth probed via seven mock deviations (absolute output, - trailing separator, `./`-prefixed spelling, `--config` echo, omitted - `root` member, phantom finding + exit 1, nondeterministic member), each - failing at exactly the intended assertion (the echo lands on the - absolute-`--config` arm, the omitted member on the form-exact decode, - the nondeterminism on the repeat byte-compare). `npm run test:self`: - planned mid-loop reds now certification-document ×2 (→ FP-091) and S-1 - with its unmapped set narrowed {11.6, 12.6, 12.7} → {12.6, 12.7} - (→ stage G); S-5 88/88 green incl. the new anchoring guards; - certification green (violators failing as certified). FP-093 stays - pending for the Windows-subset drive-mismatch arm; FP-070..072 build - T11.6-2..-4 on this module.] -- [x] FP-070 — Implement T11.6-2: resolved configuration/sources/derived - map. [R2 #6; TEST-SPEC §11.6] - [Done 2026-08-14: T11.6-2 registered in section-11.6.ts (SUITE-56), - decoding through the new scoped form-exact decoder - `decodeInventoryResolvedMap` (forms.ts/model.ts — the - `decodeInventoryRecordedDatum` scoping precedent: exactly - `configuration`/`sources`/`derived` in the full 12.7 member forms — - expectOnlyMembers per object, `null`-never-omission on - `outDir`/`targetTags`/`module`/`markdown`, selector exactly one of - {"group","kind"}/{"files"}/{"tags"}, sources/derived byte order and - uniqueness decoder-enforced — every other member unread until FP-071/072 - pin the rest). Four workspaces, no arm ever running `build`: defaults — - `markdown` key absent → view {"emit":false,"outDir":null} and derived - `markdown` null everywhere; profile/rule spelling only required fields → - `targetTags` null, `targets` "leaves", `boundaryKind` and selector kinds - explicit-though-inferred, `edgeKinds`/`kinds` all three (compared as - sets: 11.6 pins no element order for them; every other list exact — - groups/profiles/rules configuration order with `core` before `aux` - chosen so byte-ordering products fail, sources/derived path byte order); - group references asserted twice (exact configured names in the compare - plus a resolve-against-reported-lists walk); the two-group `shared` - file carrying both memberships in configuration order; a code group so - `sources` spans kinds while `derived` holds spec sources only; flag-less - AND `--json` decoded against one expectation (§11 same-information). - Emission-enabled workspace — module + next-to-source destination both - present pre-build; extension-free glob `specs/*` discovers - `specs/note.txt` (the 14.19 staging beside) listed in `sources` with - membership, `module`/`markdown` the stated null, answer finding-free - exit 0 (the 14.19 is build/check's, never inventory's). outDir - workspace — view echoes "mdout", destinations `mdout/specs/…` preserving - workspace-relative paths, nested source included. Disabled-explicit - workspace — `{emit:false, outDir:"docsout"}` echoed whole while derived - `markdown` stays null (7.3: destinations exist exactly while emission - is enabled). Traceability "T11.6-2": ["11.6"] (7.3/12.7/13.1 - parentheticals context, the §11/FP-014 precedent; no numbered condition - asserted — the 14.19 is staged, not asserted). In CERTIFICATIONS.md's - Exclusions ("`inventory` and `version`"), no fixture scope. Verified: - typecheck/format clean; suite red-as-diagnosed at the first arm's - exit-0 assert (stub: unknown-command exit 2); soundness proven by a - scratch mock implementing the projection independently (config eval + - own glob matcher + own derived arithmetic; body green — the two - implementations agree); teeth probed via ten mock deviations - (omit-boundaryKind/targetTags/selector-kind → the decode, byte-ordered - groups, bare-name groups, byte-ordered memberships, - destinations-while-disabled, non-.mdx skipped from sources, - outDir-ignored, phantom finding + exit 1), each failing at exactly the - intended assertion. `npm run test:self`: unchanged 3 planned mid-loop - reds (certification-document ×2 → FP-091; S-1 unmapped {12.6, 12.7} → - stage G); S-5 90/90 green incl. the new resolved-map guards; - certification green 17/17 (violators failing as certified). FP-071/072 - build T11.6-3/-4 on this module and may widen the scoped decode to the - full top-level form.] -- [x] FP-071 — Implement T11.6-3: record, area, durables, orders. [R2 #6; - TEST-SPEC §11.6] - [Done 2026-08-14: T11.6-3 registered in section-11.6.ts (SUITE-56), - decoding through the new full-form `decodeInventoryDocument` - (forms.ts/model.ts — FP-070's stated widening: exactly the ten 12.7 - top-level members via expectOnlyMembers, composed from the existing - scoped decoders plus the `graphData` path value, `journal` - `{"path","occupied"}`, and `sessions` decode with byte-order-of-file-name - enforcement; `decodeInventoryRecordedDatum` additionally now enforces the - recorded list's byte order and uniqueness, strengthening its existing - T12.2-2/T13.3-2 callers). Two workspaces. Record/area/journal workspace - (two spec groups `zz` before `aa` — configuration order inverts byte - order — emission on): pre-build arm flag-less AND `--json` against one - expectation — recorded exactly {state value, []} (never null/unavailable), - graphData exactly ".xspec", journal exactly {".xspec/journal", false}, - sessions [], groups ["zz","aa"], derived-with-markdown as the lag - baseline; post-build arm — both modules and both emitted .md pinned - present in recorded, every further entry attributable through the 13.1 - naming scheme (`<dir>/<NAME>.xspec.<suffix>` beside a discovered - `<dir>/<NAME>.mdx` — companions asserted by attributability, not - enumeration, since 13.1 pins no companion set), byte order - decoder-enforced; foreign file `.xspec/zzz-artefact-etranger.bin` staged - after the build — exact sources/sessions compares plus the attribution - rule exclude it from every list and a document-wide byte scan asserts it - is never claimed; lag arm — config rewritten to emit:false without - rebuild: view/derived report the new configuration (markdown null) while - recorded still carries the .md paths (as recorded, not as configured); - journal occupancy arms — garbage plain file, directory, broken symlink - each {occupied:true} with the finding-free exit-0 frame (no 14.13 from - inventory; the broken link discriminates stat-through-the-link - products). Sessions workspace: product-written session (`review create - --strategy audit --name ancien`, plain-file premise pinned), garbage - S.json, directory S2.json → sessions exactly [S.json, S2.json, - ancien.json] in byte order of file name (0x53 'S' < 0x61 'a' — case - folding inverts it), findings [] (no 14.21 here), notes.txt/.foo.json - never listed and absent from the document bytes. Traceability - "T11.6-3": ["11.6"] (13.3/13.1/6.1/10.1/12.7 context, home coverage - elsewhere; findings-[] arms assert no numbered condition — no "14", the - T11.6-2 precedent). In CERTIFICATIONS.md's Exclusions, no fixture scope. - Verified: typecheck/format clean; S-5 gains the full-document guards - (good + recorded-unavailable/empty-sessions positives; ten bad shapes - incl. extra top-level member, journal form breaks, session order) and - two recorded-order rejections — all green; suite red-as-diagnosed at the - first arm's exit-0 assert (stub: unknown command 'inventory', exit 2); - soundness proven by a scratch mock implementing the 11.6 projection - independently (config eval + own glob matcher + own derived/record/ - session arithmetic; body green — the two implementations agree); teeth - probed via eleven mock deviations (trailing-separator area, stat-journal - broken-link occupancy, journal-content-read 14.13, recorded-as-configured, - record-without-markdown, claim-foreign, sessions-any-name, - sessions-casefold order, sessions-files-only, recorded-unavailable, - groups-byte-order), each failing at exactly the intended assertion. - `npm run test:self`: unchanged 3 planned mid-loop reds - (certification-document ×2 → FP-091; S-1 unmapped {12.6, 12.7} → stage - G); certification green (violators failing as certified). FP-072 builds - T11.6-4 on this module and decoder.] -- [x] FP-072 — Implement T11.6-4: no-parse/no-write/one-finding - (condition-23, `recorded` unavailable). [R2 #6; TEST-SPEC §11.6, SPEC - 14.23] - [Done 2026-08-14: T11.6-4 registered in section-11.6.ts (SUITE-56), three - arm groups on FP-071's full-document decoder. Imperfect workspace (sources - failing every validation family + garbage journal line + corrupt session - `.xspec/reviews/louche.json`): staging premise pinned first, FP-016 style - — `build --json` exit 1 with exactly the staged multiset {14.1–14.9, - 14.11, 14.13, 14.15–14.20, one each} across MDX and TS (no 14.21 — build - reads no sessions; no 14.10/14.12 — check-only), probe-confirmed - byte-for-byte against the real product's build on the identical staging; - then flag-less and `--json` inventory against ONE expected document inside - a single whole-root `assertLeavesUnchanged` — the complete ten-member - document asserted exactly (unparseable casse.mdx listed in sources AND - carrying module/markdown in derived — discovery/configuration-determined, - the parse-driven-product discriminator; note.txt null/null; recorded [] - after the failed build, 12.1; journal occupied true; the corrupt session - listed by name), findings [] at exit 0 realizing "reported where their - conditions assign them, never here". Config-precedence arm: missing (bare - tree, T7-1 operationalization) and invalid (garbage TS beside a valid - source) each asserted flag-less (JSON-only surface — new local helper - `expectFlaglessInventoryConfigurationError`: exit 2, error document, - stable code `configuration-error`, non-null concerned path, /config/i - stderr) and via `expectConfigurationError` with `--json`; "no inventory" - is the decode's single-`error`-member enforcement. Corrupt-record arm: - valid built workspace, intact-record premise via `assertRecordedDerivedPaths` - (module+Markdown pinned), `corruptGraphDataShapeBlind` (T6.6-6's shared - staging), then both output forms inside one whole-root compare — exit 1, - exactly {"14.23": 1} (the token table pins `unreadable-record`), - concerned path `.xspec`, locations [], `recorded` exactly - {state:"unavailable"} (never read as empty), and every other member - deep-equal to the intact answer via `inventoryApartFromRecordSupplied`. - Traceability "T11.6-4": ["11.6", "14"] (TEST-SPEC 14's primary-test - record lists T11.6-4 under 14.23; 14.14/12.7/13.3/12.1 context, home - coverage elsewhere). In CERTIFICATIONS.md's Exclusions, no fixture scope. - Verified: typecheck/format clean; suite red-as-diagnosed at the premise - build's form-exact findings decode (the FP-001-class product gap — the - product still emits `condition`-member findings; inventory itself is - still unknown-command exit 2, the T11.6-1..-3 diagnosis); soundness - proven by a scratch mock implementing the 11.6 projection independently - (config extraction + own glob matcher + discovery walk + derived/record/ - session arithmetic, record unavailability by actual parse failure; the - premise findings hardcoded in 12.7 form/pinned order, their multiset - anchored by the real-product probe) — the whole registered body ran green - against it via `entry.run(mockBinding)`; teeth probed via seven mock - deviations (read-empty, session-content, refresh, parse-derived, - no-precedence, concern-inside, repair), each failing diagnosed at the - intended assertion. `npm run test:self`: unchanged 3 planned mid-loop - reds (certification-document ×2 → FP-091; S-1 unmapped {12.6, 12.7} → - stage G), 264 passed, certification green (violators failing as - certified).] - -- [x] FP-073 — Implement T12.0-13: multi-`#` operand malformedness vs `#` - in `<file>`/`--file` values (`specs/a#b.mdx` staging). [R2 #7; TEST-SPEC - §12.0] Registry `section-12.0-i.ts` or `-ii.ts`; map `"12.0"`. - [Done 2026-08-14: T12.0-13 registered in section-12.0-ii.ts (SUITE-42) on - one workspace — valid specs/OK.mdx beside specs/a#b.mdx (content - condition-free; multi-byte prefix; section `pa` spelled so the multi-`#` - operand `specs/a#b.mdx#pa` is a last-`#`-split trap: both halves name - real staged things). Staging premise FP-016 style: `build --json` exit 1 - with exactly one pinned 14.19 ({invalid-source-path, locations [], path - specs/a#b.mdx}), whole-root compare. Malformed arms: the entry's literal - `a#b#c` and the trap spelling, each on `show`, `query node`, - `occurrences --to`, and both `move` operand sides (destination = the - T6.5-4 dead-letter spelling, `#` in the section form's target-file part; - moves in whole-root modifies-nothing compares) — each exit 2 via the - FP-002 protocol (single 12.7 error document under `--json`, stderr - nonempty), the usage error preceding the failing workspace's findings - (12.0). Whole-path arms: `view specs/a#b.mdx` → exit 1, exactly its one - per-file view (membership holds — never a specs/a + b.mdx pair, which - would be exit 2 unknown file), tree projection pinned with every node - identity the unavailability marker; `at specs/a#b.mdx 0` → exit 1, root - construct + marker, occurrence null; `occurrences --file specs/a#*` → - exit 1 with exactly the 14.19 (domain membership = the pattern matched) - against the matching-nothing control `specs/zz#*` (exit 0, empty, - finding-free, 11.3). Traceability "T12.0-13": ["12.0"] (FP-016 - precedent: in no TEST-SPEC 14 staging record — the premise 14.19 rides - staging integrity, the T11.2-3 precedent; 11.2-11.5/12.7/6.5 carriage - context, home coverage elsewhere). In CERTIFICATIONS.md's Exclusions - only, no fixture scope. Verified: typecheck/format clean; suite - red-as-diagnosed at the premise build's form-exact findings decode (the - FP-001-class gap — the product emits condition-member findings; - section-12.0-ii 3→4 failed / 3 passed, the other three the pre-existing - T12.0-9/-10/-12 reds); every downstream arm probed against the built - product: the show/query-node multi-`#` arms exit 1 (the gated - invalid-workspace report — the product runs no multi-`#` value check, - the FP-039-noted precedence-gap family), `occurrences`/`view`/`at` exit - 2 unknown-command (patch-new surface; the --to arms' exit-2 coincidence - still fails at the absent error document, FP-002-class), the move - destination arm exit 1 (the product proceeds into the invalid-workspace - refusal — exactly the accepting-product path the arm discriminates), the - move origin arm exit 2 "unknown file 'specs/a'" (first-`#` split) - failing at the absent error document — all probes modifying nothing. - Soundness proven by a scratch conforming mock (config extraction + own - glob matcher + discovery walk + 14.19 arithmetic + byte-offset MDX - section/attribute scan) running the registered body green via - entry.run(mockBinding), with eight deviation mocks each failing at the - intended assertion: split-last (first --to arm's exit code), - split-last-checked (the trap arm alone catches a product rejecting only - unresolvable multi-`#` spellings), no-error-doc (the error-document - decode), view-split (view membership exit), glob-split (occurrences - --file exit), move-writes (the whole-root compare), - identity-over-invalid (the tree projection), extra-finding-attach (the - control's exit 0). `npm run test:self`: unchanged 3 planned mid-loop - reds (certification-document ×2 → FP-091; S-1 unmapped {12.6, 12.7} → - stage G), 264 passed, certification green (violators failing as - certified).] - -- [x] FP-074 — Implement T12.6-1 and T12.6-2: the `version` command. - [R2 #8; TEST-SPEC §12.6] Form-exact `{"product","interface"}` with - `interface` exactly `"1"`; workspace/configuration independence with the - `build`-exits-2 discriminating pair. New §12.6 registry module (may share - a module/wrapper with §12.7 tests); map `"12.6"`. - [Done 2026-08-14: new registry module section-12.6.ts (SUITE-57) + thin - wrapper, spread into the manifest. The version document decodes through a - new form-exact decoder (forms.ts `decodeVersionDocument`, model.ts - `VersionDocument`): exactly {"product","interface"}, both strings — H-3 - fixes 12.6's document form — with S-5 DECODERS guards (positive control, - empty-informational-product alsoGood, missing-member/null-product/ - numeric-interface/extra-member/error-document-shape rejections). - T12.6-1: bare and flagged forms each exit 0 with a single JSON document - as the entire stdout, decoded form-exactly, `interface` pinned "1"; - values identical across the bare, flagged, and repeated runs ("fixed per - build" as product-to-itself value identity, H-4 — whole-document byte - determinism stays T12.0-7's); unknown flag → exit 2 with the 12.7 error - document as the entire stdout (JSON in effect on the JSON-only surface, - no --json given) and nonempty stderr (T12.0-2; code/path values stay - T12.7-3's). T12.6-2: the valid-workspace answer is the byte reference - (decoded once); byte-identical stdout at exit 0 in the - no-discoverable-configuration directory, with invalid configuration - present, and with --config naming a nonexistent and a malformed file; - the no-config context's premise pinned in-test (`build` there fails - 14.14 via expectConfigurationError — an ancestor config accidentally - reachable would silently weaken the context) and the discriminating pair - asserted on the invalid-config fixture (`build` exit 2, stable code - configuration-error, on the very fixture `version` answers from at exit - 0). Traceability: both → ["12.6"] (the plan's mapping; the premise/pair - 14.14 rides staging integrity, the FP-016/FP-073 precedent — T12.6-2 is - in no TEST-SPEC 14 per-condition record, the never-`version` - reporter-matrix clause living at T14-4). In CERTIFICATIONS.md's - Exclusions only, no fixture scope. Verified: typecheck/format clean; - both tests red-as-diagnosed against the built product at their first - exit-0 assertion (exit 2 "unknown command 'version'" — the whole 12.6 - surface is patch-new); soundness proven by a scratch conforming mock - running both registered bodies green via entry.run(mockBinding), with - eleven deviation mocks each failing at the intended assertion: - wrong-form and extra-member (the form-exact decode), wrong-interface - (the "1" pin), unstable-values (the fixedness compare), no-error-doc - (the error-document decode), quiet-stderr (the stderr assertion), - consults-config (the no-config context's exit), consults-dashdash-config - (the never-consulted --config arm), context-answer (the byte compare), - accepts-invalid (the discriminating pair), no-premise (the staging - premise). `npm run test:self`: 266 passed, planned mid-loop reds - narrowed to 3 — certification-document ×2 (→ FP-091) and S-1 unmapped - now exactly {12.7} (→ stage G: FP-075..077); certification green - (violators failing as certified).] - -- [x] FP-075 — Implement T12.7-1: 12.7 value forms — range, byte-form - paths, the `{"unavailable": true}` uniqueness walk, finding form. Uses - FP-001's three-state datum decode; S-5 guards that walk (FP-001). - [R2 #9, R2 #41; TEST-SPEC §12.7] New §12.7 registry module; map `"12.7"`. - [Done 2026-08-14: new registry module section-12.7.ts (+ wrapper, - index spread, traceability "T12.7-1": ["12.7"] — the FP-016/T12.0-13 - precedent: every staged condition's primary test lives elsewhere in - TEST-SPEC 14's record, so no "14"). Five arms: (A) located findings — - one 14.3 whose two bearers order by start within one file and one 14.9 - (spec import cycle, bindings deliberately unused so no dependency cycle - rides beside) whose two participating import declarations order by file - bytes across files; exact code/path-null/location-file projections plus - containment in disjoint byte windows in the expected sequence, which - observes the location order by value. (B) the 14.12 finding's pinned - enumeration [rule, source, kind token, target] with locations []/path - null via `check --json`. (C) one located 14.11 whose identities must - name the foreign module (distinctive-stem containment, the T4.4-1 - operationalization). (D) a review refusal (`review create` onto an - existing name, audit strategy — git-less) as a nonempty findings-only - report with every finding's `code` null (cardinality unpinned by SPEC - for review refusals — noted). (E, Linux-leg staging per TEST-SPEC; the - T11.2-3 conditional-staging discipline, no skip) a non-UTF-8 DIRECTORY - `specs/d<0xFF>/` — the one way an import's resolved target can be a - non-UTF-8 path, `./Tgt.xspec` resolving against it — holding In.mdx - (imports `../OK.xspec` + `./Tgt.xspec`, embeds `{text(OK.ok)}`, and an - id-less `<S>` — the located finding INSIDE a non-UTF-8 file; 11.2: - validation is parse-local) and Tgt.mdx: byte-form paths asserted - byte-exactly (composed from the staging bytes) at every output the 12.0 - rule names — build's 14.1 location file and 14.19 concerned paths, the - occurrence's referencing file (source exactly the marker, target a - plain identity), the view's file members and the `./Tgt.xspec` import's - resolved target (the `../OK.xspec` one plain beside it), inventory - sources and derived module paths (exit 0 — 11.6: inventory parses no - sources and carries no finding but 14.23; markdown null with emission - disabled) — with the embed occurrence's range byte-exact (the range-form - value assertion). The T12.7-1 walk now runs over every 12.7 document the - suite captures: assertUnavailabilityMarkerForms integrated at every - forms.ts document-decode entry point (scoped decoders included, whose - unread members it covers), called explicitly on the arm-E captures, and - S-5 gains the integration guard (scoped inventory/view decodes reject a - near-marker in an unread member; exact-marker positive control). - Verified: typecheck/format clean; suite red-as-diagnosed at arm A's - first decode (the FP-001-class product gap — the product still emits - condition-member findings; §11 surfaces and review create are patch-new, - probes: occurrences/view/inventory exit 2 "unknown command", refusal - emits {"refused":…}, build skips invalid-path files' content 14.1); - staging premises proven by direct product probes (arm A: exactly one - old-shape 14.9 + one 14.3, the cycle located at A's declaration [31,56); - arm B: build 0/check 1 with exactly the one 14.12 naming rule and edge; - arm C: exactly one 14.11 at [103,120) within the window, message naming - FOREIGNMOD; arm D: create 0 then refuse 1); soundness proven by a - scratch conforming mock running the registered body green through all - five arms via entry.run(mockBinding), with eight deviation mocks each - failing diagnosed at the intended assertion (locations-order, - range-form, identities-order, no-foreign, coded-refusal, nullmarker, - lossy-paths, near-marker — the last caught by the integrated walk). - `npm run test:self`: 268 passed, planned mid-loop reds narrowed to 2 — - certification-document ×2 (→ FP-091); S-1 fully green (the "12.7" key - mapped; unmapped set now empty), S-5 and certification green (T12.7-* - stay certification Exclusions, no fixture scope).] -- [x] FP-076 — Implement T12.7-2: findings-array ordering + document forms. - [R2 #9; TEST-SPEC §12.7] - [Done 2026-08-16: four arms in section-12.7.ts. (A) Ordering+collapse: one - workspace stages 14.1 ×3 (two id-less sections in E1.mdx — range-start - order between one file's findings — plus one in a `main`+`extra` two-group - file, the identically-staged-duplicate collapse: exact counts pin one - finding, membership pinned via the inventory `sources` entry), 14.3, 14.5, - 14.9 (import cycle, unused bindings), 14.15 (named-only import designating - an existing source), 14.19 ×2 plain-`#` paths (+ Linux a non-UTF-8 - `specs/A<0xFF>.mdx` whose marked byte form sorts BEFORE the plain strings - — one byte order over both presentation forms); the numeric code order - inverts both token-alphabetical (`cycle` < `missing-id`) and - ordinal-decimal-string ("15" < "3") orders, the exact projected sequence - asserted on `build --json` AND on the gated `query nodes` read (13.3: - the same findings-only {"findings"} document — the gated-read form), the - inventory additionally pinning the entry's named unset-`outDir`-is-null - example. (B) The T14-7 dual refusal: a section move staged to both - collide (`keep.sub` present in the target) and create a dependency cycle - (moved node depends on `keep`, would become its child; no third reason - applicable — nothing references the moved node) → exactly - [refused-id-collision, refused-cycle], 14's LISTED order inverting the - alphabetical, paths null, concerns SOME-quantified in byte windows - (FP-007's latitude). (C) Identities tie-break: two forbidden rules - (declared "rb" then "ra") on one edge → two 14.12 findings equal up to - the rule name, ["ra"…] before ["rb"…] by identity bytes — failing a - configuration-order emission (the current product emits rb-first, probe - below). (D) Document forms on one valid workspace: finding-free - `check --json` {"findings": []}; `occurrences` with the byte-exact - record (source node's own construct range, 5.7); `view` bare vs `--text` - through decodeViewReport's decoder-enforced conditional presence, the - eight-member node form byte-exact (root attributes [], opening/closing - stated null; spelled attribute entries; interpreted defaults tags - []/coverage "required" on the attribute-free leaf; import entry; - expanded text values content-asserted); `at` with `occurrence` null - (offset in no occurrence); `version` decode. Comparator levels beyond - the stageable (locations proper-prefix, null-before-path, message) - admit no product-independent discriminating fixture (module header - note, the T6.6-4 precedent) — the full pinned comparator rides every - captured findings array via decodeFindingsArray. No new adapters (S-5 - untouched); traceability "T12.7-2": ["12.7"] (the T12.7-1 no-"14" - precedent: every staged condition/reason has its primary in 14's - records elsewhere). Verified: typecheck/format clean; suite - red-as-diagnosed at arm A's first decode (the FP-001-class gap — the - product emits condition-member findings; §11 surfaces/version/inventory - exit 2 "unknown command"); staging premises proven by direct probes - against the built product (arm A: exactly the staged old-shape - multiset incl. the two-group file's finding reported ONCE, gated query - {"findings"} exit 1; arm B: build 0, the dual move refused exit 1 - naming the collision, the cycle ground proven alone via a - non-colliding `keep.fresh` variant refusing on the 14.9 cycle; arm C: - 14.12 ×2 in rb-first config order — the discriminated ordering; arm D: - build/check 0 with {"findings": []}); soundness proven by a scratch - conforming mock running the registered body green through all four - arms via entry.run(mockBinding), with ten deviation mocks each failing - diagnosed at the intended assertion (alpha-codes, ordinal-string, - dup-uncollapsed, refusal-alpha, ids-config-order, text-always, - occurrence-omitted, findings-null, root-attrs-null, outdir-omitted). - `npm run test:self`: 268 passed, unchanged 2 planned mid-loop reds - (certification-document ×2 → FP-091), S-1/S-5/certification green - (T12.7-* stay certification Exclusions, no fixture scope).] -- [x] FP-077 — Implement T12.7-3: the exit-2 error document, incl. the - `configuration-error` stable code and the anchoring-form concerned path. - Pairs with FP-002's protocol. [R2 #9; TEST-SPEC §12.7] - [Done 2026-08-16: T12.7-3 registered in section-12.7.ts, four arms over - FP-002's protocol (`expectErrorDocument` → the existing S-5-guarded - `decodeErrorDocument`; no new adapters). (A) Configuration-error concerned - paths, each pinned byte-exactly as - {code: "configuration-error", path, locations: []} (locations [] per SPEC - 14's unlocated-condition class): the found xspec.config.ts from the root - (`xspec.config.ts`) and from nested/inner (`../../xspec.config.ts` — - ascent spelled `..`, failing a workspace-relative reporter); a - `--config ./cfg/broken.config.ts` argument reporting the canonical - `cfg/broken.config.ts` (11.6: no `.` segments — failing a verbatim echo); - `--config missing.config.ts` reporting the named file, never `.`; bare - `inventory` under the invalid configuration (JSON-only surface, no - --json). (B) Failed upward search with no --config → path exactly `.` - from the root AND from nested/inner (T7-1's no-ancestor-config premise). - (C) One finding however many defects: three independent 14.14 defects - (unknown top-level key, out-of-root glob, unknown markdown field) in one - declarative file — cardinality enforced by the decode (one JSON document - as the entire stdout, one `error` member, one finding form). (D) Plain - usage errors carry code AND path null: `inventory --definitely-not-a-flag` - (JSON-only, unknown flag, no --json) and `definitely-not-a-command - --json`; every exit-2 arm asserts non-empty stderr (diagnostics; - invariance stays T12.0-2's). Traceability "T12.7-3": ["12.7"] (the - T12.7-1 no-"14" precedent — 14.14's primaries are T7-1..T7.5-1); T12.7-3 - stays a certification Exclusion (named in the Exclusions' 12.7-sweep - paragraph). Verified: typecheck/format clean; suite red-as-diagnosed at - arm A's first decode (the FP-002-class gap — probes show every staged - invocation class exits 2 with the diagnostic on stderr and byte-EMPTY - stdout; `inventory` still exit 2 "unknown command"); soundness proven by - a scratch conforming mock running the registered body green through all - four arms via entry.run(mockBinding), with 11 deviation mocks each - failing diagnosed at the intended assertion (ws-relative, echo-config, - missing-dot, inv-human, search-abs, multi-docs, error-array, usage-coded, - usage-path, stderr-quiet, located). `npm run test:self`: 268 passed, - unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091), - S-1/S-5/certification green.] - -- [x] FP-078 — Implement T13.4-8: writes create missing directories - (file-form move, section-form move target, first emission under nested - `outDir`). [R2 #10; TEST-SPEC §13.4] Registry `section-13.4.ts`; map - `"13.4"`. - [Done 2026-08-16: T13.4-8 registered in the existing section-13.4.ts - (the wrapper already declares the module array) with the three named - cases, each staged with its directories absent beforehand - (premise-asserted at the last instant before the write) and its fresh - components asserted as real directories afterward via lstat kind (a - symlink component would violate 13.4's never-traverse rule): file-form - `move specs/A.mdx new/deep/b.mdx` (destination in the configured spec - group, `new/` still absent after the premise build) exit 0 with the - moved file byte-identical at the destination (import- and - reference-free staging, so relocation changes no bytes; H-4 "6.5 move - edits"), origin absent, and the regenerated module `new/deep/b.xspec.ts` - plus emitted `new/deep/b.md` under the fresh directories (13.1's - in-source-directory name rule and 13.2's next-to-source default — the - two SPEC-pinned per-source derived paths; companion sets are latitude, - content other tests' subject); section-form `move specs/S.mdx#mv - fresh/sub/T.mdx#mv` (kept-ID cross-file move, valid per 6.5; target - path under absent `fresh/`) exit 0 with the created target file's - entire initial content byte-pinned to the moved construct + one U+000A - (6.5: created empty, top-level insertion at the start of the empty - file, no import additions required) plus module and Markdown under the - fresh directories; first emission under nested nonexistent - `outDir: "out/md"` (two sources, one nested deeper) — `build` exit 0, - the whole chain out → out/md → out/md/specs → out/md/specs/sub real - directories, both destinations written. Traceability "T13.4-8": - ["13.4"] (6.5/7.3/13.1/13.2 are carriage context with home coverage at - T6.5-*/T7.3-1/T13.1-*/T13.2-1; no numbered condition asserted — - FP-078's stated map); no certification scope (T13.4-5 remains §13.4's - only in-scope test). NOT red against this repo's product: move - directory-creation and nested-outDir emission predate the patch — - direct CLI probes of all three stagings returned exactly the staged - trees, bytes included, so the pass is genuine (the FP-010/FP-012/FP-014 - precedent); teeth live in the exact pins (exit 0, real-directory kinds, - byte-identical moved/created files, pinned derived paths) and in S-7, - where the new body fails diagnosed at its first `buildOk` against the - empty stub. Verified: typecheck/format clean; section-13.4 went - 1 failed/5 passed → 1 failed/6 passed (T13.4-8 green; T13.4-6 keeps its - pre-existing FP-001-class form-exact product red, confirmed unchanged - by a stashed control run); `npm run test:self` 268 passed, unchanged 2 - planned mid-loop reds (certification-document ×2 → FP-091), - S-1/S-5/S-7/certification green.] - -- [x] FP-079 — Implement T14-6: stable codes — all 23 condition tokens read - from each condition's stated reporter; `code` `null` for plain usage - errors and review refusals. [R2 #11; TEST-SPEC §14] Registry - `section-14.ts`; map `"14"`. - [Done 2026-08-16: T14-6 registered in section-14.ts (the wrapper already - declares the module's array; traceability "T14-6": ["14"] — 12.7/12.0 - carriage context, the T12.7-* precedent). Each condition staged via its - primary-fixture form and read from ONE stated reporter of T14-4's - matrix: the 18 both-reporter conditions ride T14-4's own SWEEP_ENTRIES - stagings read via `build --json`; the five specially-reported - conditions (14.10 stale → `check`; 14.12 policy → `check`; 14.14 - unknown key → `build --json`'s exit-2 error document; 14.21 garbage - session → `check`; 14.23 shape-blind record corruption → `inventory` - at exit 1) ride stagings hoisted out of T14-4's dedicated arms into - shared module constants (STALE_DECL/STALE_EDIT, POLICY_DECL, - VALID_SPECS_DECL, GARBAGE_SESSION_*, BOGUS_KEY_DECL — a pure lift, - T14-4's assertions untouched). Per-arm assertion - (`assertExactCodeToken`): at least one finding and EVERY finding's - `code` strictly equal to the harness-pinned token - (CONDITION_CODE_TOKENS[N-1]) — sound because every staging stages - exactly one condition (T14-4 pins the counts; 14.3's per-occurrence - tolerance and 14.10's several stale files collapse into it) — so an - omitted, misspelled, null, wrong-condition, or numeral-decorated code - fails even with exit class and locations right; count precision and - reporter breadth stay T14-4's and the home tests'. The `code`-null - arms mirror T14-6's own citations: T12.7-3's unknown command beside - `--json` (error document, code null) and T12.7-1 Arm D's - duplicate-name `review create --strategy audit` refusal - (findings-only report, every finding code null). No certification - scope (CERTIFICATIONS.md certifies the code contracts - representatively via CONF-AVAIL's datum-form violators). Verified: - typecheck/format clean; T14-6 red-as-diagnosed at its first sweep - arm's form-exact decode ("expected no member \"condition\"" — the - FP-001-class product gap), T14-1..T14-5 failing exactly as before - (T14-4 still at its 14.10 arm — the hoist is semantics-preserving); - direct CLI probes of the T14-6-specific reads against the built - product: the review-create refusal fires (old-shape `{"refused":…}` → - the findings-report decode gap), `inventory` is unknown-command exit 2 - (patch-new surface), unknown-command and bogus-config `--json` exit 2 - with empty stdout (the FP-002-class error-document gap) — every arm - red at a known product-gap class with its staging premise holding. - `npm run test:self` 268 passed, unchanged 2 planned mid-loop reds - (certification-document ×2 → FP-091); S-1/S-5/S-7/certification green - (S-7 sweeps T14-6 against the stub, diagnosed).] -- [x] FP-080 — Implement T14-7: refusal reasons — each stable refusal code - with concerned file/range/identity; all-applicable-reasons-together; the - invalid-workspace refusal reporting numbered findings alone. [R2 #12; - TEST-SPEC §14] - [Done 2026-08-16: T14-7 registered in section-14.ts. The per-reason sweep - stages the reasons via the home fixtures (TEST-SPEC 14 preamble: staged at - T6.4-3/T6.5-4/T6.5-6/T6.6-3) — section-6.4's RENAME_REFUSAL_* and - section-6.5's MOVE_REFUSAL_*/stageMoveRefusalOccupants/MOVE_DERIVED_PATH_* - exports, the T6.6-3 reuse precedent — through a new reporting-contract - helper (`assertRefusalReport`): exit 1, form-exact 12.7 findings-only - report, exact finding multiset (one finding per applicable reason, none - beside — the exact counts also realize "identity-unchanged alone", the - intrinsic-only "never both", and "never 14.22"), plus each reason's - concerned file/range/identity via the support.ts SOME-quantified helpers; - modifies-nothing compares, journal discipline, and preview equivalence - stay the home tests' subject. T14-7's own stagings add what no home table - stages: (i) a plain file as a directory component of the destination path - itself (`specs/blocked`; matches no glob, under no write path, premise - `build` passes) → refused-invalid-destination concerning - specs/blocked/Out.mdx, never 14.22; (ii) the both-collide-and-cycle - section move (occupant child `keep.mv` remains after the removal while - `mv` `d={"keep"}` would become `keep`'s child, SPEC 5.3) → exactly - {refused-id-collision, refused-cycle}, never only the first, the collision - locating the occupant construct, the cycle the participating `d` spelling; - (iii) the invalid-workspace refusal with the rename staged to ALSO collide - — a control on the valid twin pins the premise (exactly - refused-id-collision locating the remaining `a.sib` bearer), then Bad.mdx - broken (14.5) → exactly the one located 14.5 finding, no refusal reason - evaluated or reported beside it. refused-unresolvable-reference: no arm - (admits no fixture; the always-passing side of successful operations); - the exact self-move's identity-unchanged stays at its home T6.5-6. - Traceability "T14-7": ["14"] (6.4/6.5/5.3/12.7/12.0 carriage context, the - T14-6 precedent); no certification scope. Verified: typecheck/format - clean; T14-7 red-as-diagnosed at the first rename case's form-exact - decode ("expected no member \"refused\"" — the FP-001/FP-007-class - product gap), section-14 6→7 failed with T14-1..T14-6 unchanged; direct - CLI probes against the built product prove the new stagings — the - component arm's control twin succeeds without the occupant (creates - specs/blocked/Out.mdx, pinning component occupancy as the sole defect) - while with it the product internal-errors exit 70 (the - vets-only-own-components gap class FP-018 diagnosed); the multi staging's - reasons each fire individually (collision-alone twin refuses on the - collision; cycle-alone twin reports the would-be keep→keep.mv→keep cycle) - while combined the product reports only the collision — the - only-the-first-found gap the arm rejects; the invalid-workspace probes - behave as specced in the old shape (control: collision refusal; broken: - the one 14.5 finding alone). `npm run test:self` unchanged 2 planned - mid-loop reds (certification-document ×2 → FP-091); S-1/S-5/S-7/ - certification green (S-7 sweeps T14-7 against the stub, diagnosed).] -- [x] FP-081 — Implement T14-8: location cardinality — one finding locating - every participant (triple-duplicate ID, import collision, cycle full - path, embedding container span); within-finding location order. [R2 #13; - TEST-SPEC §14] - [Done 2026-08-17: T14-8 registered in section-14.ts — six own stagings, - each arm's condition multiset exact and its participants asserted - index-wise via new every-participant helpers - (`assertFindingLocatesParticipants`: exact location count, per-participant - byte window, `path` null; `locationsMatchParticipants` classifies the - two-cycle report), the every-participant strictness the home tests - SOME-quantify (T1.3-5/T2.1-5/T5.3-1/T14-7 notes): (i) triple-duplicated - ID → exactly one 14.3 finding with three locations, one per bearer - window; (ii) two imports binding `A` → exactly one 14.15 finding locating - both declarations, the first included; (iii) cross-file `depends` cycle - a→b→a with its unavoidable mutual-import spec import cycle (the T5.3-1 - rationale) → exactly two 14.9 findings, told apart by disjoint windows — - one locating every participating reference spelling (the `d`-bearing - elements, one per file), one every participating import declaration; - (iv) pure mutual-import cycle with unused bindings (SPEC 2.1: valid - individually, recording no edges) → exactly one 14.9 finding locating - both declarations; (v) no-occurrence embedding `{text("emb.nope")}` → - one 14.6 finding whose range is the full braced container asserted - BYTE-EXACT [42,60) — no widening, the T14-8 pin keeping T11.4-6's byte - classification exact; (vi) forbidden-rule policy violation via - build-then-`check --json` → exactly one 14.12 finding, locations [], - path null, identities exactly [rule, source, kind token, target]. - Participant sequences are declared in the 12.7 within-finding order - (document order in-file, file bytes across files), so the index-wise - assertions pin the order value-wise beside the decoder's global - enforcement (forms.ts, S-5-guarded; the end tiebreak admits no natural - fixture — no staged pair shares file and start — and stays - decoder-enforced). Traceability "T14-8": ["14"] (5.7/11.4/12.7/2.1/5.3 - carriage context, the T14-6/-7 precedent); no certification scope - (CERTIFICATIONS.md Exclusions name T14-8 explicitly). Verified: - typecheck/format clean; T14-8 red-as-diagnosed at the first arm's - form-exact decode ("expected no member \"condition\"" — the FP-001-class - product gap), section-14 7→8 failed with T14-1..T14-7 unchanged; direct - CLI probes against the built product prove every staging fires exactly - the diagnosed multiset and the cardinality teeth are real — dup: two - old-shape per-occurrence findings locating only the later bearers; - collision: one finding locating only the second declaration; the - two-cycle staging: exactly the two 14.9 findings (import cycle at the - CycA import [0,28), dependency cycle at the CycA element [30,76)) each - locating only its CycA participant; pure import cycle: one finding, ImpA - declaration only; emb: 14.6 located at the argument [48,58) where the - container is [42,60); policy: build 0 then check 1 with exactly the one - old-shape 14.12 naming the staged edge. `npm run test:self` unchanged 2 - planned mid-loop reds (certification-document ×2 → FP-091), 268 passed; - S-1/S-5/S-7/certification green (S-7 sweeps T14-8 against the stub, - diagnosed).] - -## Stage H — property layer (§16) and oracles (S-6) - -- [x] FP-082 — P-2 generator: include backticks/`~` so fenced code blocks - and inline code spans spelling construct-like bytes are generated, with - the oracle treating them as content. [R2 #36; TEST-SPEC §16 P-2] - Generator/oracle: `test/helpers/oracles/markdown.ts` + - `test/suite/registry/section-16-p2-p3.ts`. Keep the S-6 markdown-oracle - vetted suite green (`test/self/s6-markdown-oracle.test.ts`) — extend its - vectors for the new grammar-boundary treatment. - [Done 2026-08-17: backticks/`~` enter the generator only as deliberately - staged constructs — free prose keeps excluding them (a stray backtick - would open a span across a real construct and desynchronize the product's - parse from the generator's model): new `genFenceBlock` block shape - (column-0 fences, 3–4-run backtick or tilde markers, optional - backtick-free info string, 0–3 interior lines from T3-1's construct-like - set / free prose / empty, bare equal-run closer — always closed, staged - at every nesting depth so fences land inside block sections and in - embeddable targets' subtree text) and `codeSpan` generator (equal - 1–2-backtick runs around a non-empty backtick-free construct-like - interior), staged as a prose-line inline element and as an own-line - comment's sole residue (the span keeps its removal-affected line). Every - fence/span byte is a `content` entry, so the oracle — which never scans - content for construct-like patterns — needed no functional change - (module header now documents the T3-1/P-2 grammar boundary), and the - untouched-lines byte-preservation assertion covers them directly. S-6 - vectors 31→35: construct-like fence interior preserved amid real - removals; tilde fence's blank/whitespace-only interior lines kept - (CRLF); span as a removal-affected line's sole survivor; fence bytes - riding an expansion. Stale LF-only-staging justification in conf-md - `markdownLiteralRegions`' docstring corrected (comment only; region - scanning stays plain-line-model across the fixture family — the - deviation is compile-model-only per CERTIFICATIONS.md). Verified: - typecheck/format clean; S-6 35/35; scratch per-seed dry-runs (deleted - before commit) prove fixed-seed reach — 43 fences (21 backtick/22 tilde; - 23 inside sections; 12 empty interior lines), 8 span lines (4 beside - comments), 4 embedded fence-bearing targets across the 36 P-2 trials — - and per seed the CONF-MD conformer passes P-2 while VIOL-MD-CLASS and - VIOL-MD-CR are each falsified (flip-class reach re-verified against the - violator executables themselves; module comment records it); suite - section-16-p2-p3 stays 2 passed against the built product — a genuine - pass; direct probes confirmed it parses tilde/4-run/CR-terminated - fences, blank fence interiors, 2-run spans, and fence bytes in an - embedded target's subtree text exactly as staged; `npm run test:self` - unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091), - 272 passed.] - -- [x] FP-083 — Implement the P-5 section-move category oracle + its S-6 - vetted fixed-vector suite. [R2 #42; TEST-SPEC §16 P-5, §17 S-6; SPEC 6.2, - 5.6] - New oracle under `test/helpers/oracles/`; vetted vectors: SPEC 6.2's - worked straddling-line case plus the T6.2-3/T6.2-4 cases, in a new - `test/self/s6-*-oracle.test.ts`. - [Done 2026-08-17: `test/helpers/oracles/section-move.ts` - (`predictSectionMoveImpact`) + `test/self/s6-section-move-oracle.test.ts` - (17 vectors). Interface FP-084 wires to: input is the BEFORE state in - baseline identities — origin/target piece-tree documents (content / - removal / embedding-with-expansion-and-target / nested sections carrying - `id`, tag bytes, `depends`; same object for a same-file move; - `{createdPath}` for a created target) plus movedId/newId and `otherNodes` - (untouched files' children+edgeTargets, for the cascade graph) — the - oracle derives the after side itself (6.5 at the piece level: origin - deletion enters the compile as one removal piece, insertion appends the - moved construct as the parent's last child with the U+000A rules judged - over post-deletion bytes, self-closing target parent rewritten per - T6.5-2) and reports in current identities: per-node full category - predictions ({required|tolerated-optional, attributionWithin, - attributionMustInclude} — the T6.2-3 two-sided tolerance is - required:false on cascades whose only cause is a relocated one-side-only - member; `metadata-changed` excluded by vocabulary), changed/added sets, - identityMap, and before/after own-content token sequences (runs + child/ - embed reference tokens, the P-4 shape). Every logical line's keep/drop - decision is delegated to P-2's `compileMarkdown` (the line's pieces plus - terminator; empty output = dropped), so the two oracles cannot disagree - on SPEC 3 — expansion semantics included. Guards FP-084's generator must - respect: a sequence change outside {origin parent, target parent, moved - subtree} throws (P-5's exactly-three-groups pin — no other node's bytes - on a line whose keep/drop status the move flips); single-line tags; - self-closing = empty body; complete cascade graph (dangling edge targets - throw); embedding expansions are emptiness-stable across the move - (documented staged scope — only emptiness enters the drop decision, and - the generators stage no empty subtree texts). Vectors: T6.2-3 clean - boundary (full table incl. Watch dependents' exact attributions), SPEC - 6.2's worked case (origin remainder+terminator contributed, destination - line dropped, moved node changed, parents/roots with the documented - tolerance), T6.2-4 pure final-position (empty table, sequence reproduced) - + non-final contrast (coincident parent alone changed), created-target - root changed-as-added carrying no other category over a changed moved - descendant, self-closing moved section and self-closing target parent - (T6.5-2 bytes), mid-line insertion's preceding U+000A landing in the - parent's run, non-empty-expansion-keeps-line delegation, 8 misuse guards - (sibling-flip staging first). Teeth proven by 5 scratch mutation probes - (each reverted): deletion-as-excise 2 vectors failed, dropped preceding - newline 1, added-rule removal 1, expansion stripping 1, - tolerance-made-required 3. Verified: typecheck/format clean; suite 17/17; - `npm run test:self` unchanged 2 planned mid-loop reds - (certification-document ×2 → FP-091), 289 passed. section-16-p5-p6.ts - untouched — wiring is FP-084's task (FP-085 extracts the P-6 graph-diff - oracle separately).] - -- [x] FP-084 — Generalize P-5 to random section moves using the full - 6.2/5.6 oracle. [R2 #37; TEST-SPEC §16 P-5] After FP-083. - `test/suite/registry/section-16-p5-p6.ts` currently restricts the - section-move arm to "clean-boundary" moves. Require: random section moves - generally — straddling-line drops computed via the line-drop rules/P-2 - oracle, created-target-file root as added, coincident-parent purity, - `metadata-changed` on no node — anchored by T6.2-3/T6.2-4. - [Done 2026-08-29: arm 2 rebuilt on FP-083's `predictSectionMoveImpact`; - the trial no longer mutates the model. A piece-tree builder (sharing - section-16-p4.ts's now-exported renderRef/renderOpenTag/refIdentity) - reproduces renderWorkspace byte-for-byte when undecorated (asserted per - trial) and stages the origin/target files with randomized boundary - layouts; the runner computes the full prediction before any product call, - then asserts the impact report per node: required categories present, - unpredicted ones (metadata-changed included) rejected, tolerated-optional - accepted either way, attributions within `attributionWithin` and - including `attributionMustInclude`, deleted entries rejected, code empty. - Layouts (every byte form vetted against remark-mdx by an - implementation-time probe; findings in the module header): flow (any - subtree), balanced inline (childless plain-prose subtrees — remark-mdx - accepts a multi-line element only fully flow or fully inline, so SPEC - 6.2's worked shape is staged with an inline close such as `</S>ptail`), - single-line collapse, self-closing moved section; plus self-closing - target parents (T6.5-2), terminator-less-EOF root targets (mid-line - insertion), created target files (`specs/N0.mdx`; strict import window: - max referenced-out file strictly below min referencing-in file), and a - purity branch confined to final-child-onto-own-parent (the ordinary pick - excludes the pure-reproducing target). Embeddings keep prose-flanked - lines so no drop decision ever consults expansion emptiness; expansions - enter as emptiness-faithful sentinels from a model fixpoint. Arm-2 runs - 5→8. Verified: typecheck/format clean; scratch dry-runs (deleted before - commit) drove gen+builder+oracle productless — fixed seeds: 24/24 trials - accepted by the oracle's misuse guards, every staged file parses under - remark-mdx, reach = created ×3, same-file ×14 (pure ×5), cross-file ×7, - inline ×5 / collapse ×2 / selfClose ×1, moved-node straddle flips ×3, - self-closing target parent ×3, stripped EOF ×6, required - descendant/upstream attributions ×8/×6, optional (T6.2-3 tolerance) ×4 — - plus 1,440 random-seed trials, all accepted and parsing. Against the - built product, suite section-16-p5-p6 genuinely passes (2 passed, ~105s; - like P-2/P-3 since FP-082) — teeth proven by 2 end-to-end mutation - probes (each reverted): an oracle-unseen staged byte falsified with the - "no ground for" diagnosis, and a bogus attributionMustInclude entry - falsified in the attribution loop, the product's real attributions - flowing through the within-bound. `npm run test:self` unchanged 2 - planned mid-loop reds (certification-document ×2 → FP-091), 289 passed.] - -- [x] FP-085 — Implement the P-6 baseline graph-diff oracle + its S-6 - vetted suite; wire P-6 to it. [R2 #42; TEST-SPEC §16 P-6, §17 S-6; SPEC - 5.6] - Vetted vectors: SPEC 5.6's three worked examples + the T5.6-6 case. - [Done 2026-08-29: P-6's inline category diff extracted to - test/helpers/oracles/graph-diff.ts — opaque 5.5-preimage keys - (ownKey/metaKey/pairKey), generalized past P-6's input space so the - vetted vectors can cover the worked material: added AND deleted nodes - exactly `changed` per T5.6-6 (deleted keyed by baseline identity, - flagged in `deleted`), edge-bearing added/deleted subtree members - joining relocated kept members in the tolerated-optional upstream - reading; misuse guards (relocated originators, incomplete graphs, - contains/dependency cycles, an ownKey not covering child tokens) throw - plain errors. Vetted by test/self/s6-graph-diff-oracle.test.ts: 12 - vectors — the three 5.6 worked examples (T5.6-1/-2/-3 shapes) plus the - coverage/tags-only closing sentence (T5.6-4 shapes), T5.6-6's - added/deleted convention, both one-sided tolerances, 5 misuse guards; - vector teeth proven by 4 oracle mutation probes (each reverted), all - falsified. P-6 wired to computeGraphDiff with its - no-deletion/dependency-free-addition input-space guards kept at the - call site; wiring teeth: one end-to-end probe (originators emptied, - reverted) falsified P-6 with the no-originating-node diagnosis. - Verified: typecheck/format clean; suite section-16-p5-p6 vs the built - product 2 passed (~110s, unchanged from FP-084); `npm run test:self` - 301 passed, unchanged 2 planned mid-loop reds (certification-document - ×2 → FP-091).] - -- [x] FP-086 — P-7 generator: add the `$`-at-capture-boundary literal forms - (`$0`, `$` before a non-digit, trailing `$`). [R2 #38; TEST-SPEC §16 P-7] - `test/suite/registry/section-16-p7.ts` (+ glob-input generator); keep the - S-6 glob-oracle suite (`test/self/s6-glob-oracle.test.ts`) green — - `test/helpers/oracles/glob.ts` already models patterns; extend vectors if - the oracle needs the literal-`$` forms pinned. - [Done 2026-08-29: the 7.5 policy-pattern generators now draw the literal - forms — `$0` and `$<non-digit>` as atomic literal tokens in `from`, `to` - middle, and `to` final segments; trailing `$` appended segment-final - (followed only by `/`, end of pattern, or a `$`-initial capture/ref - token, so no accidental `$<digit 1–9>` can form — invariant argued in - the module header and checked mechanically: scratch probe over 3,135 - generated rules found no from-repeat/absent-ref and, on the real CI - seeds/runs, counted `$0` ×5, `$<non-digit>` ×15, segment-final `$` ×8 - across 23 rules). `$` also became a capture-side PATH byte (an ordinary - literal in the staging discovery globs, SPEC 7.5, so paths still match - themselves; 25 staged sources / 6 targets carried `$` in the CI trials). - Oracle unchanged (already literal-correct); S-6 vectors extended with the - T7.5-5 pins: from-side `a$0.ts`≠`ab.ts`, trailing-`$`≠anchor, `$$1` - literal-then-capture, and to-side `$0`/segment-final-`$` referencing no - absent capture — 29 passed. Verified: typecheck/format clean; - `npm run test:self` 303 passed with the unchanged 2 planned mid-loop - reds (certification-document ×2 → FP-091; S-7 sweep green, so P-7 with - the new generator still fails-as-diagnosed against the stub). Against - the built product, P-7's discovery property passes in full; the capture - property falsifies on the PRE-EXISTING findings-shape red (product emits - old-shape `condition` members vs the form-exact 12.7 decode of FP-001) — - confirmed identical on the pre-change baseline (same seed 271828183, - same diagnosis), a genuine product conformance red for phase 10, not a - harness defect; the pre-shrink falsifying trial carried `$` forms - through config load, `build` exit 0, and `check` exit 1, proving the - `$`-bearing staging sound end-to-end.] - -- [x] FP-087 — Implement P-11: availability robustness fuzz over - `occurrences`/`view`/`at`. [R2 #14; TEST-SPEC §16 P-11] New §16 registry - module + wrapper; H-7 map per the passages its TEST-SPEC entry asserts. - Property machinery: `test/helpers/property.ts` (fixed default seed set — - see AGENTS.md). - [Done 2026-08-29: test/suite/registry/section-16-p11.ts (+ wrapper, - manifest spread, H-7 entry "P-11": ["11.2", "11.4", "12.7"]). Input - space per the entry's "P-8's generators": section-16-p8.ts now exports - FUZZ_BASE_FILES and drawFuzzMutation (the weighted mutation menu), and - P-11 draws 1–3 mutations over the three SOURCES only — never - xspec.config.ts, which must stay valid so no unstaged 14.14 exit-2 can - arise (P-8's own trial stream proven byte-identical across the refactor - by a pre/post generation diff over the fixed seeds). Per trial, 2–4 - drawn invocations from a 17-entry menu — 7 answer arms (bare/--file/--to - `occurrences`; bare/operand/--file `view`, each side of `--text`; `at` - with offsets drawn over [0, staged byte length]) and 10 - staged-argument-error arms (wrong-kind / unknown / operand-plus---file - `view`; wrong-kind, out-of-range, malformed-spelling `at`; malformed - `--to`; outside-root glob; repeated flag; unknown flag) — asserting: - termination (hang/overflow guards converted to diagnosed failures), no - signal death, exit 0/1 on answer arms with exit 2 only on the staged - errors (which must exit 2 with the 12.7 error document — 11.2: argument - checks precede answering), stdout exactly one complete JSON document, - the form-exact 12.7 decode per surface (three-state datum discipline via - the decoders and their marker walk), and 11.2's iff closed both ways - (any finding or unavailable datum ⟹ exit 1 with the full document - emitted; clean ⟹ exit 0). No staging build (11.2 never-stale: these - surfaces answer from current sources). Seed-coverage dry-run over the - CI-pinned 36 trials: every menu entry, mutation kind, and mutation - target occurs. Verified: typecheck/format clean; `npm run test:self` - 303 passed with the unchanged 2 planned mid-loop reds - (certification-document ×2 → FP-091; S-1 green with the new mapping, - S-7 sweep green so P-11 fails-as-diagnosed against the stub). Against - the built product P-11 falsifies immediately as a genuine phase-10 red: - bare `occurrences` exits 2 — the product implements none of the §11 - surfaces ("unknown command 'occurrences'", probed directly) — shrinking - to a no-op mutation plus bare `occurrences` with the exit-clause - diagnosis (seed 271828183).] - -- [x] FP-088 — Implement P-12: `at` ≡ view-derived resolution over every - offset; occurrence order/totality equivalence. [R2 #15; TEST-SPEC §16 - P-12] - [Done 2026-08-29: test/suite/registry/section-16-p12.ts (+ wrapper, - manifest spread, H-7 entry "P-12": ["5.7", "11.5"]). Product-vs-product - equivalences, no independent oracle (per CERTIFICATIONS.md's P-12 - exclusion note): the comparator is `resolveAtFromView`, imported from - section-11.5.ts where T11.5-1 proves it against precomputed constants. - Input space: compact spec-only workspaces (1–3 files, a constant anchor - section `t` per file so resolving references always have a target, - nested sections to depth 2, multi-byte prose, imports blank-line - separated per the FP-094 lesson, `d` refs and embeddings in - resolving/maybe/never spellings), plus one optional twist — duplicate-id - (both bearers' identities unavailable; a resolving `d={"t"}` occurrence - with source explicitly unavailable, carried identically by view, - enumeration, and at) or break-parse (masked file: no view entry, at must - answer the unavailability marker at every offset). Per trial: one bare - `view`; `occurrences` twice (stdout byte-identical + one exit, 5.7/12.0 - determinism); duplicate-span-freedom on both sides (5.7 "identical - ranges do not occur", which also makes the sort key total); enumeration - == view-collected records sorted by file path bytes, start, end (both - sides through the same form-exact 12.7 record decode); then `at` for - EVERY file at EVERY offset 0..byte length vs the view-derived - resolution; every answer exits 0/1 (no staged usage error exists). - 9 CI-pinned trials (runs 3 × 3 default seeds, ~1470 at invocations); - an implementation-time dry-run over the committed seeds verified every - twist kind, multi-file workspaces, imports, embeddings, d props, - external refs, and multi-byte prose all occur, and every staged source - parses under remark-mdx except exactly the break-parse files (which - fail to parse). Verified: typecheck/format clean; `npm run test:self` - 303 passed with the unchanged 2 planned mid-loop reds - (certification-document ×2 → FP-091; S-1 green with the new mapping, - S-7 sweep green so P-12 fails-as-diagnosed against the empty stub). - Against the built product P-12 falsifies immediately as a genuine - phase-10 red: bare `view` exits 2 ("unknown command 'view'" — the - product implements none of the §11 surfaces), shrinking in 5 steps to - the minimal one-anchor-file workspace (seed 271828183).] - -- [x] FP-089 — Implement the P-13 coverage-reachability oracle (independent - SPEC 8.1/8 reachability) + its S-6 vetted suite (vectors per SPEC 15). - [R2 #42; TEST-SPEC §16 P-13, §17 S-6] - [Done 2026-08-29: `test/helpers/oracles/coverage.ts` (`computeCoverage`) - + `test/self/s6-coverage-oracle.test.ts` (21 vectors). Interface FP-090 - wires to: input is the caller-staged graph — every node with root flag, - contains-children (leaf judgment only; the reachability walk never - consults them), spelled coverage attribute, tags; dependency edges with - kinds, root-adjacent ones included — plus the target and boundary - groups' FULL memberships (roots included; the coverage-scoped root - exclusions are the oracle's job) and the profile (mode; targets default - "leaves"; targetTags null/omitted = absent; edgeKinds default all - three). Output: the four 8.2 counts plus required / covered (each with - the unique shortest covering path, boundary node first, 12.0 - element-wise byte tie-break via reverse-BFS distance levels + greedy - byte-least descent) / uncovered / ignored (all applicable reasons in - the fixed 8.2 order, spelled as the harness IGNORED_REASON_KINDS tokens - "root"/"coverage-none"/"non-leaf"/"lacking-tags" so FP-090 can compare - classifyIgnoredReasons output directly), every array identity-byte - sorted. Misuse guards throw plain errors: incomplete graphs, duplicate - group members, self-edges and combined contains/depends/embeds cycles - (5.3 — outside the valid-workspace input space), roots carrying - tags/coverage (5.5), empty edgeKinds/targetTags (14.14). Vectors: SPEC - 15's exact worked workspace under T15-1's grouping — the worked - transitive statement's full 8.2 result, direct contrast, one-edge - direct over the depends edge, edgeKinds breaking the worked path at - each step, targets "all" + contains-never-grants, one-or-more-edges - (boundary membership alone covers nothing), targetTags - carried/lacking/any-of with the fixed reason order pinned on root and - print, coverage="none" attribute variants (exclusion, - order-beside-lacking-tags, 2.5's descendants-retain sentence, and the - T8.2-1-shape coverage-none/non-leaf/lacking-tags triple), root-marker + - root-sourced-embeds variants (4.5: never intermediate; a boundary - root's byte-least one-edge route loses to the non-root path), three - 12.0 tie-break vectors (boundary element, interior element, - length-before-bytes), 6 misuse-guard tests. Teeth proven by 7 oracle - mutation probes (each reverted), all falsified: contains-into-adjacency - 1 vector, root-filters-removed 2, direct-as-transitive 1, - tie-break-reversed 2, root-exclusion-dropped 9, reason-order-swap 1 - (that probe initially passed — the reason-order vector was added to - close the gap), coverage-none-not-excluded 3. Verified: - typecheck/format clean; suite 21/21; `npm run test:self` 324 passed - (+21), unchanged 2 planned mid-loop reds (certification-document ×2 → - FP-091). section-16 registry untouched — wiring is FP-090's task.] - -- [x] FP-090 — Implement P-13: coverage oracle property — random - workspaces/profiles vs the FP-089 oracle. [R2 #16; TEST-SPEC §16 P-13] - After FP-089. - [Done 2026-08-29: test/suite/registry/section-16-p13.ts (+ wrapper - section-16-p13.test.ts, manifest spread, H-7 entry "P-13": ["7.4", "8", - "8.1", "8.2"]). Generator: valid-by-construction workspaces under a rank - discipline (file index, then per-file post-order; every reference targets - a strictly lower rank in-file or any node of an earlier file, so the - combined contains/depends/embeds graph and the import graph are acyclic - and build must exit 0) — 1–3 spec files with nested tagged / - coverage-attributed sections (segment pools deliberately non-sorted so - identity byte order decouples from structure), d refs and embeddings in - local, external-chain, and module-form (root-targeted) spellings, - top-level embeddings (root-sourced), 0–2 TS files with whole-file and - named-unit markers/text calls, overlapping spec/code groups with - discovered-membership repair, 1–3 profiles over every 7.4 knob - (mode/targets/targetTags incl. a no-node tag/edgeKinds/spec+code - boundaries). Per trial: `build` (exit 0) then one `coverage --json`; - profile-name set pinned (8.2 all-profiles); per profile the four counts, - covered rows with exact paths, uncovered, and ignored rows (reasons via - classifyIgnoredReasons, fixed order) compared against computeCoverage fed - the generator's own model — required observed as covered ∪ uncovered + - count; the unique shortest+tie-break oracle path makes exact equality - P-13's permitted-path clause. 24 CI-pinned trials (runs 8 × 3 default - seeds); a dry-run over the committed seeds verified every MDX parses with - imports as real ESM blocks (FP-094 lesson), every TS parses, every oracle - input passes the misuse guards, and every input class occurs (all three - edge kinds, root-sourced ×28 / root-targeted ×51 edges, code boundaries - ×18, boundary=target ×10, all four ignored reasons + multi-reason rows, - multi-edge covered paths ×11, tie-broken paths ×16, all targetTags and - most edgeKinds subsets, targets leaves/all/omitted). Teeth proven by 6 - probes (each reverted), all falsified against the built product: - transitive-as-direct, coverage-none dropped, tags dropped, children - dropped, code-sourced edges dropped, reported paths reversed (that one - failing the covered-path assertion specifically). Verified: - typecheck/format clean; unlike P-11/P-12, P-13 is GREEN against the built - product — its coverage engine predates this patch and agrees with the - oracle on all 24 workspaces (which also proves the staged workspaces - valid: build accepts every one); `npm run test:self` 324 passed with the - unchanged 2 planned mid-loop reds (certification-document ×2 → FP-091); - S-1 green with the new mapping; S-7 sweep green, so P-13 fails as - diagnosed against the empty stub product (H-8).] - -## Stage I — CONF-AVAIL certification family - -- [x] FP-091 — Build the CONF-AVAIL fixture family, wire the manifest, and - flip the whole-document pins. [R3 gaps 1–2, R2 #40, VERIFY; CERTIFICATIONS.md - CONF-AVAIL; TEST-SPEC §17 C-1/C-2] - After the six in-scope tests are registered (FP-051, FP-053, FP-059, - FP-060, FP-062, FP-063). - - New `test/fixtures/conf-avail/`: plain Node ESM conformer (`bin.mjs` + - `product.mjs`, no build step/deps — see AGENTS.md and existing - fixtures) implementing the scope CERTIFICATIONS.md states, and the - three violators beside it (`bin-<deviation>.mjs` naming per document): - VIOL-AVAIL-NULLMARKER (certifies T11.2-2, T11.2-4, T11.4-3, T11.4-4), - VIOL-AVAIL-OMIT (those plus T11.4-1), VIOL-AVAIL-NOFILE (exactly - T11.3-4). - - `test/self/certification-fixtures.ts`: append the fifth conformer entry - (list currently ends at CONF-DISC), in-scope IDs and violator - `certifies` verbatim from the document. - - `test/self/certification-document.test.ts`: pins 4 conformers/13 - violators → 5/16. - - Verify: `npm run test:self` fully green on certification-document and - certification (conformer passes every in-scope test; each violator - fails at least one certified test) — this task clears the three VERIFY - certification-document failures. - [Done 2026-08-29: test/fixtures/conf-avail/ — product.mjs (~2k lines, - ports of the CONF-MD/CONF-DISC config+glob+discovery and attributed - line-model compile plus CONF-VALID's 1.4 predicate) with bin.mjs and the - three violator bins (bin-nullmarker.mjs / bin-omit.mjs / bin-nofile.mjs), - each threading exactly one deviation switch through runXspec's options - seam: `nullMarkers` and `omitNullMembers` hook the single serialization - point (exit codes computed pre-transform, so only bytes deviate); - `ignoreFileRestriction` hooks commandOccurrences' domain computation. - Conformer serves `view` (bare/operand/--file/--text) and `occurrences` - (bare/--file/--to) per 11.2-11.4: full positional trees with tag - decompositions and every spelled attribute (quoted/braced/valueless/ - spread) as {name, range, text}, spelled-identity definedness (exactly one - quoted static id; chain spelling+well-formedness+conformance; own-only - uniqueness), interpreted tags/coverage three-state datums, invalid - elements as 14.16 content with innermost-SECTION parenting, imports of - all four binding forms at MDX ESM block positions (target from specifier - form + discovery alone), resolution through defined identities recording - 5.7 occurrences (source withheld as ONE datum where undefined), SCC-based - 14.9, expansion poisoning via tri-state subtree/own definedness memos over - the ownership-attributed compile, findings in the pinned 12.7 order with - stable tokens, and the any-finding-or-marker exit-1 discipline. - certification-fixtures.ts appended verbatim; pins 4/13 → 5/16 (plus the - certification.test.ts header prose). Verified: conformer 6/6 pass; - NULLMARKER fails exactly its 4, OMIT exactly its 5, NOFILE exactly - T11.3-4 (its restricted arm), all as diagnosed assertion failures, no - errors/hangs; `npm run test:self` FULLY GREEN — 330 passed, 0 failed — - clearing the plan's 2 remaining mid-loop reds; typecheck + format:check - clean.] - -## Stage J — E-6 cross-platform legs - -- [x] FP-092 — Extend the E-6 representative fixture with the new command - steps. [R2 #43; TEST-SPEC E-6] - `test/helpers/e6.ts` (the `step(...)` sequence): add `occurrences`, - `view --text`, `at`, `inventory` (invoked from a NESTED working - directory), `version`, and `move --preview` — their path/range-dense JSON - documents byte-compared across legs via the existing exchange - (`test/suite/e6-exchange-writer.test.ts`, `test/windows/ - e6-byte-identity.test.ts`, `XSPEC_E6_EXCHANGE_DIR`; see AGENTS.md). - [Done 2026-08-29: test/helpers/e6.ts — the step sequence now opens with - `version` and adds bare `occurrences`, `view --text`, and `at` after the - impact steps (the `at` offset derived from the staged Core.mdx constant, - landing inside the `{text(Other.oth)}` embedding so the answer carries - section + occurrence + resolved target; Core.mdx is still pristine - there), `move --preview --json` immediately before the applied move - (previewing exactly the operation the next step performs), and - `inventory` as the final step from `specs/sub` — the nested directory - the move created — via a new optional `cwd` parameter on the `step` - helper (root `../..`, config `../../xspec.config.ts`: the /-joined - anchoring at its densest — journal occupied, session listed). 17 → 23 - invocations; exchange format unchanged (step names/argv/exit codes plus - stream and workspace bytes still compared). Command-list and - 23-invocation comments updated in e6-exchange-writer.test.ts and - e6-byte-identity.test.ts, hang guards 240s → 300s. Verified: typecheck + - format:check clean; both E-6 tests fail red-as-diagnosed at the new - first step (`version` — the stub knows no such command, exit 2), no - crash/hang, the Windows leg failing before consulting the exchange; - `npm run test:self` fully green (330 passed).] - -- [x] FP-093 — Add the Windows-subset drive-mismatch anchoring arm of - T11.6-1. [R2 #43; TEST-SPEC §11.6 (E-6 arm)] After FP-069. - `test/windows/` (beside `e6-subset.test.ts`): the drive-mismatch - anchoring arm runs on the Windows leg only. - [Done 2026-08-29: new test/windows/e6-drive-mismatch.test.ts (subset - part 2 of 3; sibling headers renumbered, AGENTS.md notes the - Windows-only staging). Body order pins the e6-byte-identity failure - taxonomy (H-8/H-9 — never a skip, never a vacuous pass): (1) fixed- - vector self-checks of the platform-absolute spelling validator - (accepts `C:\…\work` vs cwd drive Z; rejects forward slashes, - drive-less, relative, trailing separator, `\\?\` prefix, same-drive) - plus the platform-join composition — pure path.win32 arithmetic, run - on every platform; (2) same-drive premise arm — flag-less `inventory` - from the workspace root, exit 0, findings [], anchoring `.` / - `xspec.config.ts` byte-exact — so a stub or nonconforming product - fails first, diagnosed, on any platform, and on the Windows leg the - arm carries its own contrast (same workspace, relative until the - drives differ); (3) non-win32 → loud plain-Error (the product answers - but `subst` exists only on Windows; run the Windows leg); (4) the - staging: a free letter substituted over a tempRoot scratch dir - (claim-by-trying Z→E — `subst` refuses in-use letters, so concurrent - instances race safely, H-1/E-3; released in finally, leak named if - deletion fails), cwd the mapped drive's root, `--config` the - realpath.native long-name drive-qualified spelling of the config (one - canonical form whether or not the product canonicalizes; the - registered body's absolute-`--config` same-drive arm already proves - the spelling never flips the form, so the absolute output is - attributable to the mismatch alone); expected `root`/`config` that - same platform-absolute spelling byte-exact through - decodeInventoryAnchoring (whose doc-comment anticipated exactly this - caller), findings [] at exit 0, repeated invocation byte-identical - stdout (deterministic per invocation, 12.0; H-4). Verified: typecheck - + format:check clean; `npm run test:windows` on this Linux container - — the arm fails red-as-diagnosed at the premise arm (unknown command - `inventory`, exit 2 vs expected 0), no crash/hang, subst never - attempted, the two sibling tests unchanged; `npm run test:self` fully - green (330 passed). The subst leg itself first executes on the - Windows CI leg once a product answers `inventory` — same epistemic - position as the exchange comparison (FP-081/FP-092), its failure - modes all loud staging errors.] - -## Stage K — findings discovered mid-loop - -- [ ] FP-094 — Restage T11.5-1's fixture so its imports (and top-level - comment) are real MDX blocks, not paragraph prose. [Found 2026-08-14 - during FP-068; TEST-SPEC §11.5 T11.5-1, SPEC 1 ("an MDX document"), 2.1] - `test/suite/registry/section-11.5.ts` (FP-066's fixture, ~lines 184-230): - specs/total.mdx separates its head prose, both import declarations, and - the top-level comment by SINGLE newlines — under MDX block grammar that - whole run is one paragraph, so the two `import` lines are paragraph - prose, not import declarations. Proven against the built product: with - IMPORT_ONE's specifier changed to `"./typo.xspec"` in the exact staged - layout, `build --json` stays finding-free (a parsed import would report - 14.15), while the same import blank-line-separated binds (and its - unbound references report 14.8). Consequences when a conforming product - lands: the `view.imports` anchor (EXPECTED_IMPORTS: two entries with - resolved targets) fails as a harness staging defect — the product will - report no import declarations — and the "inside the first import - declaration" pointwise arm probes prose (its root resolution stays - correct, its label wrong); EXPECTED_COMMENTS' top entry rides the same - paragraph (whether an inline `{/* */}` inside a paragraph is a - comment-range entry is unverifiable until `view` exists). Fix: insert - blank-line segments so the imports and the top comment start their own - blocks (composed constants recompute every offset; the derivability - comparator and self-checks adapt), keep the deep comment's in-section - placement only if the landed product classifies it as a comment range, - and re-verify: fixture self-checks pass, `build --json` finding-free, - a typo-specifier control probe now DOES report 14.15 on the restaged - layout. No other §11 fixture shares the hazard (11.2/11.3/11.4 - layouts checked 2026-08-14: all imports start a block). diff --git a/test/suite/registry/section-11.5.ts b/test/suite/registry/section-11.5.ts index 212a471..b144222 100644 --- a/test/suite/registry/section-11.5.ts +++ b/test/suite/registry/section-11.5.ts @@ -164,7 +164,16 @@ function sliceCheck( // (é: 2 bytes; è: 2 bytes; —: 3 bytes) shift every later offset, so byte // offsets diverge from code-point and UTF-16 counts (SPEC 1.7). Every // segment's text is a named constant so construct-slice expectations are -// composed, never retyped. +// composed, never retyped. Every block construct is blank-line-separated +// (FP-094): under MDX block grammar a line glued to a paragraph rides that +// paragraph, so the separation is load-bearing — it is what makes the two +// `import` lines import DECLARATIONS rather than paragraph prose +// (SPEC 1, 2.1; an import glued to the head prose binds nothing, and a +// typo specifier there draws no 14.15) and both `{/* … */}` comments flow +// expression blocks, unambiguous MDX comments whose ranges the view must +// carry (SPEC 11.4) — the deep one kept inside a.b.c, so its in-section +// placement no longer rests on how an inline expression inside a paragraph +// is classified. const AT_FILE = "specs/total.mdx"; const BASE_FILE = "specs/base.mdx"; @@ -188,36 +197,42 @@ const Z_PROSE_TEXT = "Finale.\n"; const F = new ByteFixture(); const PROSE_HEAD = F.add(PROSE_HEAD_TEXT); +F.add("\n"); // blank line: each import must start its own MDX block const IMPORT_ONE = F.add(IMPORT_ONE_TEXT); -F.add("\n"); +F.add("\n\n"); const IMPORT_TWO = F.add(IMPORT_TWO_TEXT); -F.add("\n"); +F.add("\n\n"); // blank line: the comment is a flow expression block const COMMENT_TOP = F.add(COMMENT_TOP_TEXT); -F.add("\n"); +F.add("\n\n"); const A_OPEN = F.add(A_OPEN_TEXT); -F.add("\n"); +F.add("\n\n"); F.add(A_PROSE_TEXT); +F.add("\n"); // blank line: the nested opening tag starts its own block const AB_OPEN = F.add(AB_OPEN_TEXT); -F.add("\n"); +F.add("\n\n"); const ABC_OPEN = F.add(ABC_OPEN_TEXT); -F.add("\n"); +F.add("\n\n"); const DEEP_PROSE = F.add(DEEP_PROSE_TEXT); +F.add("\n"); // blank line: the deep comment is a flow block inside a.b.c const COMMENT_DEEP = F.add(COMMENT_DEEP_TEXT); -F.add("\n"); +F.add("\n\n"); F.add(CLOSE_TEXT); const ABC_RANGE: SourceRange = { start: ABC_OPEN.start, end: F.pos }; -F.add("\n"); +F.add("\n\n"); const AB_TAIL = F.add(AB_TAIL_TEXT); +F.add("\n"); F.add(CLOSE_TEXT); const AB_RANGE: SourceRange = { start: AB_OPEN.start, end: F.pos }; -F.add("\n"); +F.add("\n\n"); const A_CLOSE = F.add(CLOSE_TEXT); const A_RANGE: SourceRange = { start: A_OPEN.start, end: F.pos }; -F.add("\n"); +F.add("\n\n"); const PROSE_BETWEEN = F.add(PROSE_BETWEEN_TEXT); -const Z_OPEN = F.add(Z_OPEN_TEXT); F.add("\n"); +const Z_OPEN = F.add(Z_OPEN_TEXT); +F.add("\n\n"); F.add(Z_PROSE_TEXT); +F.add("\n"); const Z_CLOSE = F.add(CLOSE_TEXT); const Z_RANGE: SourceRange = { start: Z_OPEN.start, end: F.pos }; F.add("\n"); @@ -228,10 +243,10 @@ const ROOT_RANGE: SourceRange = { start: 0, end: AT_LENGTH }; // Composed construct-slice expectations (never retyped): each paired // section's construct spans its opening tag's first character through its // closing tag's last (SPEC 1.7). -const ABC_CONSTRUCT_TEXT = `${ABC_OPEN_TEXT}\n${DEEP_PROSE_TEXT}${COMMENT_DEEP_TEXT}\n${CLOSE_TEXT}`; -const AB_CONSTRUCT_TEXT = `${AB_OPEN_TEXT}\n${ABC_CONSTRUCT_TEXT}\n${AB_TAIL_TEXT}${CLOSE_TEXT}`; -const A_CONSTRUCT_TEXT = `${A_OPEN_TEXT}\n${A_PROSE_TEXT}${AB_CONSTRUCT_TEXT}\n${CLOSE_TEXT}`; -const Z_CONSTRUCT_TEXT = `${Z_OPEN_TEXT}\n${Z_PROSE_TEXT}${CLOSE_TEXT}`; +const ABC_CONSTRUCT_TEXT = `${ABC_OPEN_TEXT}\n\n${DEEP_PROSE_TEXT}\n${COMMENT_DEEP_TEXT}\n\n${CLOSE_TEXT}`; +const AB_CONSTRUCT_TEXT = `${AB_OPEN_TEXT}\n\n${ABC_CONSTRUCT_TEXT}\n\n${AB_TAIL_TEXT}\n${CLOSE_TEXT}`; +const A_CONSTRUCT_TEXT = `${A_OPEN_TEXT}\n\n${A_PROSE_TEXT}\n${AB_CONSTRUCT_TEXT}\n\n${CLOSE_TEXT}`; +const Z_CONSTRUCT_TEXT = `${Z_OPEN_TEXT}\n\n${Z_PROSE_TEXT}\n${CLOSE_TEXT}`; // --- the view-derived resolution comparator (SPEC 11.5) ----------------------- From 138c91da0214bd03c8eee01e3088c9b2b6f66c37 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 19:26:02 +0000 Subject: [PATCH 140/226] sdg(phase-9): plan compliance round-2 fixes (FP-095, FP-096) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 137 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 specs/tmp/FIX_PLAN.md diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md new file mode 100644 index 0000000..dd5b395 --- /dev/null +++ b/specs/tmp/FIX_PLAN.md @@ -0,0 +1,137 @@ +# FIX_PLAN — Phase 9 (test harness), compliance round 2 + +Source: compliance review of TEST-SPEC.md §§10–18 + cross-cutting (2026-08-29). Two gaps. +Scope guard for every task: Phase 9 — harness code only (`test/`), never product code (`src/`). +Task numbering continues from FP-094 (earlier rounds' tasks, already done and cleared). + +--- + +## FP-095 — T10.5-3: stage and assert the embedding half of SPEC 10.5's note + +**Status:** TODO + +**Requirement.** TEST-SPEC.md §10.5, T10.5-3: "both halves of 10.5's note staged (a new +`d` edge makes the source `metadata-changed`, a new embedding makes it `changed`): a +fixture node whose only affected target was added since the baseline gets no +`dependency-consistency` item, its new `d` edge surfacing as its own +`metadata-consistency` item; **a second node whose only affected target entered through a +new `{text(...)}` embedding likewise gets no `dependency-consistency` item — the new +embedded reference changes its own content (5.5), it is `changed`, and the change is +reviewed via its own `subtree-coherence` item**." Product-spec anchor: SPEC.md 10.5, +dependency-impact rule 2's note ("An edge to a target added since the baseline is +necessarily itself new (5.4), so that change is reviewed at its source: a new `d` edge +makes the source `metadata-changed`, a new embedding makes it `changed` (5.6)"). + +**Gap (reviewer finding 1).** In `test/suite/registry/section-10.5.ts`, the T10.5-3 +fixture (`N_BASELINE`/`N_CURRENT`, currently lines ~919–1007) stages only the `d`-edge +half: `dep2` gains `d={"h.newt"}` in `N_CURRENT`. No node anywhere in the file gains a new +`{text(...)}` embedding (the file stages zero embeddings), and no assertion covers the +embedding half. + +**Change.** Extend the T10.5-3 fixture with a second node whose only affected target +enters through a `{text(...)}` embedding new since the baseline, and assert the embedding +half. Required properties of the staging (all load-bearing under SPEC.md 10.5/5.6): + +- The embedder is present in both `N_BASELINE` and `N_CURRENT`; its only change between + the two is that its text gains a `{text(...)}` embedding of a node **added since the + baseline** (e.g. a new top-level section `dep3` whose current text gains + `{text("h.newt")}` — the string form resolves within the same file, TEST-SPEC T2.3-2; + `h.newt` is already added-since-baseline in this fixture). +- The embedder carries no `d` attribute and no other embedding (so that target is its + *only* affected target), has no `changed` ancestor (else 10.5's skipping rule absorbs + its item), and sits at file top level (10.5 parent-consistency rule 2 creates items only + per non-root ancestor, keeping the expected item set free of parent-consistency noise — + same reasoning as the existing fixture comment at the top of the T10.5-3 block). +- No other node `d`-references or embeds the embedder, and the code fixture + (`N_CODE_BASELINE`/`N_CODE_CURRENT`, `src/ci3.ts`) does not reference it — leaving the + existing dep1/dep2/m/m2/code-impact assertions' expected values untouched. + +Assertions to add (the three the TEST-SPEC statement names): + +1. The embedder gets **no `dependency-consistency` item**: extend the exact + `kindScopeSet(status)` expected set — it must gain `subtree-coherence <embedder>` and + must NOT gain any `dependency-consistency` or `metadata-consistency` entry for the + embedder; the existing "exactly one dependency-consistency item, scoped at dep1" + `dcRows` check then also covers the embedder — extend its diagnosis message to cite the + embedding half. +2. The embedder **is `changed`** (not `metadata-changed`): assert its impact category via + the harness's existing category observable (the same surface T1.6-4 uses for + category assertions), citing SPEC 5.5/5.6. +3. The change is **reviewed via the embedder's own `subtree-coherence` item**: covered by + the exact `kindScopeSet` (item 1); the diagnosis messages should say so, citing + SPEC 10.5's note. + +Also update the test's `title` to mirror the amended T10.5-3 statement (both halves), per +the harness convention that titles restate the TEST-SPEC statement. No traceability +change: the test ID stays T10.5-3 (`test/suite/registry/traceability.ts` untouched). + +**Verify.** `npm run typecheck` and `npm run format:check` green; `npm run test:self` +green (self-tests + certification — this is a product-facing test body, so no +certification fixture change is expected); after `npm run build`, the amended test runs +and fails-as-diagnosed against the stub product: +`npx vitest run --config test/vitest.config.ts --project suite test/suite/section-10.5.test.ts` +(Phase 9: product tests are expected to fail; failure text must be the test's own +diagnoses, not harness errors). + +--- + +## FP-096 — T12.0-1: JSON-only parity arm must not assert byte-identity across the two invocation forms + +**Status:** TODO + +**Requirement.** TEST-SPEC.md §11 preamble: "Each surface's flag-less and `--json` +invocations are asserted to carry the same information — **byte-identity between the two +forms is not asserted (SPEC.md does not require it)**." TEST-SPEC §12.0 T12.0-1: "the +JSON-only surfaces of 10.7, 11, and 12.6 emit the same single document with the flag as +without." SPEC.md 12.0's byte-determinism binds identical input only; a flagged and a +flag-less invocation are different inputs, and H-4/H-6 license byte comparison only for +identical invocations (PROCESS.md: the test spec must not add requirements — a harness +assertion beyond it is an added product requirement). + +**Gap (reviewer finding 2).** In `test/suite/registry/section-12.0-i.ts`, `runSweepStory`'s +`assertJsonOnlyParity` arm (currently lines ~422–456) reruns each `jsonOnly` step without +`--json` and calls `assertBytesEqual(bare.stdoutBytes, result.stdoutBytes, …)` — +byte-identity across the flagged/flag-less pair for `query` (all six subcommands), +`occurrences`, `view`, `at`, `inventory`, `review export`, and `version`. A conforming +product whose two forms differ only in JSON formatting bytes would be failed. + +**Change.** In the `assertJsonOnlyParity` arm of `runSweepStory`, replace the stdout +byte-equality with the specified assertion set: + +- each form exits identically (already asserted — both runs `expectExit(..., 0, ...)`; + keep it); +- each form emits a single JSON document as its entire stdout (already asserted — + `parseJsonStdout` on both runs; keep it, but capture the bare run's parsed document + instead of discarding it); +- the two decoded documents carry the same information — decode-and-compare: deep + equality of the parsed documents in which array order is significant but object key + order is not (key order is formatting, exactly what the fix must stop failing products + over). Note `support.ts`'s `assertSameJson` renders via `JSON.stringify` and is + therefore object-key-order-sensitive — canonicalize (recursively sort object keys) + before rendering, or add a key-order-insensitive deep-equality helper, with a diagnosis + citing SPEC 10.7/11/12.6 and TEST-SPEC §11 (same information; byte-identity not + asserted); +- byte-identity NOT asserted: remove the `assertBytesEqual` call (and the now-unused + import if nothing else in the module uses it). + +Update the prose that argues the old operationalization so the module stays honest: + +- the `SweepStoryOptions.assertJsonOnlyParity` doc comment (~lines 384–390, + "byte-identical to the flagged run's (product-to-itself, H-4…)"); +- the module-header bullet "T12.0-1's JSON-only parity arms" (~lines 36–45, which derives + byte comparison from H-4 — the derivation TEST-SPEC §11 rejects); +- the `T12_0_1` `title` (~line 468: "…byte-identical stdout at the same exit code + (product-to-itself, H-4)…") — reword to "same information at the same exit code, one + JSON document as the entire stdout each way; byte-identity not asserted" mirroring + TEST-SPEC §11/§12.0. + +**Leave untouched** (not flagged; different claims): T12.0-2's stderr-invariance +`assertBytesEqual` arms (~lines 577, 635 — SPEC 12.0's "the output form never changes an +exit code or standard-error content" compares stderr, not the JSON document) and every +byte-determinism compare over identical repeated invocations elsewhere in the harness +(H-4/H-6's licensed form). + +**Verify.** `npm run typecheck` and `npm run format:check` green; `npm run test:self` +green; after `npm run build`, the amended test still fails-as-diagnosed against the stub +product: +`npx vitest run --config test/vitest.config.ts --project suite test/suite/section-12.0-i.test.ts`. From bd7250baee505e781b816bd31398810e71d2ef84 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 19:34:35 +0000 Subject: [PATCH 141/226] sdg(phase-9): stage and assert the embedding half of SPEC 10.5's note in T10.5-3 (FP-095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T10.5-3's fixture staged only the d-edge half of SPEC 10.5's note (dep2's new d edge to the added h.newt). Add dep3, a top-level section present on both sides whose only change is that its text gains {text("h.newt")} — an embedding of a node added since the baseline. Assert the embedding half per TEST-SPEC §10.5: dep3 gets no dependency-consistency item and no metadata-consistency item (exact kindScopeSet gains only subtree-coherence dep3; the dcRows dep1-only check now covers both halves), dep3's impact category is exactly `changed` — never `metadata-changed` — via the impact surface T1.6-4 uses, and the change is reviewed via dep3's own subtree-coherence item. Title and diagnoses reworded to mirror the amended statement; test ID and traceability unchanged. Verified: typecheck, format:check, and test:self green (330 self-tests + certification); the amended section-10.5 suite runs green against the built product on this branch (the product already implements the note's embedding half — the plan's expectation of a stub-product red run was stale). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 72 +--------------------- test/suite/registry/section-10.5.ts | 92 ++++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 79 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index dd5b395..5e462e1 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -1,80 +1,12 @@ # FIX_PLAN — Phase 9 (test harness), compliance round 2 -Source: compliance review of TEST-SPEC.md §§10–18 + cross-cutting (2026-08-29). Two gaps. +Source: compliance review of TEST-SPEC.md §§10–18 + cross-cutting (2026-08-29). One gap +remains (FP-095 done: T10.5-3 now stages and asserts both halves of SPEC 10.5's note). Scope guard for every task: Phase 9 — harness code only (`test/`), never product code (`src/`). Task numbering continues from FP-094 (earlier rounds' tasks, already done and cleared). --- -## FP-095 — T10.5-3: stage and assert the embedding half of SPEC 10.5's note - -**Status:** TODO - -**Requirement.** TEST-SPEC.md §10.5, T10.5-3: "both halves of 10.5's note staged (a new -`d` edge makes the source `metadata-changed`, a new embedding makes it `changed`): a -fixture node whose only affected target was added since the baseline gets no -`dependency-consistency` item, its new `d` edge surfacing as its own -`metadata-consistency` item; **a second node whose only affected target entered through a -new `{text(...)}` embedding likewise gets no `dependency-consistency` item — the new -embedded reference changes its own content (5.5), it is `changed`, and the change is -reviewed via its own `subtree-coherence` item**." Product-spec anchor: SPEC.md 10.5, -dependency-impact rule 2's note ("An edge to a target added since the baseline is -necessarily itself new (5.4), so that change is reviewed at its source: a new `d` edge -makes the source `metadata-changed`, a new embedding makes it `changed` (5.6)"). - -**Gap (reviewer finding 1).** In `test/suite/registry/section-10.5.ts`, the T10.5-3 -fixture (`N_BASELINE`/`N_CURRENT`, currently lines ~919–1007) stages only the `d`-edge -half: `dep2` gains `d={"h.newt"}` in `N_CURRENT`. No node anywhere in the file gains a new -`{text(...)}` embedding (the file stages zero embeddings), and no assertion covers the -embedding half. - -**Change.** Extend the T10.5-3 fixture with a second node whose only affected target -enters through a `{text(...)}` embedding new since the baseline, and assert the embedding -half. Required properties of the staging (all load-bearing under SPEC.md 10.5/5.6): - -- The embedder is present in both `N_BASELINE` and `N_CURRENT`; its only change between - the two is that its text gains a `{text(...)}` embedding of a node **added since the - baseline** (e.g. a new top-level section `dep3` whose current text gains - `{text("h.newt")}` — the string form resolves within the same file, TEST-SPEC T2.3-2; - `h.newt` is already added-since-baseline in this fixture). -- The embedder carries no `d` attribute and no other embedding (so that target is its - *only* affected target), has no `changed` ancestor (else 10.5's skipping rule absorbs - its item), and sits at file top level (10.5 parent-consistency rule 2 creates items only - per non-root ancestor, keeping the expected item set free of parent-consistency noise — - same reasoning as the existing fixture comment at the top of the T10.5-3 block). -- No other node `d`-references or embeds the embedder, and the code fixture - (`N_CODE_BASELINE`/`N_CODE_CURRENT`, `src/ci3.ts`) does not reference it — leaving the - existing dep1/dep2/m/m2/code-impact assertions' expected values untouched. - -Assertions to add (the three the TEST-SPEC statement names): - -1. The embedder gets **no `dependency-consistency` item**: extend the exact - `kindScopeSet(status)` expected set — it must gain `subtree-coherence <embedder>` and - must NOT gain any `dependency-consistency` or `metadata-consistency` entry for the - embedder; the existing "exactly one dependency-consistency item, scoped at dep1" - `dcRows` check then also covers the embedder — extend its diagnosis message to cite the - embedding half. -2. The embedder **is `changed`** (not `metadata-changed`): assert its impact category via - the harness's existing category observable (the same surface T1.6-4 uses for - category assertions), citing SPEC 5.5/5.6. -3. The change is **reviewed via the embedder's own `subtree-coherence` item**: covered by - the exact `kindScopeSet` (item 1); the diagnosis messages should say so, citing - SPEC 10.5's note. - -Also update the test's `title` to mirror the amended T10.5-3 statement (both halves), per -the harness convention that titles restate the TEST-SPEC statement. No traceability -change: the test ID stays T10.5-3 (`test/suite/registry/traceability.ts` untouched). - -**Verify.** `npm run typecheck` and `npm run format:check` green; `npm run test:self` -green (self-tests + certification — this is a product-facing test body, so no -certification fixture change is expected); after `npm run build`, the amended test runs -and fails-as-diagnosed against the stub product: -`npx vitest run --config test/vitest.config.ts --project suite test/suite/section-10.5.test.ts` -(Phase 9: product tests are expected to fail; failure text must be the test's own -diagnoses, not harness errors). - ---- - ## FP-096 — T12.0-1: JSON-only parity arm must not assert byte-identity across the two invocation forms **Status:** TODO diff --git a/test/suite/registry/section-10.5.ts b/test/suite/registry/section-10.5.ts index 1591a18..109f337 100644 --- a/test/suite/registry/section-10.5.ts +++ b/test/suite/registry/section-10.5.ts @@ -59,6 +59,7 @@ import type { } from "../../helpers/adapters/index.js"; import { decodeExportReport, + decodeImpactReport, decodeItemReport, decodeNextReport, decodeNodeReport, @@ -901,7 +902,15 @@ const T10_5_2 = defineProductTest({ // Top-level sections so no parent-consistency noise arises; h holds the // added/deleted children (its own item absorbs them via the skipping rule, -// keeping the root unchanged). +// keeping the root unchanged). Both halves of SPEC 10.5's note are staged +// against the added target h.newt: dep2's only affected target enters +// through a new `d` edge (dep2 becomes `metadata-changed`), dep3's through a +// new `{text(...)}` embedding (dep3 becomes `changed`, SPEC 5.5/5.6 — the +// string form resolves within the same file, TEST-SPEC T2.3-2) — neither +// gets a `dependency-consistency` item, each change being reviewed at its +// source. dep3 carries no `d` attribute and no other embedding, nothing +// references or embeds dep3, and the code fixture never mentions it, so the +// other items' expected values stay untouched. const N_FILE = "specs/N.mdx"; const N_X = "specs/N.mdx#x"; const N_Y = "specs/N.mdx#y"; @@ -910,6 +919,7 @@ const N_M2 = "specs/N.mdx#m2"; const N_T = "specs/N.mdx#t"; const N_DEP1 = "specs/N.mdx#dep1"; const N_DEP2 = "specs/N.mdx#dep2"; +const N_DEP3 = "specs/N.mdx#dep3"; const N_H = "specs/N.mdx#h"; const N_H_GONE = "specs/N.mdx#h.gone"; const N_H_NEWT = "specs/N.mdx#h.newt"; @@ -945,6 +955,10 @@ const N_BASELINE = [ "Dep two text.", "</S>", "", + '<S id="dep3">', + "Dep three text.", + "</S>", + "", '<S id="h">', "Aitch own text.", "", @@ -988,6 +1002,10 @@ const N_CURRENT = [ "Dep two text.", "</S>", "", + '<S id="dep3">', + 'Dep three text. {text("h.newt")}', + "</S>", + "", '<S id="h">', "Aitch own text.", "", @@ -1025,7 +1043,7 @@ const N_CODE_CURRENT = [ const T10_5_3 = defineProductTest({ id: "T10.5-3", title: - "metadata, dependency, and code items: one metadata-consistency item per metadata-changed node — m's d retargeting (context: the added and removed d targets), m2's coverage and tags edits (empty context; both changes described in the reason), and dep2's added d edge — one dependency-consistency item per node with a dependency edge to a both-sides target whose effectiveHash changed (dep1 against t; context: the changed target; origin: its originating node), while dep2, whose only affected target h.newt was added since the baseline, gets no dependency-consistency item — the change is reviewed at its source as dep2's own metadata-consistency item (context: the added target); and one code-impact item per impacted location with context the impact-edge targets that make it impacted, deleted (h.gone) and added (h.born) targets included, unchanged targets excluded (SPEC 5.6, 9.2, 10.5)", + "metadata, dependency, and code items: one metadata-consistency item per metadata-changed node — m's d retargeting (context: the added and removed d targets), m2's coverage and tags edits (empty context; both changes described in the reason), and dep2's added d edge — one dependency-consistency item per node with a dependency edge to a both-sides target whose effectiveHash changed (dep1 against t; context: the changed target; origin: its originating node), with both halves of 10.5's note staged (a new d edge makes the source metadata-changed, a new embedding makes it changed): dep2, whose only affected target h.newt was added since the baseline, gets no dependency-consistency item — the change is reviewed at its source as dep2's own metadata-consistency item (context: the added target) — and dep3, whose only affected target entered through a new {text(...)} embedding, likewise gets no dependency-consistency item — the new embedded reference changes dep3's own content, it is changed (not metadata-changed), and the change is reviewed via dep3's own subtree-coherence item; and one code-impact item per impacted location with context the impact-edge targets that make it impacted, deleted (h.gone) and added (h.born) targets included, unchanged targets excluded (SPEC 5.5, 5.6, 9.2, 10.5)", timeoutMs: 240_000, run: async (product) => { await withWorkspace( @@ -1042,6 +1060,55 @@ const T10_5_3 = defineProductTest({ workspace, `${prefix} \`build\` after the edits`, ); + + // The embedding half's category (SPEC 10.5's note): the new + // `{text(...)}` embedding changes dep3's own content, so dep3 is + // `changed` — never `metadata-changed`, embedded references + // surfacing through ownHash, not metadataHash (SPEC 5.5, 5.6). + // Asserted via the impact surface, as in T1.6-4. + const impactLabel = `${prefix} \`impact --base <baseline> --json\``; + const impact = decodeImpactReport( + await runJson( + product, + workspace, + ["impact", "--base", base, "--json"], + impactLabel, + ), + impactLabel, + ); + const dep3Entry = impact.requirements.find((entry) => + entry.nodes.includes(N_DEP3), + ); + if (dep3Entry === undefined) { + fail( + `${impactLabel}: expected an entry for ${N_DEP3} — its new ` + + `{text(...)} embedding changed its own content (SPEC 5.5, ` + + `5.6); got entries for ` + + JSON.stringify(impact.requirements.map((entry) => entry.nodes)), + ); + } + assertSameJson( + dep3Entry.nodes, + [N_DEP3], + `${impactLabel}: dep3's entry covers exactly dep3 (its category ` + + `is \`changed\`, so no ancestor chain collapses onto it, ` + + `SPEC 9.3)`, + ); + assertSameJson( + dep3Entry.deleted, + false, + `${impactLabel}: dep3 is present on both sides of the baseline`, + ); + assertSameJson( + dep3Entry.categories.map((category) => category.category), + ["changed"], + `${impactLabel}: a new {text(...)} embedding changes the ` + + `embedder's own content, so dep3's only category is \`changed\` ` + + `— never \`metadata-changed\`: embedded references surface ` + + `through ownHash, not metadataHash (SPEC 5.5, 5.6; SPEC 10.5's ` + + `note: "a new embedding makes it \`changed\`")`, + ); + await createBaseSession(product, workspace, base, "s", prefix); const status = await sessionStatus(product, workspace, "s", prefix); @@ -1053,17 +1120,22 @@ const T10_5_3 = defineProductTest({ `metadata-consistency ${N_DEP2}`, `metadata-consistency ${N_M}`, `metadata-consistency ${N_M2}`, + `subtree-coherence ${N_DEP3}`, `subtree-coherence ${N_H}`, `subtree-coherence ${N_T}`, ].sort(), `${prefix}: one metadata-consistency item per metadata-changed ` + `node (m, m2, dep2), one dependency-consistency item for dep1 ` + - `alone — dep2's only affected target was added since the ` + - `baseline, so its change is reviewed at its source — one ` + - `code-impact item for the impacted location, and the ` + - `subtree-coherence items of the changed nodes t and h (h's ` + - `child additions and deletion originate at h; the skipped ` + - `children generate no own items) (SPEC 5.6, 10.5)`, + `alone — dep2's and dep3's only affected target was added ` + + `since the baseline, so each change is reviewed at its source ` + + `(SPEC 10.5's note): dep2's new d edge as dep2's own ` + + `metadata-consistency item, dep3's new {text(...)} embedding ` + + `via dep3's own subtree-coherence item (dep3 is changed, so it ` + + `gets no metadata-consistency item either) — one code-impact ` + + `item for the impacted location, and the subtree-coherence ` + + `items of the other changed nodes t and h (h's child additions ` + + `and deletion originate at h; the skipped children generate no ` + + `own items) (SPEC 5.5, 5.6, 10.5)`, ); const dcRows = status.items.filter( (row) => row.kind === "dependency-consistency", @@ -1072,7 +1144,9 @@ const T10_5_3 = defineProductTest({ fail( `${prefix}: exactly one dependency-consistency item exists, ` + `scoped at dep1 — an edge to a target added since the ` + - `baseline yields no such item (SPEC 10.5, 5.6); got ` + + `baseline yields no such item, for dep2's new d edge and ` + + `dep3's new {text(...)} embedding alike (SPEC 10.5's note, ` + + `5.6); got ` + JSON.stringify(dcRows.map((row) => row.scope)), ); } From d2abb674a802affede9143581ddb90d05394468d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 19:40:07 +0000 Subject: [PATCH 142/226] sdg(phase-9): compare decoded documents, not bytes, in T12.0-1's JSON-only parity arm (FP-096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEST-SPEC §11 preamble: the flag-less and --json forms of a JSON-only surface are asserted to carry the same information — byte-identity between the two forms is not asserted (SPEC.md does not require it; H-4/H-6 license byte comparison only across identical invocations). runSweepStory's parity arm now decodes both stdouts and compares the parsed documents with object key order insignificant (recursively sorted before assertSameJson's key-order-sensitive rendering) and array order significant, replacing the assertBytesEqual over the pair. T12.0-2's stderr-invariance byte compares (different claim: stderr, one invocation's two forms) are untouched, and the module header, option/step doc comments, the T12.0-1 title, and section-12.6's cross-reference no longer argue the byte operationalization. Verified: typecheck, format:check, test:self (330/330, certification included) green; against the stub product the suite file still fails as diagnosed (T12.0-1 at the first sweep step: unknown command 'occurrences'). Final FP-096 task done — FIX_PLAN.md deleted (Ralph Loop note 3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 69 --------------------------- test/suite/registry/section-12.0-i.ts | 60 ++++++++++++++++------- test/suite/registry/section-12.6.ts | 2 +- 3 files changed, 45 insertions(+), 86 deletions(-) delete mode 100644 specs/tmp/FIX_PLAN.md diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md deleted file mode 100644 index 5e462e1..0000000 --- a/specs/tmp/FIX_PLAN.md +++ /dev/null @@ -1,69 +0,0 @@ -# FIX_PLAN — Phase 9 (test harness), compliance round 2 - -Source: compliance review of TEST-SPEC.md §§10–18 + cross-cutting (2026-08-29). One gap -remains (FP-095 done: T10.5-3 now stages and asserts both halves of SPEC 10.5's note). -Scope guard for every task: Phase 9 — harness code only (`test/`), never product code (`src/`). -Task numbering continues from FP-094 (earlier rounds' tasks, already done and cleared). - ---- - -## FP-096 — T12.0-1: JSON-only parity arm must not assert byte-identity across the two invocation forms - -**Status:** TODO - -**Requirement.** TEST-SPEC.md §11 preamble: "Each surface's flag-less and `--json` -invocations are asserted to carry the same information — **byte-identity between the two -forms is not asserted (SPEC.md does not require it)**." TEST-SPEC §12.0 T12.0-1: "the -JSON-only surfaces of 10.7, 11, and 12.6 emit the same single document with the flag as -without." SPEC.md 12.0's byte-determinism binds identical input only; a flagged and a -flag-less invocation are different inputs, and H-4/H-6 license byte comparison only for -identical invocations (PROCESS.md: the test spec must not add requirements — a harness -assertion beyond it is an added product requirement). - -**Gap (reviewer finding 2).** In `test/suite/registry/section-12.0-i.ts`, `runSweepStory`'s -`assertJsonOnlyParity` arm (currently lines ~422–456) reruns each `jsonOnly` step without -`--json` and calls `assertBytesEqual(bare.stdoutBytes, result.stdoutBytes, …)` — -byte-identity across the flagged/flag-less pair for `query` (all six subcommands), -`occurrences`, `view`, `at`, `inventory`, `review export`, and `version`. A conforming -product whose two forms differ only in JSON formatting bytes would be failed. - -**Change.** In the `assertJsonOnlyParity` arm of `runSweepStory`, replace the stdout -byte-equality with the specified assertion set: - -- each form exits identically (already asserted — both runs `expectExit(..., 0, ...)`; - keep it); -- each form emits a single JSON document as its entire stdout (already asserted — - `parseJsonStdout` on both runs; keep it, but capture the bare run's parsed document - instead of discarding it); -- the two decoded documents carry the same information — decode-and-compare: deep - equality of the parsed documents in which array order is significant but object key - order is not (key order is formatting, exactly what the fix must stop failing products - over). Note `support.ts`'s `assertSameJson` renders via `JSON.stringify` and is - therefore object-key-order-sensitive — canonicalize (recursively sort object keys) - before rendering, or add a key-order-insensitive deep-equality helper, with a diagnosis - citing SPEC 10.7/11/12.6 and TEST-SPEC §11 (same information; byte-identity not - asserted); -- byte-identity NOT asserted: remove the `assertBytesEqual` call (and the now-unused - import if nothing else in the module uses it). - -Update the prose that argues the old operationalization so the module stays honest: - -- the `SweepStoryOptions.assertJsonOnlyParity` doc comment (~lines 384–390, - "byte-identical to the flagged run's (product-to-itself, H-4…)"); -- the module-header bullet "T12.0-1's JSON-only parity arms" (~lines 36–45, which derives - byte comparison from H-4 — the derivation TEST-SPEC §11 rejects); -- the `T12_0_1` `title` (~line 468: "…byte-identical stdout at the same exit code - (product-to-itself, H-4)…") — reword to "same information at the same exit code, one - JSON document as the entire stdout each way; byte-identity not asserted" mirroring - TEST-SPEC §11/§12.0. - -**Leave untouched** (not flagged; different claims): T12.0-2's stderr-invariance -`assertBytesEqual` arms (~lines 577, 635 — SPEC 12.0's "the output form never changes an -exit code or standard-error content" compares stderr, not the JSON document) and every -byte-determinism compare over identical repeated invocations elsewhere in the harness -(H-4/H-6's licensed form). - -**Verify.** `npm run typecheck` and `npm run format:check` green; `npm run test:self` -green; after `npm run build`, the amended test still fails-as-diagnosed against the stub -product: -`npx vitest run --config test/vitest.config.ts --project suite test/suite/section-12.0-i.test.ts`. diff --git a/test/suite/registry/section-12.0-i.ts b/test/suite/registry/section-12.0-i.ts index f5fdb73..c144393 100644 --- a/test/suite/registry/section-12.0-i.ts +++ b/test/suite/registry/section-12.0-i.ts @@ -39,10 +39,13 @@ // without. Each such step (all reads, so rerunnable) is rerun without // `--json` at the same story state, asserting the same exit code (0), a // single JSON document as the entire stdout (H-5's JSON-only clause), and -// stdout byte-identical to the flagged run's: a single JSON document is -// the surface's ONLY output form with or without the flag, so the flag is -// inert there and "the same single document" is a product-to-itself byte -// comparison (H-4). +// that the two decoded documents carry the same information: deep +// equality of the parsed documents with array order significant and +// object key order not (key order is formatting, not information). +// Byte-identity across the flagged/flag-less pair is NOT asserted — +// TEST-SPEC §11: SPEC.md does not require it, and H-4/H-6 license byte +// comparison only across identical invocations, which a flagged and a +// flag-less run are not. // - T12.0-4 doubles `--config` with an identical value across the whole sweep // — a repetition regardless of value, and the strictest probe (it fails a // product that dedupes repeated identical values). Each doubled run's argv @@ -210,7 +213,8 @@ interface SweepStep { * The step drives a JSON-only surface (SPEC 10.7, 11, 12.6): a single JSON * document is its only output form, with or without `--json`. T12.0-1's * parity arm reruns the step without the flag and asserts the same single - * document (byte-identical stdout; see the module header). + * document (same information; byte-identity not asserted — see the module + * header). */ readonly jsonOnly?: true; } @@ -384,14 +388,37 @@ interface SweepStoryOptions { /** * T12.0-1's parity arm: rerun each JSON-only step (SPEC 10.7, 11, 12.6) * without `--json` and assert it emits the same single document — same - * exit code, one JSON document as the entire stdout, byte-identical to the - * flagged run's (product-to-itself, H-4; see the module header). + * exit code, one JSON document as the entire stdout, decoding to the same + * information as the flagged run's (key-order-insensitive deep equality; + * byte-identity not asserted — see the module header). */ readonly assertJsonOnlyParity?: boolean; /** Test id labelling every diagnosis (e.g. "T12.0-1"). */ readonly label: string; } +/** + * Recursively sort object keys so two decoded JSON documents that differ + * only in key order render identically under `assertSameJson`'s + * `JSON.stringify` comparison (which is key-order-sensitive). Arrays are + * mapped element-wise, never reordered — array order stays significant; + * object key order is formatting, not information (TEST-SPEC §11). + */ +function canonicalizeJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeJson); + } + if (value !== null && typeof value === "object") { + const record = value as Record<string, unknown>; + const sorted: Record<string, unknown> = {}; + for (const key of Object.keys(record).sort()) { + sorted[key] = canonicalizeJson(record[key]); + } + return sorted; + } + return value; +} + /** * Run the full-surface story: every step with `--json` (and the sweep's extra * flags), asserting exit 0 exactly (H-5) and that the entire stdout is one @@ -438,20 +465,21 @@ async function runSweepStory(options: SweepStoryOptions): Promise<void> { `11, 12.6), and the output form never changes an exit code ` + `(SPEC 12.0)`, ); - parseJsonStdout( + const bareDoc = parseJsonStdout( bare, `${bareContext} — on a JSON-only surface a single JSON document is ` + `the entire standard output with or without --json (SPEC 10.7, ` + `11, 12.6, H-5)`, ); - assertBytesEqual( - bare.stdoutBytes, - result.stdoutBytes, + assertSameJson( + canonicalizeJson(bareDoc), + canonicalizeJson(doc), `${bareContext} — the JSON-only surfaces of 10.7, 11, and 12.6 emit ` + - `the same single document with the flag as without: a single JSON ` + - `document is the surface's only output form, so the flag is inert ` + - `and stdout is byte-identical across the pair (SPEC 10.7, 11, ` + - `12.6; product-to-itself, H-4)`, + `the same single document with the flag as without: the two ` + + `decoded documents carry the same information, compared with ` + + `array order significant and object key order not (SPEC 10.7, ` + + `11, 12.6; TEST-SPEC §11 — byte-identity between the two forms ` + + `is not asserted)`, ); } step.harvest?.(doc, options.state, context); @@ -465,7 +493,7 @@ async function runSweepStory(options: SweepStoryOptions): Promise<void> { const T12_0_1 = defineProductTest({ id: "T12.0-1", title: - "`--json` everywhere: every command and subcommand — build, check, ids, show, coverage, impact, all six query subcommands, occurrences, view, at, inventory, version, all eight review subcommands, rename, and file-form move — accepts the flag and emits exactly one JSON document as the entire standard output at its specified exit code; the JSON-only surfaces of 10.7, 11, and 12.6 (review export; the query subcommands, occurrences, view, at, and inventory; version) emit the same single document with the flag as without — byte-identical stdout at the same exit code (product-to-itself, H-4); information parity with the human report is adapter-verified per command by the per-section tests (SPEC 12.0, 11, 12.6, 10.7)", + "`--json` everywhere: every command and subcommand — build, check, ids, show, coverage, impact, all six query subcommands, occurrences, view, at, inventory, version, all eight review subcommands, rename, and file-form move — accepts the flag and emits exactly one JSON document as the entire standard output at its specified exit code; the JSON-only surfaces of 10.7, 11, and 12.6 (review export; the query subcommands, occurrences, view, at, and inventory; version) emit the same single document with the flag as without — same information at the same exit code, one JSON document as the entire stdout each way; byte-identity between the two forms is not asserted (TEST-SPEC §11); information parity with the human report is adapter-verified per command by the per-section tests (SPEC 12.0, 11, 12.6, 10.7)", timeoutMs: 240_000, run: async (product) => { const { workspace, state } = await createSweepWorkspace(); diff --git a/test/suite/registry/section-12.6.ts b/test/suite/registry/section-12.6.ts index ec1e440..605e248 100644 --- a/test/suite/registry/section-12.6.ts +++ b/test/suite/registry/section-12.6.ts @@ -197,7 +197,7 @@ const T12_6_1 = defineProductTest({ // Flagged form: `--json` is accepted and inert on a JSON-only // surface — the same document form at the same exit code (SPEC - // 12.6, 12.0; the byte-level flag-parity compare is T12.0-1's). + // 12.6, 12.0; the flag-parity compare is T12.0-1's). const flaggedContext = "T12.6-1 `version --json`"; const flagged = await expectVersionAnswer( product, From 2be1bc7f288ad790cf1e1481b70cfc57a6751000 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 19:53:05 +0000 Subject: [PATCH 143/226] =?UTF-8?q?sdg(phase-9):=20flip=20patch=200001=20s?= =?UTF-8?q?tage=20to=20Tested=20=E2=80=94=20code=20review=20clean,=20harne?= =?UTF-8?q?ss=20spec-compliant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/patches/0001-external-ui-apis.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/patches/0001-external-ui-apis.md b/specs/patches/0001-external-ui-apis.md index 211b995..ba4510a 100644 --- a/specs/patches/0001-external-ui-apis.md +++ b/specs/patches/0001-external-ui-apis.md @@ -1,7 +1,7 @@ # 0001 — Foundational machine surfaces for an external spec UI - **Type:** Improvement Proposal (IP) -- **Stage:** Tests Specified +- **Stage:** Tested - **Branch:** `claude/xspec-ui-apis-4df8fa` (harness-designated for this session; stands in for `patch/external-ui-apis`) ## Motivation From f4191225f81bcb13b53335de9793ba8dd85d04bd Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 20:21:58 +0000 Subject: [PATCH 144/226] sdg(phase-10): plan product-compliance fixes from review findings 23 tasks in three dependency-ordered stages: the SPEC 12.7 finding/error- document forms and stable-code model first (the choke points ~90 failures decode through), then the patch-0001 command surfaces (version, occurrences, view, at, inventory, previews) on the new occurrence and code-range machinery, then localized behavioral fixes (refusal contract, operand classification, gate precedence, obstructed writes, unreadable-record persistence, review payload ranges). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 590 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 590 insertions(+) create mode 100644 specs/tmp/FIX_PLAN.md diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md new file mode 100644 index 0000000..3cce714 --- /dev/null +++ b/specs/tmp/FIX_PLAN.md @@ -0,0 +1,590 @@ +# FIX_PLAN — Phase 10: product adherence to `specs/SPEC.md` + +Source: two compliance reviews of the product against SPEC.md plus a red verify run +(142 failed / 495 passed; branch `claude/xspec-ui-apis-4df8fa`, PR #7). Goal: every +test passes (`npm test` locally and in CI, including the Windows E-6 leg). + +**Rules for every task (read once per spawn):** + +- Phase 10: never modify the test harness (`test/`). Product code (`src/`) only. + Never couple product code to harness internals. +- Respect `specs/IMPLEMENTATION.md` (three layers: pure `core`, I/O `workspace`, + rendering `cli`; one canonical JSON serializer; findings built as data, rendered + once per output form; no new runtime dependencies). +- Work top to bottom. The stages are ordered by dependency: Stage A rebuilds the + SPEC 12.7 finding/error-document forms and the stable-code model — single choke + points that ~90 failures decode through; Stage B adds the missing command + surfaces; Stage C makes localized behavioral fixes. A task lists its hard + prerequisites; do not pick a task whose prerequisites are still in this file. +- Build first (`npm run build`), then run the named tests: + `npx vitest run --config test/vitest.config.ts --project suite test/suite/<file>` + (see `AGENTS.md`). A task's named tests are its verification; other tests may + stay red until later tasks land. Before committing: `npm run typecheck` and + `npm run format`. Commit `sdg(phase-10): <imperative summary>`, push. +- When a task is done, remove it from this file in the same commit. If a task + turns out too large for one spawn, land a coherent part, and replace the task + with precise remainder task(s) here. When the last task is removed, delete + this file. + +--- + +## Stage A — SPEC 12.7 report forms and the stable-code model + +### A1. Rebuild the finding model into the 12.7 finding form with stable code tokens + +SPEC 14 (code table), 12.7 (finding value form), 12.0. The model in +`src/core/findings.ts` carries `condition: 1..22` plus ad-hoc members +(`correction`, `file`, `range`, `line`, `column`, `cycle`, `rule`, `edge`); the +serializer `findingToJson` (`src/cli/report.ts`) emits `{"condition":"14.N",...}`. +Required observable form: `{"code", "message", "locations", "path", "identities"}` +— exactly these five members, `null` never omitted, empty lists `[]`. + +- Extend the condition table to 23 entries with the SPEC 14 tokens as string + codes: `missing-id`, `invalid-structural-id`, `duplicate-id`, + `invalid-segment-or-tag`, `unknown-dependency`, `unknown-text-target`, + `unknown-ts-reference`, `invalid-argument`, `cycle`, `stale-output`, + `cross-module-text`, `policy-violation`, `journal-error`, + `configuration-error`, `invalid-import`, `invalid-construct`, `invalid-prop`, + `unsupported-node-usage`, `invalid-source-path`, `unparseable-source`, + `corrupt-session`, `obstructed-write-path` (rename condition 22 from the + current "symbolic link in a write path" — behavioral widening is task C3), + `unreadable-record` (new condition 23, SPEC 14.23). Add the nine refusal-reason + tokens in SPEC 14's listed order (used from task B8 on): `refused-invalid-id`, + `refused-identity-unchanged`, `refused-id-collision`, + `refused-structural-parent`, `refused-unresolvable-reference`, `refused-cycle`, + `refused-destination-exists`, `refused-missing-target-parent`, + `refused-invalid-destination`. Keep an internal ordinal per code for sorting + (numbered conditions 1–23, then refusal reasons in listed order, then + code-less) and the exit class; the emitted `code` is the token string, or + `null` for code-less findings (10.7 review refusals — task A5). +- Model members: `message` (fold the current `correction` into it — SPEC 14 + actionability lives in the message); `locations` — ordered list of + `{file, range}`, one per offending construct, empty for conditions without + in-source locations; `path` — the concerned file or path for non-located + conditions, `null` for located ones; `identities` — list of context strings, + empty where none. Content of `identities` is contractual only where 14 states + it: condition 12 carries, in order, the violated rule's name, the edge's + source identity, its kind token (`"depends"`/`"embeds"`/`"references"`, 12.7), + and its target identity, with `locations` empty and `path` null (fold the + current `rule`/`edge` members in here); condition 11's foreign module is + identity data, not a location. +- Port every construction site (≈40 across `src/core/*` and `src/workspace/*`) + mechanically: today's single `file`+`range` becomes a one-element `locations`; + a concerned-path-style `file` without range (conditions 13, 14, 19, 21, 22, + 10's unit form) becomes `path`. Conditions that locate in source must carry + byte ranges (SPEC 14, 1.7): convert condition-20 parse-failure locations from + `line`/`column` to the failure's byte range (remark-mdx and the TS compiler + both expose offsets); drop `line`/`column` entirely. The legacy `cycle` + member: keep the cycle path in `identities` for now — cycle-as-locations is + task A3. +- Update the JSON serializer to emit exactly the five-member form and the human + renderer (`renderFindingsHuman`, `renderFindingLine`) to present the same + information (code token, every location, concerned path) — SPEC 14 last + paragraph, 12.0. + +Verify: `section-16-p1.test.ts` (P-1, seed 271828183 fails today at +`$.findings[0].condition: expected no member "condition"`), `section-14.test.ts` +T14-1..6 arms, `section-7*.test.ts` T7.1-1/T7.5-2..6, T12.1-4, T12.2-2/3. + +### A2. Findings-array ordering, duplicate collapse, and the marked byte-form path + +SPEC 12.7 (ordering and collapse), 12.0 (marked byte form). Prereq A1. Implement +one ordering/dedup function applied by every findings emitter +(`src/cli/report.ts`), and one path-value renderer shared by all JSON output: + +- Order `"findings"` by: code ordinal (numbered conditions in numeric order, + then refusal reasons in 14's listed order, then code-less findings); then + `locations` element-wise — each element by file path bytes, then range start, + then range end; a sequence that is a proper prefix of another sorts first; + then `path` (`null` before any path; paths compare byte-wise whatever their + presentation form); then `identities` element-wise under the same prefix rule + (string elements byte-wise); then `message`. Findings identical in every + member collapse to one. +- Path value form (12.7): a path whose bytes are valid UTF-8 is a string; + otherwise the marked byte form `{"bytes": "<lowercase hex, two digits per + byte>"}` — used wherever a JSON output carries a workspace-relative path + (finding `locations[].file` and `path` now; the 11.3–11.6 surfaces reuse it in + Stage B). This needs the internal path representation at finding sites to + preserve raw bytes for non-UTF-8 discovered paths (14.19) rather than a lossy + string — choose conservatively (e.g. carry bytes alongside the string) and + note the choice. + +Verify: T12.7-1/2 (`section-12.7.test.ts`), T14 ordering/collapse arms, P-7. + +### A3. Multi-location cardinality for jointly-violated conditions + +SPEC 14 (location-cardinality paragraph). Prereq A1. One finding carries a +location for every participating construct — no representative: + +- Condition 3 (duplicate ID): one finding per duplicated identity locating every + bearer (`src/core/spec-references.ts` / structure validation). +- Condition 15 import-binding collision: locate every colliding declaration. +- Condition 9 (cycle): locate the full path in source — a dependency cycle by + every reference spelling recording a participating dependency edge, a spec + import cycle by each participating import declaration — `path` null; the cycle + is locations now, not `identities` (replace A1's interim). Producer: + `src/core/graph.ts` cycle detection must map edges back to spellings. +- A reference spelling of the MDX embedding form that records no occurrence + (unknown target / invalid argument, 14.5–14.8): the finding's range is the + full braced container, opening brace through closing brace + (`SpecEmbedding.range` in `src/core/mdx.ts` already holds it) — not the inner + expression. + +Verify: T14-8 (`section-14.test.ts`), T7.5 arms. + +### A4. Exit-2 JSON error document + +SPEC 12.0 (JSON-in-effect rule), 12.7 (error document), 14.14. Prereq A1. Today +every exit-2 outcome leaves stdout empty (`src/cli/main.ts` usage path; +`emitConfigurationErrors` in `src/cli/report.ts` writes stderr only). Required: + +- JSON output is in effect exactly when `--json` appears among the invocation's + arguments — even when the arguments are themselves the error: unknown command, + unknown flag, malformed value — or when the invoked surface is JSON-only + (`query`, `occurrences`, `view`, `at`, `inventory` (11), `version` (12.6), + `review export` (10.7)). When in effect, an exit-2 usage or configuration + error emits as its entire stdout the single document `{"error": <finding + form>}` (one five-member finding form, 12.7). Stderr diagnostics and exit + codes are unchanged; without JSON in effect stdout stays empty. +- Plain usage error: `code` and `path` null (no stable code — 14). +- Configuration error (14.14): `code` `"configuration-error"`; `path` is the + concerned path in the anchoring form of 11.6, relative to the invocation + working directory — the configuration file the upward search found or + `--config` named (canonical `..`-ascend/descend `/`-joined spelling), or `.` + when the upward search failed with no `--config`. One invocation reports one + error: a configuration file with several defects is a single condition-14 + finding with a deterministic message. Implement the cwd→path anchoring + spelling as a shared helper (`inventory`, task B7, reuses it). + +Verify: T12.0-1..6 arms (`section-12.0*.test.ts`), ~25 tests across sections +7–13 that exercise exit-2 under `--json`. + +### A5. Review-operation refusals report as code-less findings + +SPEC 10.7, 12.7, 14 ("review-operation refusals likewise carry none"). Prereq +A1. `emitReviewRefusal` (`src/cli/commands/review-session.ts:789`) emits +`{"refused": {"command", "message"}}` / ad-hoc text. Required: a refused review +operation (`split` on a wrong-kind or childless item, `resolve` on a blocked +item, `create` with an existing name) is a findings-class outcome whose report +is `{"findings": [...]}` — one finding, `code` null, `locations` empty, +`identities` per 14 (informational), exit 1, nothing modified; human form the +same information. + +Verify: T10.3-1, T10.7 refusal arms (`section-10*.test.ts`). + +### A6. `rename`/`move` success report is the applied mapping + +SPEC 6.4 ("A successful rename's report is the applied mapping … the +information of the preview's `mapping` (6.6), carried in JSON per 12.0"), 6.5 +(move "reports its applied mapping, as rename does"). Today both emit +`{"findings":[]}` (JSON) / nothing (human) — verify run: "missing `mapping` key". +Report the complete identity mapping the operation journaled: JSON carries a +`mapping` member of `{"from", "to"}` entries ordered by `from` bytes (the +preview `mapping` form of 12.7); human output lists the same mapping. Files: +`src/cli/commands/rename.ts`, `src/cli/commands/move.ts`. + +Verify: T6.4-1, T6.5-1 success arms (`section-6.4.test.ts`, +`section-6.5*.test.ts`). + +--- + +## Stage B — missing command surfaces (patch 0001) + +### B1. `xspec version` + +SPEC 12.6, 12.7, 12.0. Not in the command table (`src/cli/args.ts` `COMMANDS`); +exits 2 unknown-command today. Add the command and handler (`src/cli/main.ts`): + +- JSON-only: `{"product": <string>, "interface": "1"}` is its only output form, + with or without `--json`. Interface is exactly the string `"1"`. Product + version fixed per build (read from the package's own metadata at build or + startup — byte-deterministic, no environment-dependent content). +- Loads no configuration and consults no workspace: `--config` accepted, not + consulted; identical output in any working directory, missing/invalid + configuration included — configuration-error precedence (14.14) never reaches + it. Usage errors (unknown flag) keep exit 2 and, being a JSON-only surface, + emit the A4 error document. + +Verify: T12.6-1/2 (`section-12.6.test.ts`), T12.0-9 arm; unblocks the Windows CI +leg's leading edge (E-6). + +### B2. Record reference occurrences in core analysis and graph data + +SPEC 5.7, 13.3, 12.7 (occurrence record form), 11.2 (existence/source-datum +rules). No occurrence concept exists in `src/`. In `core`, compute and carry, +and in graph data persist, reference occurrences: + +- One occurrence per textual spelling of a dependency-kind reference whose + target resolves: each `d` array entry separately; each MDX `{text(...)}` + embedding; each TS `text(...)` call; each TS dependency marker. Duplicates + collapsing to one edge remain distinct occurrences. Constructs recording no + edge record none (imports, type-only bindings, shadowed chains, dynamic or + unresolving spellings). +- Record: referencing file; own range — exact per kind: a `d` entry's own + expression; an MDX embedding the entire braced container, opening through + closing brace (`SpecEmbedding.range`, `src/core/mdx.ts`, already holds it); a + TS `text(...)` occurrence the entire call expression, callee through closing + parenthesis (record this span in `src/core/code-analysis.ts` — today only the + argument chain's span exists); a marker the bare chain, terminator excluded + (`CodeReference.range` holds it) — edge kind; source graph node as one datum + (identity plus that node's own range; explicitly unavailable when 11.2 leaves + the containing node's identity undefined — representable now, consumed by + Stage B surfaces); resolved target's identity. +- Total order: referencing file path bytes, then range start, then range end. +- Persist in graph data (`src/core/graph-data.ts` stored shape, + `src/core/graph.ts`) so 13.3's "graph data contains … reference occurrences" + holds and refresh round-trips them byte-deterministically. + +Verify: T5.7-1..4 (`section-5.7.test.ts`) via the Stage B surfaces; T13.3-1/2 +arms once B4 lands. + +### B3. Compute code-location source ranges + +SPEC 1.7 (code-location ranges), 4.6. `CodeUnit` (`src/core/code-analysis.ts`), +`CodeLocationNode` (`src/core/graph.ts`), and `StoredCodeLocation` +(`src/core/graph-data.ts`) carry no range. Compute and store one per code +location: whole-file = the entire file; named unit = the construct binding its +name — a function/class-valued variable declaration's unit spans its own name +through its initializer (not the multi-declaration statement); the nested units +of a dotted namespace name all share the single namespace declaration's range; a +named default export takes the exported construct's range, an anonymous one's +`default` unit the whole export declaration; `path#unit@N` takes its own +occurrence's construct. Presentation stays confined to exactly two outputs — +occurrence records (B2/B4) and review payloads (C6); everywhere else a code +location remains a bare identity (query edges/reachable unchanged). + +Verify: T1.7-2 (`section-1.7.test.ts`) once B4/C6 expose the ranges. + +### B4. `xspec occurrences` and the shared 11.2 availability layer + +SPEC 11.3, 11.2, 12.7. Prereqs A1–A4, B2. Register the command (JSON-only) and +build the per-file availability machinery in `core` (not in the command file — +`view`/`at` reuse it): + +- Document `{"findings", "occurrences"}`: records in occurrence order, each in + the 12.7 record form `{"file", "range", "kind", "source", "target"}`, `source` + `{"identity", "range"}` or `{"unavailable": true}` per 11.2. +- `--file <glob>`: set restriction over discovered sources (spec and code) under + the glob rules of 7; a pattern resolving outside the workspace root is an + invalid flag value (exit 2); a glob admitting nothing yields + `{"findings": [], "occurrences": []}` exit 0 — no unknown-file error exists on + this filter. Without it, the domain is the entire discovered set. +- `--to <node>`: acceptance is syntactic only — well-formed iff at most one `#`, + non-empty path part, and any post-`#` part is one or more non-empty + `.`-joined segments each satisfying 1.4; malformed = usage error exit 2; + unknown or unresolving selects nothing (the 12.0 exit-class exception). + Filters combine conjunctively. +- 11.2 contract (shared machinery): the consulted domain's findings accompany + the answer — a finding belongs to a domain file when one of its locations lies + in it or it is the concerned path; a cross-file cycle accompanies whole when + any participant is in the domain. Any finding or explicitly-unavailable datum + in the answer → exit 1 with the full document still emitted; complete and + finding-free → exit 0. Argument checks precede answering (exit 2 whatever the + workspace carries). On a workspace passing `build`'s validations these + surfaces join the read-time refresh of 13.3; on a failing one they answer from + current sources and write nothing — journal (14.13) and write-path (14.22) + gate findings, being no domain file's findings, never accompany the answer. + +Verify: T11.3-1..4 (`section-11.3.test.ts`), T11.2-1/3/5/6 arms +(`section-11.2.test.ts`), T13.3-1/2, P-11. + +### B5. `xspec view` + +SPEC 11.4, 11.2, 12.7. Prereqs B2, B4 (availability layer). Register the command +(JSON-only). Document `{"findings", "views"}`, one +`{"file", "root", "imports", "occurrences", "comments"}` per parseable requested +file ordered by path bytes: + +- Operands vs. flag: `<file>` operands assert membership — a file outside the + discovered set is unknown (exit 2), a discovered code source is a wrong-kind + operand (exit 2); a `#`-containing operand is a whole path, never a + `path#id` split. `--file <glob>` is a set restriction (empty admitted set → + empty finding-free answer, exit 0). Combining operands with `--file` is a + usage error. Neither → every discovered spec source. An unparseable requested + file contributes no view entry (its parse finding accompanies, exit 1); an + invalid-path (14.19) file keeps its view, every identity unavailable, + condition-19 finding accompanying. +- Node form `{"identity", "range", "opening", "closing", "attributes", "tags", + "coverage", "children"}` plus `"ownText"`/`"subtreeText"` exactly when + `--text`. Positional tree by construct nesting alone (a section inside a + non-section construct parents to the innermost enclosing section, else root). + `opening`/`closing`: tag ranges — self-closing has opening only, root neither + (null). `attributes`: one `{"name", "range", "text"}` per spelled attribute in + tag order — repeated/unknown/spread included, spread `name` null, `text` the + attribute's own characters. `identity`, `tags`, `coverage`, text members: + plain value, null where 11.4 defines structural absence (a root's + tags/coverage — absent, never unavailable), or `{"unavailable": true}` per + 11.2 (spelled-identity rules; interpreted tags/coverage undefined on + repeated/malformed props; text all-or-nothing over transitive expansion, + unavailable on any unresolved spelling or embedding cycle on the path). +- With `--text` the consulted domain adds every file the requested expansions + transitively consult (resolved targets reachable through occurrence-recording + embeddings, cycle participants included); a spelling recording no occurrence + is an expansion boundary; a masked file is consulted only when itself + requested. +- `imports`: every declaration, valid or invalid, `{"range", "name", "target"}` + in document order — `name` the default-binding identifier or null (absent, + never unavailable), `target` the resolved file or `{"unavailable": true}`. + `occurrences`: the file's records in document order. `comments`: every MDX + comment's range. + +Verify: T11.4-1..6 (`section-11.4.test.ts`), T11.2-2/4, P-12. + +### B6. `xspec at` + +SPEC 11.5, 12.7. Prereqs B2, B4. Register (JSON-only). Document +`{"findings", "resolution"}`; `resolution` `{"section", "occurrence"}` or +`{"unavailable": true}` (unparseable file — parse finding accompanies, exit 1): + +- `<file>` asserts membership exactly as a `view` operand (unknown / wrong-kind + → exit 2). `<offset>` must be one or more ASCII decimal digits, read decimal, + leading zeros permitted — sign, whitespace, or any other character is a usage + error; an offset greater than the file's byte length is a usage error; equal + resolves to the root. +- `section`: `{"identity", "range"}` of the innermost section construct whose + range contains the offset (root when none — resolution is total over the + file), identity per 11.2. `occurrence`: the containing occurrence's full + record, `null` when the offset lies in none. + +Verify: T11.5-1..3 (`section-11.5.test.ts`), P-12. + +### B7. `xspec inventory` + +SPEC 11.6, 12.7, 14.23. Prereqs A1–A4 (anchoring helper from A4). Register +(JSON-only). Parses no sources, never refreshes or writes, answers whatever the +sources' validity; configuration errors keep precedence. Document +`{"findings", "root", "config", "configuration", "sources", "derived", +"recorded", "graphData", "journal", "sessions"}`: + +- Anchoring: `root`/`config` relative to the invocation cwd in the canonical + spelling (ascend `..` segments, then descend, `/`-joined, no `.` segments or + trailing separator; cwd itself `.`); only when the platform admits no relative + path (different Windows drives) the platform's absolute drive-qualified form. +- `configuration` resolved view with every default and inferred kind explicit: + `specs`/`code` one `{"name", "globs"}` per group; `markdown` + `{"emit", "outDir"}` (absent key → `{"emit": false, "outDir": null}`); + `coverage` one `{"name", "target", "targetTags", "targets", "boundary", + "boundaryKind", "mode", "edgeKinds"}` per profile (`targetTags` null when + absent); `policy` one `{"name", "type", "from", "to", "kinds"}` per rule, each + selector `{"group", "kind"}`, `{"files"}`, or `{"tags"}`. +- `sources`: one `{"path", "groups"}` per discovered file, `groups` + `{"name", "kind"}` entries. `derived`: one `{"source", "module", "markdown"}` + per discovered spec source — `module`/`markdown` null for a spec-group file + without `.mdx`; `markdown` null also while emission is disabled. +- `recorded`: the recorded derived-file paths in byte order — empty before any + generation (a missing store is an empty record here), but recorded state that + exists and cannot be read as a record is condition 23: `recorded` is + `{"unavailable": true}`, a finding with code `unreadable-record` and concerned + path `.xspec` accompanies, exit 1, everything else emitted in full. Implement + the record read with a three-way outcome (absent / readable / unreadable) as a + shared helper — the preview delta (B10) and Stage C reuse it. This is the only + finding an inventory ever carries. +- `graphData`: `.xspec` (workspace-relative, no trailing separator). `journal`: + `{"path", "occupied"}` — occupancy is presence of anything at the path, no + content read. `sessions`: every directory entry under the review-session + directory whose name is a well-formed session file name, by name alone, + whatever occupies it, in byte order of file name. Other lists: paths byte + order; groups/profiles/rules configuration order. + +Verify: T11.6-2..4 (`section-11.6.test.ts`; T11.6-1's drive-mismatch arm is +Windows-only, `test/windows/e6-drive-mismatch.test.ts`). + +### B8. `rename`/`move` refusal contract: every reason, stable codes, 12.7 form + +SPEC 14 (refusal-reason paragraph), 6.4, 6.5, 12.7. Prereqs A1–A2 (codes/form), +B2 (spelling spans for locations). Replace the ad-hoc first-failure refusals +(`emitRefusal` in `src/cli/commands/rename.ts` and `move.ts`, emitting +`{"refused": …}`) with the findings report `{"findings": [...]}` (exit 1): + +- Evaluate and report every applicable reason together, one finding per reason, + each reason on its own terms — e.g. an occupied non-spec-source `.mdx` target + outside every spec group reports both `refused-destination-exists` and + `refused-invalid-destination` (`sectionDestinationProblem` returns one problem + today). +- Per-reason content under the cardinality rule: `refused-invalid-id` / + `refused-identity-unchanged` / `refused-structural-parent` / + `refused-missing-target-parent` concern the stated identity (in + `identities`); `refused-id-collision` locates every colliding bearer; + `refused-unresolvable-reference` locates each rewritten reference spelling + that would not resolve; `refused-cycle` locates the would-be cycle's full + in-source path; `refused-destination-exists` / `refused-invalid-destination` + concern the destination/target path (`path` member). Would-be cycles and + unresolvable references must surface as these refusal codes — today they leak + out of in-memory reanalysis as numbered conditions. +- `refused-invalid-destination` also covers a workspace-relative directory + component of the destination path or of a derived path it would generate + occupied by anything other than a directory (`symlinkComponentOf` in + `src/workspace/writes.ts` checks symlinks only; a plain-file component + currently crashes mid-write) — check destination-side components up front. +- The invalid-workspace refusal reports the workspace's numbered findings alone; + no report ever mixes refusal reasons with numbered conditions. Refusal + evaluation must be shared with `--preview` (B9) — same findings, codes, exit. + +Verify: T6.4-1/3 (`section-6.4.test.ts`), T6.5-1/3/4/6 (`section-6.5*.test.ts`), +T14-7 (`section-14.test.ts`). + +### B9. `--preview` for `rename`/`move`: plan surface (mapping + files/edits) + +SPEC 6.6, 12.7, 13.5. Prereqs B2, B8. `--preview` is not in the command table +(exit 2 unknown flag today). Full validation and planning, zero modification (no +sources, journal, derived files, or graph data touched): + +- Non-mutating under 13.5: acquires no workspace exclusivity; `--test-hold` + together with `--preview` is a usage error (exit 2). Byte-deterministic. +- Document `{"findings", "mapping", "files", "delta"}` (delta itself is B10 — + emit it as the record-based value or land B9+B10 together if inseparable). + `mapping`: `{"from", "to"}` per mapped identity, by `from` bytes. `files`: one + `{"file", "edits"}` per file the operation would rewrite, relocate, or create, + by path bytes — `file` the pre-operation path (for target-file creation, the + path the creation would occupy); edits `{"class", "range"}` ordered by range + start, end, class-name bytes; classes exactly `"reference-rewrite"`, + `"id-rewrite"`, `"import-specifier-rewrite"`, `"import-addition"`, + `"import-removal"`, `"origin-deletion"`, `"target-insertion"`, + `"target-parent-rewrite"`, `"file-relocation"`, `"file-creation"`. +- Exact pre-operation ranges: a rewrite spans the construct it rewrites (a + reference occurrence's 5.7 span; the `id` attribute's own characters; the + import specifier literal; the target parent's self-closing tag); a removal + spans every byte removed — origin-deletion extends over each line the + line-drop rule additionally drops (contiguous leftover whitespace + + terminator), as does import-removal; import-addition, target-insertion, and + file-creation are zero-length insertion points (file-creation at the new + file's start — the one location without pre-operation coordinates, the created + file's only edit, subsuming its entire initial content); file-relocation spans + the entire moved file. Ranges may nest. No replacement text anywhere. +- Refusal equivalence: refused exactly when the real operation would be, same + findings/codes/exit (shared evaluation from B8); the refused document keeps + the preview form with `mapping`, `files`, `delta` null. +- Refactor so the real operation and the preview share one plan: in a + pre-existing file the real import-addition offset must equal the preview's + (6.5). + +Verify: T6.6-2..5 (`section-6.6*.test.ts`), T12.7-3. + +### B10. Preview derived-file delta and the condition-23 outcome + +SPEC 6.6 (delta), 14.23, 12.7. Prereqs B9, B7 (shared record reader). `delta` is +`{"generated", "removed"}`, both directions one datum, paths in byte order: +derived paths the operation would newly generate (nothing currently recorded +there) and recorded derived paths left no longer generated (the pre-move module +path after a file move included). Both directions consult the recorded +derived-file paths; a preview never refreshes the record. Unreadable record → +`delta` is `{"unavailable": true}` as one datum, an `unreadable-record` finding +(concerned path `.xspec`) accompanies, exit 1, the rest of the preview emitted +in full; the real operation is not refused in that state. A refused preview +consults no record — never a condition-23 finding beside a refusal. + +Verify: T6.6-6 arms (`section-6.6*.test.ts`). + +--- + +## Stage C — localized behavioral fixes + +### C1. Move operand classification is by spelling, at exit 2 + +SPEC 6.5 (operand classification), 12.0 (`#` split, UTF-8 arguments). Three +misroutes in `src/cli/commands/move.ts` / `src/cli/args.ts`, all currently exit +1 refusals, all usage errors (exit 2): + +- An invocation mixing the two synopses' forms (one operand containing `#`, the + other not — e.g. file-form origin with a `#`-containing destination) matches + neither synopsis: usage error, never `fileDestinationProblem`. +- A non-UTF-8 operand value is a usage error: remove move's + `utf8ExemptPositionals` exemption in `parseArgv` (`src/cli/args.ts` ~451) — + no argument value may name a non-UTF-8 path (12.0). +- An operand with more than one `#` is a malformed value: usage error in + `parseMoveArgument`, never an invalid-ID refusal. + +Verify: T6.5-5 (`section-6.5*.test.ts`). + +### C2. Argument checks precede the invalid-workspace gate on gated reads + +SPEC 12.0 (precedence bullet), 13.3. On a workspace failing `build`'s +validations, `ids`/`show`/`coverage`/`impact`/`review`/`query` currently emit +the gate report (exit 1) before argument checks. Required: each argument check +runs first, judged from what it consults, identically on valid and failing +workspaces — a profile/group name against configuration, a session name against +the session directory, a requirement- or graph-node identity parse-local against +the named file (a discovered path of the identity's kind; an `id` over the +file's spelled identities; a code unit over the file's named units), an +unparseable named file masking the check (gate report, exit 1). So +`show docs/none.mdx#x` (unknown file) and `query node specs/A.mdx#nope` +(unknown id in a parseable file) exit 2 on a failing workspace. Item IDs stay +behind the gate (judged against session content, which gated commands do not +read there). Files: gate sequencing in `src/workspace/pipeline.ts` and the +command handlers under `src/cli/commands/`. + +Verify: T12.0-10 (`section-12.0*.test.ts`). + +### C3. Obstructed write path: any non-directory component, refused before modifying + +SPEC 13.4, 14.22. Prereq A1 (token `obstructed-write-path`). +`symlinkComponentOf` (`src/workspace/writes.ts` ~108) detects symlink components +only; a plain-file component flows through — `build` modifies files, then +crashes ENOTDIR exit 70. Required: a workspace-relative directory component of +any path xspec writes occupied by anything other than a directory (plain file, +symlink whatever it targets, any non-directory) refuses the write, reported as +condition 22 before anything is modified; `check` reports it without writing. +One finding per distinct offending component, concerned path the component's +workspace-relative path, however many write paths it refuses. An occupant at a +derived file's own path stays a replacement, not an error; a durable file's own +path holding a non-plain-file stays 14.13/14.21; a move's destination-side +component stays `refused-invalid-destination` (B8), never condition 22. + +Verify: P-8 (`section-16-p8.test.ts` or the P-8 registry file), T13.4 arms +(`section-13.4*.test.ts`). + +### C4. Unreadable recorded state persists; `check` reports the exclusive unit form + +SPEC 13.3, 14.23, 14.10. Prereq A1 (condition 23), B7 (shared record reader). +Today `graphDataMatchesCurrent`/`refreshedGraphData` +(`src/core/graph-data.ts` ~229–280) treat a malformed store as an ordinary +mismatch and fabricate a fresh record: after corrupting `.xspec/graph.json`, +`ids` exits 0 and rewrites the store, and `check` then exits 0. Required: + +- Refreshing reads (`ids`, `show`, `coverage`, `impact`, `review`, `query`, + `occurrences`, `view`, `at`) never consult, repair, or replace recorded state + that exists but cannot be read as a record, and report no finding for it: they + answer from current analysis, leave the store byte-for-byte, and the state + persists until a successful `build` (which replaces it silently) or a + `rename`/`move` finishing regeneration. +- `check` reports the state as staleness (14.10): the unreadable-record unit + form — one condition-10 (`stale-output`) finding instructing rebuilding, + concerned path the graph-data area — exclusive with the mismatch unit form + (never both), and while it holds the recorded-file per-file form (a recorded + derived path no longer generated), consulting no readable record, is + undetectable and not reported; the other per-file forms report normally. + +Verify: T13.3-3 (`section-13.3*.test.ts`), T12.2 arms. + +### C5. 14.10 unit-form findings concern the graph-data area + +SPEC 14.10, 11.6. The graph-data staleness finding names `.xspec/graph.json` +(`stalenessFindings`, `src/workspace/check.ts` ~132). Required: both unit forms' +concerned path is the graph-data area itself — `.xspec`, the 11.6 spelling, no +trailing separator — never any path inside it (the record's layout is +unenumerated, 13.3). + +Verify: T12.2-2/3 arms (`section-12.2*.test.ts`), T14 arms. + +### C6. Review payloads carry source ranges for every present node + +SPEC 10.7 (`next --json`, `show`, `export` payload). Prereq B3 (code-location +ranges). `nodeStateJson` (`src/cli/commands/review-session.ts`) returns present +code locations as identity+presence only, and `originEntryJson` carries no +range. Required: every present scope, context, and origin node — requirement +node and code location alike — carries its source range (1.7), read from the +current graph; an absent node carries none. + +Verify: T10.7-7, T10.7-12 (`section-10.7*.test.ts`). + +### C7. Full-suite verification sweep + +Prereq: all tasks above removed. Run `npm run typecheck`, `npm run format:check`, +`npm run build`, `npm test` (Linux full suite) — every test must pass. Push and +confirm the branch-head CI runs: harness-self, full suite (Linux), and the +Windows E-6 leg (its byte-identity test consumes the Linux run's exchange +artifact; see `AGENTS.md`). Diagnose any residual failure against SPEC.md +(property seeds are replayable: `XSPEC_PROPERTY_SEED=<seed from the failure>`); +fix small residues directly, or append precise tasks here for anything larger. +Product green + this file emptied ends the phase (delete this file when its last +task is removed). From f9c0fb446ad8bb3d2c0cd398c88e6cc38eecfe69 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 21:00:21 +0000 Subject: [PATCH 145/226] sdg(phase-10): rebuild findings into the 12.7 five-member form with stable code tokens Replace the condition-number finding model with SPEC 12.7's observable form {"code", "message", "locations", "path", "identities"}: the 23 SPEC 14 condition tokens plus the nine refusal-reason tokens (with per-code ordinal and exit class), located conditions carrying byte-range locations (condition 20's line/column converted to byte ranges and dropped from the model), concerned-path conditions (10, 13, 14, 19, 21, 22) carrying `path`, condition 12's contractual identities [rule, source, kind, target], condition 11's foreign module as identity data, and cycle paths as identity context (cycle-as-locations stays task A3). Every construction site across core and workspace is ported; the JSON serializer emits exactly the five members and the human renderer presents the same information. The 12.7 findings ordering and duplicate collapse land as one choke point (core/findings.ts orderFindings) applied by every emitter; FIX_PLAN's A2 is rewritten to its remainder (marked byte-form path values). Suite: 98 failed / 539 passed vs the 142/495 baseline - 44 fixed, no new failures; every residual is owned by a later plan task (A2-A5, B*, C*). Self-tests and certification: 330/330. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 106 +++--------- src/cli/report.ts | 139 ++++++++-------- src/core/code-analysis.ts | 68 ++++---- src/core/config.ts | 17 +- src/core/discovery.ts | 63 +++---- src/core/findings.ts | 321 +++++++++++++++++++++++++++--------- src/core/graph.ts | 56 ++++--- src/core/journal.ts | 24 ++- src/core/mdx.ts | 46 +++--- src/core/policy.ts | 18 +- src/core/review.ts | 41 +++-- src/core/source-text.ts | 25 ++- src/core/spec-references.ts | 90 +++++----- src/workspace/baseline.ts | 7 +- src/workspace/check.ts | 23 ++- src/workspace/config.ts | 12 +- src/workspace/journal.ts | 12 +- src/workspace/locate.ts | 3 +- src/workspace/pipeline.ts | 52 +++--- src/workspace/writes.ts | 15 +- 20 files changed, 612 insertions(+), 526 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 3cce714..7999224 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -30,86 +30,32 @@ test passes (`npm test` locally and in CI, including the Windows E-6 leg). ## Stage A — SPEC 12.7 report forms and the stable-code model -### A1. Rebuild the finding model into the 12.7 finding form with stable code tokens - -SPEC 14 (code table), 12.7 (finding value form), 12.0. The model in -`src/core/findings.ts` carries `condition: 1..22` plus ad-hoc members -(`correction`, `file`, `range`, `line`, `column`, `cycle`, `rule`, `edge`); the -serializer `findingToJson` (`src/cli/report.ts`) emits `{"condition":"14.N",...}`. -Required observable form: `{"code", "message", "locations", "path", "identities"}` -— exactly these five members, `null` never omitted, empty lists `[]`. - -- Extend the condition table to 23 entries with the SPEC 14 tokens as string - codes: `missing-id`, `invalid-structural-id`, `duplicate-id`, - `invalid-segment-or-tag`, `unknown-dependency`, `unknown-text-target`, - `unknown-ts-reference`, `invalid-argument`, `cycle`, `stale-output`, - `cross-module-text`, `policy-violation`, `journal-error`, - `configuration-error`, `invalid-import`, `invalid-construct`, `invalid-prop`, - `unsupported-node-usage`, `invalid-source-path`, `unparseable-source`, - `corrupt-session`, `obstructed-write-path` (rename condition 22 from the - current "symbolic link in a write path" — behavioral widening is task C3), - `unreadable-record` (new condition 23, SPEC 14.23). Add the nine refusal-reason - tokens in SPEC 14's listed order (used from task B8 on): `refused-invalid-id`, - `refused-identity-unchanged`, `refused-id-collision`, - `refused-structural-parent`, `refused-unresolvable-reference`, `refused-cycle`, - `refused-destination-exists`, `refused-missing-target-parent`, - `refused-invalid-destination`. Keep an internal ordinal per code for sorting - (numbered conditions 1–23, then refusal reasons in listed order, then - code-less) and the exit class; the emitted `code` is the token string, or - `null` for code-less findings (10.7 review refusals — task A5). -- Model members: `message` (fold the current `correction` into it — SPEC 14 - actionability lives in the message); `locations` — ordered list of - `{file, range}`, one per offending construct, empty for conditions without - in-source locations; `path` — the concerned file or path for non-located - conditions, `null` for located ones; `identities` — list of context strings, - empty where none. Content of `identities` is contractual only where 14 states - it: condition 12 carries, in order, the violated rule's name, the edge's - source identity, its kind token (`"depends"`/`"embeds"`/`"references"`, 12.7), - and its target identity, with `locations` empty and `path` null (fold the - current `rule`/`edge` members in here); condition 11's foreign module is - identity data, not a location. -- Port every construction site (≈40 across `src/core/*` and `src/workspace/*`) - mechanically: today's single `file`+`range` becomes a one-element `locations`; - a concerned-path-style `file` without range (conditions 13, 14, 19, 21, 22, - 10's unit form) becomes `path`. Conditions that locate in source must carry - byte ranges (SPEC 14, 1.7): convert condition-20 parse-failure locations from - `line`/`column` to the failure's byte range (remark-mdx and the TS compiler - both expose offsets); drop `line`/`column` entirely. The legacy `cycle` - member: keep the cycle path in `identities` for now — cycle-as-locations is - task A3. -- Update the JSON serializer to emit exactly the five-member form and the human - renderer (`renderFindingsHuman`, `renderFindingLine`) to present the same - information (code token, every location, concerned path) — SPEC 14 last - paragraph, 12.0. - -Verify: `section-16-p1.test.ts` (P-1, seed 271828183 fails today at -`$.findings[0].condition: expected no member "condition"`), `section-14.test.ts` -T14-1..6 arms, `section-7*.test.ts` T7.1-1/T7.5-2..6, T12.1-4, T12.2-2/3. - -### A2. Findings-array ordering, duplicate collapse, and the marked byte-form path - -SPEC 12.7 (ordering and collapse), 12.0 (marked byte form). Prereq A1. Implement -one ordering/dedup function applied by every findings emitter -(`src/cli/report.ts`), and one path-value renderer shared by all JSON output: - -- Order `"findings"` by: code ordinal (numbered conditions in numeric order, - then refusal reasons in 14's listed order, then code-less findings); then - `locations` element-wise — each element by file path bytes, then range start, - then range end; a sequence that is a proper prefix of another sorts first; - then `path` (`null` before any path; paths compare byte-wise whatever their - presentation form); then `identities` element-wise under the same prefix rule - (string elements byte-wise); then `message`. Findings identical in every - member collapse to one. -- Path value form (12.7): a path whose bytes are valid UTF-8 is a string; - otherwise the marked byte form `{"bytes": "<lowercase hex, two digits per - byte>"}` — used wherever a JSON output carries a workspace-relative path - (finding `locations[].file` and `path` now; the 11.3–11.6 surfaces reuse it in - Stage B). This needs the internal path representation at finding sites to - preserve raw bytes for non-UTF-8 discovered paths (14.19) rather than a lossy - string — choose conservatively (e.g. carry bytes alongside the string) and - note the choice. - -Verify: T12.7-1/2 (`section-12.7.test.ts`), T14 ordering/collapse arms, P-7. +### A2. Marked byte-form path values in finding JSON + +SPEC 12.7 (path value form), 12.0 (marked byte form), 14.19. Landed already +(with the A1 model rebuild): the five-member finding form with stable code +tokens, the 12.7 findings ordering/collapse choke point +(`orderFindings`/`compareFindings` in `src/core/findings.ts`, applied by every +emitter through `src/cli/report.ts` and `src/workspace/pipeline.ts`), and the +human renderer. Remaining — the path presentation form: + +- A path whose bytes are valid UTF-8 is a JSON string; otherwise the marked + byte form `{"bytes": "<lowercase hex, two digits per byte>"}` — wherever a + JSON output carries a workspace-relative path (finding `locations[].file` + and `path` now; the 11.3–11.6 surfaces reuse the same renderer in Stage B). + Implement one path-value renderer shared by all JSON output. +- This needs the internal path representation at finding sites to preserve raw + bytes for non-UTF-8 discovered paths (14.19) rather than the lossy U+FFFD + string `src/core/discovery.ts` produces today (`fileLabel` from + `lossyUtf8Decoder`) — choose conservatively (e.g. carry bytes alongside the + string on the finding path, or a marker encoding) and note the choice. The + findings comparator must then compare such paths by their exact bytes + (`compareFindings` in `src/core/findings.ts` compares the string spelling + today), and the human renderer must render them deterministically. + +Verify: T12.7-1/2 (`section-12.7.test.ts` — T12.7-2's 14.19 arm expects +`{"bytes":"73706563732f41ff2e6d6478"}` where the product now emits the lossy +string), T14 arms, P-7. ### A3. Multi-location cardinality for jointly-violated conditions diff --git a/src/cli/report.ts b/src/cli/report.ts index 3ee92f6..14071cd 100644 --- a/src/cli/report.ts +++ b/src/cli/report.ts @@ -1,4 +1,4 @@ -// Findings-report rendering (SPEC 12.0, 14). +// Findings-report rendering (SPEC 12.0, 12.7, 14). // // IMPLEMENTATION (cross-cutting rules): reports are built as data (the // Finding model, core/findings.ts) and rendered once per output form — @@ -9,98 +9,95 @@ // messages (exit 2) are standard-error content; the exit-2 renderer for // them lives here too so every command reports them identically. // +// Every emitter applies the SPEC 12.7 findings-array discipline through one +// choke point (core/findings.ts `orderFindings`): the pinned total order and +// duplicate collapse, identically in the human and JSON forms. The JSON +// finding is exactly the five-member 12.7 finding form; the human line +// presents the same information — code, every location, concerned path, +// context identities, message (SPEC 14, 12.0). +// // All rendering is byte-deterministic for identical findings (SPEC 12.0): // static text, workspace-relative paths, and byte offsets only — no // absolute paths, no wall clock, no environment-dependent content. import { canonicalJson } from "../core/canonical-json.js"; import type { JsonObject, JsonValue } from "../core/canonical-json.js"; -import type { ConditionNumber, Finding } from "../core/findings.js"; -import { conditionName } from "../core/findings.js"; +import type { Finding, FindingLocation } from "../core/findings.js"; +import { orderFindings } from "../core/findings.js"; import type { CliWriter } from "./io.js"; -/** The SPEC 14 condition identity of a finding (`3` → `"14.3"`). */ -export function conditionIdentity(condition: ConditionNumber): string { - return `14.${String(condition)}`; +/** A location as human text: `FILE:START-END`. */ +function renderLocation(location: FindingLocation): string { + return `${location.file}:${String(location.range.start)}-${String(location.range.end)}`; } /** - * One finding as a human report line (SPEC 14: actionable — file, location, - * and correction): `FILE:START-END: NAME (14.N): MESSAGE — CORRECTION`. - * Location falls back to `line[:column]` when the finding carries no byte - * range; both parts are omitted when absent. + * One finding as a human report line, presenting the same information as + * the 12.7 JSON finding form (SPEC 14, 12.0): the primary location (or the + * concerned path) as the prefix, the stable code as the label, the + * actionable message, any further locations, and the context identities. */ function renderFindingLine(finding: Finding): string { - let location = ""; - if (finding.file !== undefined) { - location = finding.file; - if (finding.range !== undefined) { - location += `:${String(finding.range.start)}-${String(finding.range.end)}`; - } else if (finding.line !== undefined) { - location += `:${String(finding.line)}`; - if (finding.column !== undefined) { - location += `:${String(finding.column)}`; - } - } - location += ": "; + let prefix = ""; + if (finding.locations.length > 0) { + prefix = `${renderLocation(finding.locations[0]!)}: `; + } else if (finding.path !== null) { + prefix = `${finding.path}: `; } - const label = - `${conditionName(finding.condition)} ` + - `(${conditionIdentity(finding.condition)})`; - const correction = - finding.correction === undefined ? "" : ` — ${finding.correction}`; - return `${location}${label}: ${finding.message}${correction}\n`; + const label = finding.code ?? "finding"; + const more = + finding.locations.length > 1 + ? ` (also at ${finding.locations + .slice(1) + .map(renderLocation) + .join(", ")})` + : ""; + const identities = + finding.identities.length > 0 ? ` [${finding.identities.join(", ")}]` : ""; + return `${prefix}${label}: ${finding.message}${more}${identities}\n`; } /** - * The human findings report: one line per finding, in the given (already - * deterministic) order, closed by a one-line count. Standard-output content - * (SPEC 12.0). + * The human findings report: the SPEC 12.7 order and collapse, one line per + * finding, closed by a one-line count. Standard-output content (SPEC 12.0). */ export function renderFindingsHuman(findings: readonly Finding[]): string { - const lines = findings.map(renderFindingLine); - const count = findings.length; + const ordered = orderFindings(findings); + const lines = ordered.map(renderFindingLine); + const count = ordered.length; lines.push(`${String(count)} finding${count === 1 ? "" : "s"}\n`); return lines.join(""); } -/** One finding as JSON data — the same information as the human line. */ -function findingToJson(finding: Finding): JsonObject { +/** + * One finding as JSON data — exactly the five-member finding form of SPEC + * 12.7: `{"code", "message", "locations", "path", "identities"}`, `null` + * never omitted, empty lists `[]`. + */ +export function findingToJson(finding: Finding): JsonObject { return { - condition: conditionIdentity(finding.condition), + code: finding.code, message: finding.message, - correction: finding.correction, - file: finding.file, - location: - finding.range === undefined - ? undefined - : { start: finding.range.start, end: finding.range.end }, - line: finding.line, - column: finding.column, - cycle: finding.cycle === undefined ? undefined : [...finding.cycle], - // SPEC 7.5 → 14.12: a policy violation carries the rule name and the - // offending edge; the JSON form holds the same information as the - // human message (SPEC 12.0), structured. - rule: finding.rule, - edge: - finding.edge === undefined - ? undefined - : { - from: finding.edge.source, - to: finding.edge.target, - kind: finding.edge.kind, - }, + locations: finding.locations.map((location) => ({ + file: location.file, + range: { start: location.range.start, end: location.range.end }, + })), + path: finding.path, + identities: [...finding.identities], }; } /** - * The findings report as the single JSON document of `--json` (SPEC 12.0: - * same information as the human report; the canonical serializer keeps it - * byte-deterministic). An empty findings list is the exit-0 document of a - * command whose report is its findings (`build`, `check`). + * The findings report as the single JSON document of `--json` (SPEC 12.0, + * 12.7: `{"findings": […]}` in the pinned order, duplicates collapsed; the + * canonical serializer keeps it byte-deterministic). An empty findings list + * is the exit-0 document of a command whose report is its findings + * (`build`, `check`). */ export function findingsReportJson(findings: readonly Finding[]): string { - const document: JsonValue = { findings: findings.map(findingToJson) }; + const document: JsonValue = { + findings: orderFindings(findings).map(findingToJson), + }; return canonicalJson(document); } @@ -122,23 +119,19 @@ export function emitFindingsReport( /** * SPEC 12.0/14.14: render one configuration-error finding as a diagnostic * line. Configuration errors are usage errors: the message is - * standard-error content, and standard output stays empty. + * standard-error content. */ export function renderConfigurationError(finding: Finding): string { - const location = - finding.file === undefined - ? "" - : finding.line === undefined - ? `${finding.file}: ` - : `${finding.file}:${String(finding.line)}: `; - return `xspec: ${conditionName(finding.condition)}: ${location}${finding.message}\n`; + const location = finding.path === null ? "" : `${finding.path}: `; + return `xspec: configuration error: ${location}${finding.message}\n`; } /** * Report configuration errors (SPEC 14.14) the way every command must: each - * as a standard-error diagnostic line, standard output untouched (with - * `--json`, the exit-2 error prevents emitting the single JSON document, so - * standard output stays empty — SPEC 12.0). The caller exits 2. + * as a standard-error diagnostic line. The caller exits 2. (The exit-2 JSON + * error document of 12.0/12.7 — the standard-output half when JSON output + * is in effect — is emitted by the caller's error path, not here; stderr + * diagnostics are identical either way.) */ export function emitConfigurationErrors( stderr: CliWriter, diff --git a/src/core/code-analysis.ts b/src/core/code-analysis.ts index 1ba0d00..8fae1ea 100644 --- a/src/core/code-analysis.ts +++ b/src/core/code-analysis.ts @@ -36,6 +36,7 @@ import { Utf8Offsets } from "./bytes.js"; import type { DerivedPathKind } from "./discovery.js"; import { derivedFilePathKind } from "./discovery.js"; import type { Finding } from "./findings.js"; +import { compareFindings, locatedFinding } from "./findings.js"; import type { ClassifiedChain } from "./references.js"; import { classifyReference } from "./references.js"; import { decodeSourceBytes } from "./source-text.js"; @@ -231,15 +232,13 @@ export function analyzeCodeSource( /** The 14.20 finding for a source the parser cannot process (overflow). */ function stackOverflowFinding(path: string, grammar: string): Finding { - return { - condition: 20, - file: path, - range: { start: 0, end: 0 }, - message: - `unparseable source: not well-formed ${grammar} — the file's ` + + return locatedFinding( + 20, + `unparseable source: not well-formed ${grammar} — the file's ` + `nesting exceeds what the parser can process, so no location inside ` + `it can be analyzed; simplify or split the file (SPEC 14.20)`, - }; + [{ file: path, range: { start: 0, end: 0 } }], + ); } /** @@ -293,21 +292,23 @@ function parseFailureFinding( start + Math.max(diagnostic.length, 0), sourceFile.text.length, ); - const position = sourceFile.getLineAndCharacterOfPosition(start); - const lineStart = sourceFile.getPositionOfLineAndCharacter(position.line, 0); - // 1-based column in the line's Unicode code points (Finding contract). - const column = [...sourceFile.text.slice(lineStart, start)].length + 1; - return { - condition: 20, - file: path, - range: { start: offsets.byteOffset(start), end: offsets.byteOffset(end) }, - line: position.line + 1, - column, - message: - `unparseable source: not well-formed TypeScript under the ` + + // SPEC 14/1.7: the reported location is the failure's byte range (the + // compiler exposes UTF-16 offsets; converted here). + return locatedFinding( + 20, + `unparseable source: not well-formed TypeScript under the ` + `${grammar} grammar the file name selects — ${reason}. Correct the ` + `syntax at the reported location (SPEC 14.20)`, - }; + [ + { + file: path, + range: { + start: offsets.byteOffset(start), + end: offsets.byteOffset(end), + }, + }, + ], + ); } // --------------------------------------------------------------------------- @@ -389,13 +390,16 @@ class CodeAnalyzer { condition: 8 | 11 | 15 | 18, node: ts.Node, message: string, + identities: readonly string[] = [], ): void { - this.findings.push({ - condition, - file: this.path, - range: this.rangeOf(node), - message, - }); + this.findings.push( + locatedFinding( + condition, + message, + [{ file: this.path, range: this.rangeOf(node) }], + identities, + ), + ); } /** The tracked binding a resolved symbol belongs to, if any. */ @@ -1233,6 +1237,8 @@ class CodeAnalyzer { } if (rootBinding.modulePath !== calleeBinding.modulePath) { // SPEC 4.4 → 14.11: a node passed to another module's text export. + // The foreign (called) module is identity data on the finding, not a + // further location (SPEC 14, 12.7). this.addFinding( 11, call, @@ -1241,6 +1247,7 @@ class CodeAnalyzer { `called belongs to module ` + `${JSON.stringify(calleeBinding.modulePath)} — pass a node only ` + `to its own module's "text" export (SPEC 4.4, 14.11)`, + [calleeBinding.modulePath], ); return; } @@ -1468,12 +1475,7 @@ function leftmostIdentifier(expression: ts.Expression): ts.Identifier | null { } } -/** Deterministic finding order (SPEC 12.0): by location, then condition. */ +/** Deterministic finding order (SPEC 12.0, 12.7). */ function sortFindings(findings: readonly Finding[]): Finding[] { - return [...findings].sort( - (a, b) => - (a.range?.start ?? 0) - (b.range?.start ?? 0) || - (a.range?.end ?? 0) - (b.range?.end ?? 0) || - a.condition - b.condition, - ); + return [...findings].sort(compareFindings); } diff --git a/src/core/config.ts b/src/core/config.ts index 084ea6d..7cc75cb 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -20,6 +20,7 @@ import ts from "typescript"; import type { Finding } from "./findings.js"; +import { pathFinding } from "./findings.js"; import type { CompiledGlob } from "./glob.js"; import { compileGlob, unboundToCaptures } from "./glob.js"; @@ -141,7 +142,10 @@ class ConfigFindings { /** SPEC 14.14: every entry is a configuration error (usage error, 12.0). */ add(message: string, line?: number): void { - this.findings.push({ condition: 14, message, file: this.fileName, line }); + // The offending line, when known, is message content: a configuration + // error carries a concerned path, not an in-source location (SPEC 14). + const where = line === undefined ? "" : `line ${String(line)}: `; + this.findings.push(pathFinding(14, `${where}${message}`, this.fileName)); } get count(): number { @@ -1389,15 +1393,14 @@ export function parseConfiguration( return { ok: false, findings: [ - { - condition: 14, - file: fileName, - message: - `not well-formed TypeScript in the declarative form of SPEC 7 ` + + pathFinding( + 14, + `not well-formed TypeScript in the declarative form of SPEC 7 ` + `— the file's expression nesting exceeds what the parser can ` + `process; flatten the configuration to plain literal form ` + `(SPEC 7, 14.14)`, - }, + fileName, + ), ], }; } diff --git a/src/core/discovery.ts b/src/core/discovery.ts index bcc488f..89cf397 100644 --- a/src/core/discovery.ts +++ b/src/core/discovery.ts @@ -18,6 +18,7 @@ import type { Configuration, ConfiguredGroup } from "./config.js"; import type { Finding } from "./findings.js"; +import { pathFinding } from "./findings.js"; const SLASH = 0x2f; // "/" const HASH = 0x23; // "#" — reserved by node identities (SPEC 1.5) @@ -335,52 +336,56 @@ export function classifySources( // SPEC 7.2 → 14.14: a file matched by both a spec and a code group // is a configuration error (usage class; precedes source analysis). valid = false; - findings.push({ - condition: 14, - file: fileLabel, - message: + findings.push( + pathFinding( + 14, `matched by both spec group "${candidate.specGroups[0]}" and ` + - `code group "${candidate.codeGroups[0]}" — a configuration ` + - `error: adjust the configured globs so no file belongs to both ` + - `a spec and a code group (SPEC 7.2, 14.14)`, - }); + `code group "${candidate.codeGroups[0]}" — a configuration ` + + `error: adjust the configured globs so no file belongs to both ` + + `a spec and a code group (SPEC 7.2, 14.14)`, + fileLabel, + ), + ); } if (bytesContainByte(candidate.bytes, HASH)) { // SPEC 7 → 14.19: `#` is reserved by node identities (SPEC 1.5). valid = false; - findings.push({ - condition: 19, - file: fileLabel, - message: + findings.push( + pathFinding( + 19, `the workspace-relative path contains "#", which node ` + - `identities reserve (path#id) — rename the file to a "#"-free ` + - `path (SPEC 7, 1.5, 14.19)`, - }); + `identities reserve (path#id) — rename the file to a "#"-free ` + + `path (SPEC 7, 1.5, 14.19)`, + fileLabel, + ), + ); } if (decoded === null) { // SPEC 7 → 14.19: paths are matched as UTF-8 bytes; a discovered // path that is not valid UTF-8 is invalid. valid = false; - findings.push({ - condition: 19, - file: fileLabel, - message: + findings.push( + pathFinding( + 19, `the workspace-relative path is not valid UTF-8 — rename the ` + - `file to a valid UTF-8 path (SPEC 7, 14.19)`, - }); + `file to a valid UTF-8 path (SPEC 7, 14.19)`, + fileLabel, + ), + ); } if (candidate.specGroups.length > 0 && !candidate.isMdx) { // SPEC 7.1 → 14.19: every spec-group match MUST end `.mdx`. valid = false; - findings.push({ - condition: 19, - file: fileLabel, - message: + findings.push( + pathFinding( + 19, `matched by spec group "${candidate.specGroups[0]}" but the ` + - `file does not have the .mdx extension — every spec-group match ` + - `must end ".mdx"; rename the file or narrow the group's globs ` + - `(SPEC 7.1, 14.19)`, - }); + `file does not have the .mdx extension — every spec-group ` + + `match must end ".mdx"; rename the file or narrow the group's ` + + `globs (SPEC 7.1, 14.19)`, + fileLabel, + ), + ); } if (!valid || decoded === null) continue; if (candidate.specGroups.length > 0) { diff --git a/src/core/findings.ts b/src/core/findings.ts index 0f60cee..e50d16b 100644 --- a/src/core/findings.ts +++ b/src/core/findings.ts @@ -1,13 +1,17 @@ // The validation-finding data model. // // IMPLEMENTATION (cross-cutting rules): every validation failure is -// represented as data carrying its SPEC 14 condition number and exit class; +// represented as data carrying its SPEC 14 stable code and exit class; // reports are built as data and rendered once per output form (human, JSON) // by the CLI layer. SPEC 14: reported errors are actionable — they identify // the file, location, and correction — and when several conditions are -// present, each is reported, not only the first. +// present, each is reported, not only the first. SPEC 12.7 fixes the +// observable finding form — `{"code", "message", "locations", "path", +// "identities"}` — which this model mirrors as data, plus the total findings +// order and duplicate collapse this module implements for every emitter. import type { ByteRange } from "./bytes.js"; +import { compareBytes } from "./bytes.js"; /** * SPEC 12.0: exit codes partition all outcomes — 0 success, 1 findings @@ -16,7 +20,7 @@ import type { ByteRange } from "./bytes.js"; */ export type ExitCode = 0 | 1 | 2; -/** SPEC 14: the defined error conditions, numbered 1–22. */ +/** SPEC 14: the defined error conditions, numbered 1–23. */ export type ConditionNumber = | 1 | 2 @@ -39,96 +43,251 @@ export type ConditionNumber = | 19 | 20 | 21 - | 22; - -interface ConditionInfo { - /** The condition's short name, from its SPEC 14 entry. */ - readonly name: string; - /** - * The exit class of a command reporting the condition: 1 for findings; - * 2 for condition 14, which is a usage error preceding all source analysis - * (SPEC 14.14, 12.0). - */ - readonly exitClass: 1 | 2; -} + | 22 + | 23; + +/** + * SPEC 14: the numbered conditions' stable code tokens, listed in condition + * order — index N−1 is condition N's token. A code's value is its token + * string (12.7); the numeral is the condition's ordinal, ordering findings, + * no part of the value. + */ +export const CONDITION_CODES = [ + "missing-id", // 14.1 + "invalid-structural-id", // 14.2 + "duplicate-id", // 14.3 + "invalid-segment-or-tag", // 14.4 + "unknown-dependency", // 14.5 + "unknown-text-target", // 14.6 + "unknown-ts-reference", // 14.7 + "invalid-argument", // 14.8 + "cycle", // 14.9 + "stale-output", // 14.10 + "cross-module-text", // 14.11 + "policy-violation", // 14.12 + "journal-error", // 14.13 + "configuration-error", // 14.14 + "invalid-import", // 14.15 + "invalid-construct", // 14.16 + "invalid-prop", // 14.17 + "unsupported-node-usage", // 14.18 + "invalid-source-path", // 14.19 + "unparseable-source", // 14.20 + "corrupt-session", // 14.21 + "obstructed-write-path", // 14.22 + "unreadable-record", // 14.23 +] as const; +export type ConditionCode = (typeof CONDITION_CODES)[number]; + +/** + * SPEC 14: the refusal reasons of `rename`/`move` (6.4, 6.5), stable codes + * in the order 14 lists them — the findings order after the numbered + * conditions (12.7). + */ +export const REFUSAL_CODES = [ + "refused-invalid-id", + "refused-identity-unchanged", + "refused-id-collision", + "refused-structural-parent", + "refused-unresolvable-reference", + "refused-cycle", + "refused-destination-exists", + "refused-missing-target-parent", + "refused-invalid-destination", +] as const; +export type RefusalCode = (typeof REFUSAL_CODES)[number]; -/** The SPEC 14 condition table: short name and exit class per condition. */ -export const CONDITIONS: Readonly<Record<ConditionNumber, ConditionInfo>> = { - 1: { name: "missing ID", exitClass: 1 }, - 2: { name: "invalid structural ID", exitClass: 1 }, - 3: { name: "duplicate ID", exitClass: 1 }, - 4: { name: "invalid segment or tag", exitClass: 1 }, - 5: { name: "unknown dependency", exitClass: 1 }, - 6: { name: "unknown text target", exitClass: 1 }, - 7: { name: "unknown TypeScript reference", exitClass: 1 }, - 8: { name: "invalid argument", exitClass: 1 }, - 9: { name: "cycle", exitClass: 1 }, - 10: { name: "stale generated output", exitClass: 1 }, - 11: { name: "cross-module text call", exitClass: 1 }, - 12: { name: "policy violation", exitClass: 1 }, - 13: { name: "journal error", exitClass: 1 }, - 14: { name: "configuration error", exitClass: 2 }, - 15: { name: "invalid import", exitClass: 1 }, - 16: { name: "invalid construct", exitClass: 1 }, - 17: { name: "invalid prop", exitClass: 1 }, - 18: { name: "unsupported node usage", exitClass: 1 }, - 19: { name: "invalid source path", exitClass: 1 }, - 20: { name: "unparseable source", exitClass: 1 }, - 21: { name: "corrupt review session", exitClass: 1 }, - 22: { name: "symbolic link in a write path", exitClass: 1 }, -}; +/** Every stable code SPEC 14 assigns: numbered conditions, then refusals. */ +export type FindingCode = ConditionCode | RefusalCode; + +/** Condition N's stable code token (SPEC 14: `1` → `"missing-id"`). */ +export function conditionCode(condition: ConditionNumber): ConditionCode { + return CONDITION_CODES[condition - 1]; +} /** - * SPEC 5.2/7.5 → 14.12: the offending edge a policy-violation finding - * carries, endpoints as graph-node identities. Structurally identical to - * the graph layer's `GraphEdge` (core/graph.ts), restated here so the - * finding model stays dependency-free. + * One offending construct's location: the containing file (workspace- + * relative, `/`-separated, SPEC 1.5) and its byte range (SPEC 1.7). The + * observable form is `{"file", "range"}` (SPEC 12.7). */ -export interface FindingEdge { - readonly kind: "contains" | "depends" | "embeds" | "references"; - readonly source: string; - readonly target: string; +export interface FindingLocation { + readonly file: string; + readonly range: ByteRange; } /** - * One validation failure, carried as data and rendered later by the CLI. - * The structured fields identify the file and location; `message` (with - * `correction`, when separate) states what is wrong and how to correct it, - * satisfying SPEC 14's actionability requirement as data. + * One validation failure, carried as data in the shape of SPEC 12.7's + * finding form and rendered later by the CLI: + * + * - `code`: the stable token SPEC 14 assigns, or null where 14 assigns none + * (plain usage errors, review-operation refusals). + * - `message`: the human-readable description — actionable, stating the + * correction (SPEC 14). + * - `locations`: one entry per offending construct, ordered by file path + * bytes, then range start, then range end; empty for conditions without + * in-source locations (SPEC 14, 12.7). + * - `path`: the concerned file or path for non-located conditions + * (configuration, path-level, journal, session, and record conditions); + * null for located ones (SPEC 14). + * - `identities`: the identities or other context strings the condition + * names, empty where none — contractual exactly where 14 states it + * (14.12's enumeration, 14.11's foreign module, a refusal reason's + * concerned identity), otherwise informational (SPEC 12.7). */ export interface Finding { - /** SPEC 14 condition number, 1–22. */ - readonly condition: ConditionNumber; - /** What is wrong — actionable, stating the correction unless `correction` carries it (SPEC 14). */ + readonly code: FindingCode | null; readonly message: string; - /** The correction, when stated separately from `message` (SPEC 14). */ - readonly correction?: string; - /** Workspace-relative `/`-separated path of the concerned file (SPEC 1.5). */ - readonly file?: string; - /** Byte-offset range locating the finding inside `file` (SPEC 1.7 form). */ - readonly range?: ByteRange; - /** 1-based line of a location, e.g. a parse failure's (SPEC 14.20). */ - readonly line?: number; - /** 1-based column of a location, in that line's Unicode code points. */ - readonly column?: number; - /** - * SPEC 5.3/2.1 → 14.9: the full cycle path, as a closed walk of graph-node - * identities (dependency cycles) or spec-source paths (import cycles) — - * first element repeated at the end; a length-one cycle is `[a, a]`. - */ - readonly cycle?: readonly string[]; - /** SPEC 7.5 → 14.12: the violated policy rule's name. */ - readonly rule?: string; - /** SPEC 7.5 → 14.12: the offending edge. */ - readonly edge?: FindingEdge; + readonly locations: readonly FindingLocation[]; + readonly path: string | null; + readonly identities: readonly string[]; } -/** The exit class of a finding's condition (SPEC 12.0, 14.14). */ -export function conditionExitClass(condition: ConditionNumber): 1 | 2 { - return CONDITIONS[condition].exitClass; +/** + * A finding locating its offending construct(s) in source: `path` null + * (SPEC 14: located conditions carry no concerned path). Locations are + * sorted into the pinned within-finding order (SPEC 12.7). + */ +export function locatedFinding( + condition: ConditionNumber, + message: string, + locations: readonly FindingLocation[], + identities: readonly string[] = [], +): Finding { + return { + code: conditionCode(condition), + message, + locations: sortLocations(locations), + path: null, + identities, + }; +} + +/** + * A finding without in-source locations, concerning a file or path (SPEC + * 14: configuration, path-level, journal, session, and record conditions + * carry the file or path they concern) — or, for conditions carrying + * context identities alone (14.12), no path either. + */ +export function pathFinding( + condition: ConditionNumber, + message: string, + path: string | null, + identities: readonly string[] = [], +): Finding { + return { + code: conditionCode(condition), + message, + locations: [], + path, + identities, + }; +} + +/** + * A code's rank in the findings order (SPEC 12.7): the numbered conditions + * in numeric order, then the refusal reasons in the order 14 lists them, + * then code-less findings. + */ +export function codeOrdinal(code: FindingCode | null): number { + if (code === null) return CONDITION_CODES.length + REFUSAL_CODES.length; + const condition = (CONDITION_CODES as readonly string[]).indexOf(code); + if (condition !== -1) return condition; + return ( + CONDITION_CODES.length + (REFUSAL_CODES as readonly string[]).indexOf(code) + ); } -/** The short SPEC 14 name of a condition (e.g. 14 → "configuration error"). */ -export function conditionName(condition: ConditionNumber): string { - return CONDITIONS[condition].name; +/** + * The exit class of a command reporting a finding with this code (SPEC + * 12.0): 2 for condition 14, a usage error preceding all source analysis + * (SPEC 14.14); 1 for every other finding, refusals and code-less findings + * included. + */ +export function codeExitClass(code: FindingCode | null): 1 | 2 { + return code === "configuration-error" ? 2 : 1; +} + +/** The pinned within-finding location order (SPEC 12.7). */ +export function compareLocations( + a: FindingLocation, + b: FindingLocation, +): number { + return ( + compareBytes(a.file, b.file) || + a.range.start - b.range.start || + a.range.end - b.range.end + ); +} + +/** Sort locations into the pinned within-finding order (SPEC 12.7). */ +export function sortLocations( + locations: readonly FindingLocation[], +): readonly FindingLocation[] { + return [...locations].sort(compareLocations); +} + +/** + * Element-wise sequence comparison under the prefix rule (SPEC 12.7): a + * sequence that is a proper prefix of another sorts first. + */ +function compareSequences<T>( + a: readonly T[], + b: readonly T[], + compareElement: (x: T, y: T) => number, +): number { + const shared = Math.min(a.length, b.length); + for (let index = 0; index < shared; index += 1) { + const byElement = compareElement(a[index]!, b[index]!); + if (byElement !== 0) return byElement; + } + return a.length - b.length; +} + +/** + * The total findings order of SPEC 12.7: by code (numbered conditions in + * numeric order, then refusal reasons in 14's listed order, then code-less + * findings), then by locations element-wise (file path bytes, range start, + * range end; proper prefix first), then by concerned path (null before any + * path; byte-wise), then by identities element-wise under the same prefix + * rule (byte-wise elements), then by message. Returns 0 exactly for + * findings identical in every member — which collapse to one (12.7) — so + * the order is total. + */ +export function compareFindings(a: Finding, b: Finding): number { + const byCode = codeOrdinal(a.code) - codeOrdinal(b.code); + if (byCode !== 0) return byCode; + const byLocations = compareSequences( + a.locations, + b.locations, + compareLocations, + ); + if (byLocations !== 0) return byLocations; + if ((a.path === null) !== (b.path === null)) return a.path === null ? -1 : 1; + if (a.path !== null && b.path !== null) { + const byPath = compareBytes(a.path, b.path); + if (byPath !== 0) return byPath; + } + const byIdentities = compareSequences(a.identities, b.identities, (x, y) => + compareBytes(x, y), + ); + if (byIdentities !== 0) return byIdentities; + return compareBytes(a.message, b.message); +} + +/** + * The `"findings"` array discipline of SPEC 12.7, applied by every findings + * emitter: the pinned total order, findings identical in every member + * collapsed to one. + */ +export function orderFindings(findings: readonly Finding[]): Finding[] { + const ordered = [...findings].sort(compareFindings); + const collapsed: Finding[] = []; + for (const finding of ordered) { + const previous = collapsed[collapsed.length - 1]; + if (previous !== undefined && compareFindings(previous, finding) === 0) { + continue; + } + collapsed.push(finding); + } + return collapsed; } diff --git a/src/core/graph.ts b/src/core/graph.ts index efbc343..6514873 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -35,6 +35,7 @@ import { compareBytes, sortByBytes } from "./bytes.js"; import type { ByteRange } from "./bytes.js"; import type { CodeAnalysis } from "./code-analysis.js"; import type { Finding } from "./findings.js"; +import { compareFindings, locatedFinding } from "./findings.js"; import type { SpecDocument, SpecEmbedding, SpecSection } from "./mdx.js"; import type { ReferenceTarget, @@ -481,14 +482,8 @@ export function buildWorkspaceGraph( } } - // SPEC 14: deterministic finding order — by file, location, condition. - findings.sort( - (a, b) => - compareBytes(a.file ?? "", b.file ?? "") || - (a.range?.start ?? 0) - (b.range?.start ?? 0) || - (a.range?.end ?? 0) - (b.range?.end ?? 0) || - a.condition - b.condition, - ); + // SPEC 14/12.7: deterministic finding order. + findings.sort(compareFindings); // The collapsed edge set, ordered (source, kind, target) — kinds in // SPEC 5.2 listing order (SPEC 12.0 determinism). @@ -524,7 +519,7 @@ function unresolvedFinding( range: ByteRange, message: string, ): Finding { - return { condition, file, range, message }; + return locatedFinding(condition, message, [{ file, range }]); } /** A human description of an external reference's target (messages only). */ @@ -661,18 +656,18 @@ function dependencyCycleFindings( if (start === undefined) { throw new Error("xspec internal error: cycle through an unknown node"); } - const finding: Finding = { - condition: 9, - file: start.path, - range: start.section.range, - cycle, - message: - `dependency cycle: ${cycle.join(" → ")} — the combined ` + + // SPEC 14.9/12.7: the full cycle path travels as identity context (a + // closed walk, first identity repeated at the end); the finding locates + // the cycle's starting section. + return locatedFinding( + 9, + `dependency cycle: ${cycle.join(" → ")} — the combined ` + `contains/depends/embeds graph over requirement nodes must be ` + `acyclic; break the cycle by removing or retargeting one of its ` + `depends or embeds references (SPEC 5.3, 14.9)`, - }; - return finding; + [{ file: start.path, range: start.section.range }], + cycle, + ); }); } @@ -708,18 +703,25 @@ function importCycleFindings( const closing = spec?.imports.imports.find( (declared) => declared.targetPath === cycle[1], ); - const finding: Finding = { - condition: 9, - file: cycle[0], - ...(closing !== undefined ? { range: closing.statement.range } : {}), - cycle, - message: - `spec import cycle: ${cycle.join(" → ")} — import cycles among ` + + // SPEC 14.9/12.7: the participating files travel as identity context; + // the finding locates the import that closes the reported cycle. + return locatedFinding( + 9, + `spec import cycle: ${cycle.join(" → ")} — import cycles among ` + `spec source files are invalid, even when no requirement-level ` + `dependency cycle exists; remove one of the participating imports ` + `(SPEC 2.1, 14.9)`, - }; - return finding; + [ + { + file: cycle[0], + range: + closing !== undefined + ? closing.statement.range + : { start: 0, end: 0 }, + }, + ], + cycle, + ); }); } diff --git a/src/core/journal.ts b/src/core/journal.ts index 7735389..bc3fc27 100644 --- a/src/core/journal.ts +++ b/src/core/journal.ts @@ -45,6 +45,7 @@ import type { ByteRange } from "./bytes.js"; import { compareBytes, sortByBytes } from "./bytes.js"; import { compactJson } from "./canonical-json.js"; import type { Finding } from "./findings.js"; +import { pathFinding } from "./findings.js"; import { firstInvalidUtf8 } from "./source-text.js"; import { containsControl, @@ -195,7 +196,7 @@ export function parseJournal(bytes: Uint8Array): ParsedJournal { if (result.ok) { entries.push({ ...result.entry, line, range }); } else { - findings.push(journalFinding(line, range, result.problem)); + findings.push(journalFinding(line, result.problem)); } offset = end + 1; } @@ -203,23 +204,18 @@ export function parseJournal(bytes: Uint8Array): ParsedJournal { } /** One 14.13 finding for a bad journal line, naming the line (SPEC 14.13). */ -function journalFinding( - line: number, - range: ByteRange, - problem: string, -): Finding { - return { - condition: 13, - file: JOURNAL_PATH, - line, - range, - message: - `journal error: the entry on line ${String(line)} of ${JOURNAL_PATH} ` + +function journalFinding(line: number, problem: string): Finding { + // SPEC 14: a journal condition carries the path it concerns, not an + // in-source location; the offending line is named in the message. + return pathFinding( + 13, + `journal error: the entry on line ${String(line)} of ${JOURNAL_PATH} ` + `${problem} — the journal is a durable, append-only record written ` + `only by \`xspec rename\` and \`xspec move\` (SPEC 6.1, 13.4); ` + `restore it from version control or delete the offending line ` + `(SPEC 14.13)`, - }; + JOURNAL_PATH, + ); } type LineResult = diff --git a/src/core/mdx.ts b/src/core/mdx.ts index 2559fd2..891887a 100644 --- a/src/core/mdx.ts +++ b/src/core/mdx.ts @@ -47,6 +47,7 @@ import { unified } from "unified"; import type { ByteRange } from "./bytes.js"; import { Utf8Offsets } from "./bytes.js"; import type { ConditionNumber, Finding } from "./findings.js"; +import { compareFindings, locatedFinding } from "./findings.js"; import { decodeSourceBytes } from "./source-text.js"; import { containsControl, @@ -939,15 +940,13 @@ export function parseSpecSource( // same way by its own catch-all. return { kind: "unparseable", - finding: { - condition: 20, - file: path, - range: { start: 0, end: 0 }, - message: - `unparseable source: not well-formed MDX — the file's nesting ` + + finding: locatedFinding( + 20, + `unparseable source: not well-formed MDX — the file's nesting ` + `exceeds what the parser can process, so no location inside ` + `it can be analyzed; simplify or split the file (SPEC 14.20)`, - }, + [{ file: path, range: { start: 0, end: 0 } }], + ), }; } throw error; @@ -975,7 +974,10 @@ function parseFailureFinding( // A VFileMessage's `place` is a point ({line, column, offset}) or a // position ({start, end}); either way the offsets are UTF-16 indices. - let range: ByteRange | undefined; + // SPEC 14/1.7: the reported location is a byte range; a failure exposing + // no offset locates at the file start (range [0, 0)). Line/column, when + // the failure carries them, enter the message text only. + let range: ByteRange = { start: 0, end: 0 }; let line: number | undefined; let column: number | undefined; const place = failure.place as @@ -1005,17 +1007,12 @@ function parseFailureFinding( line !== undefined ? ` at line ${String(line)}${column !== undefined ? `, column ${String(column)}` : ""}` : ""; - const finding: Finding = { - condition: 20, - file: path, - message: - `unparseable source: not well-formed MDX${where} — ${reason}. ` + + return locatedFinding( + 20, + `unparseable source: not well-formed MDX${where} — ${reason}. ` + `Correct the syntax at the reported location (SPEC 14.20)`, - ...(range !== undefined ? { range } : {}), - ...(line !== undefined ? { line } : {}), - ...(column !== undefined ? { column } : {}), - }; - return finding; + [{ file: path, range }], + ); } /** A byte range from a parse failure's UTF-16 point (and optional end). */ @@ -1179,7 +1176,9 @@ class DocumentBuilder { range: ByteRange, message: string, ): void { - this.findings.push({ condition, message, file: this.path, range }); + this.findings.push( + locatedFinding(condition, message, [{ file: this.path, range }]), + ); } /** The node's UTF-16 span; every parsed mdast node carries one. */ @@ -1819,13 +1818,8 @@ class DocumentBuilder { /** The completed, deterministic document model. */ finish(): SpecDocument { - // Deterministic report order (SPEC 12.0): by location, then condition. - const sorted = [...this.findings].sort( - (a, b) => - (a.range?.start ?? 0) - (b.range?.start ?? 0) || - (a.range?.end ?? 0) - (b.range?.end ?? 0) || - a.condition - b.condition, - ); + // Deterministic report order (SPEC 12.0, 12.7). + const sorted = [...this.findings].sort(compareFindings); return { path: this.path, text: this.text, diff --git a/src/core/policy.ts b/src/core/policy.ts index 6e68b71..d6999c0 100644 --- a/src/core/policy.ts +++ b/src/core/policy.ts @@ -28,6 +28,7 @@ import type { Configuration, PolicyRule, PolicySelector } from "./config.js"; import type { Finding } from "./findings.js"; +import { pathFinding } from "./findings.js"; import type { CaptureValues, CompiledGlob } from "./glob.js"; import type { GraphEdge, GraphNode, WorkspaceGraph } from "./graph.js"; @@ -138,16 +139,19 @@ function violationFinding(rule: PolicyRule, edge: GraphEdge): Finding { `forbidden rule` : `its source matches "from" but its target does not match "to" of ` + `the allowedOnly rule`; - return { - condition: 12, - message: - `policy violation: rule "${rule.name}": the ${edge.kind} edge ` + + // SPEC 14.12/12.7: the offending entity is a graph edge, not a spelling — + // no in-source locations, no concerned path; the identities are, in + // order, the violated rule's name and the edge's source identity, kind + // token, and target identity. + return pathFinding( + 12, + `policy violation: rule "${rule.name}": the ${edge.kind} edge ` + `${edge.source} -> ${edge.target} violates the rule — ${description} ` + `(SPEC 7.5); remove or redirect the dependency, or revise the rule ` + `in the configuration (SPEC 14.12)`, - rule: rule.name, - edge: { kind: edge.kind, source: edge.source, target: edge.target }, - }; + null, + [rule.name, edge.source, edge.kind, edge.target], + ); } /** diff --git a/src/core/review.ts b/src/core/review.ts index f859d3c..6252447 100644 --- a/src/core/review.ts +++ b/src/core/review.ts @@ -76,6 +76,7 @@ import type { } from "./config.js"; import { DEPENDENCY_EDGE_KINDS } from "./config.js"; import type { Finding } from "./findings.js"; +import { pathFinding } from "./findings.js"; /** SPEC 10.1: the reviews directory under the workspace root. */ export const REVIEWS_DIRECTORY = ".xspec/reviews"; @@ -485,19 +486,17 @@ export function corruptSessionFinding( name: string, problems: readonly string[], ): Finding { - return { - condition: 21, - file: sessionFilePath(name), - message: - `corrupt review session ${JSON.stringify(name)}: ` + + return pathFinding( + 21, + `corrupt review session ${JSON.stringify(name)}: ` + problems.join("; ") + - ` (SPEC 10.1)`, - correction: - `the session file ${sessionFilePath(name)} was modified outside ` + - `xspec or damaged; sessions are durable files changed only by their ` + - `owning commands (SPEC 13.4) — restore the file from version control, ` + - `or delete it and create the session again (SPEC 14.21)`, - }; + ` (SPEC 10.1) — the session file ${sessionFilePath(name)} was ` + + `modified outside xspec or damaged; sessions are durable files ` + + `changed only by their owning commands (SPEC 13.4) — restore the ` + + `file from version control, or delete it and create the session ` + + `again (SPEC 14.21)`, + sessionFilePath(name), + ); } /** @@ -510,19 +509,17 @@ export function corruptSessionOccupantFinding( name: string, occupant: string, ): Finding { - return { - condition: 21, - file: sessionFilePath(name), - message: - `corrupt review session ${JSON.stringify(name)}: the session path ` + + return pathFinding( + 21, + `corrupt review session ${JSON.stringify(name)}: the session path ` + `${sessionFilePath(name)} is occupied by ${occupant}, not a plain ` + `file — a durable file's path occupied by anything other than a ` + - `plain file is never read, appended to, or replaced (SPEC 13.4, 10.1)`, - correction: - `remove the occupant and restore the session as a plain file from ` + - `version control, or delete it and create the session again ` + + `plain file is never read, appended to, or replaced (SPEC 13.4, ` + + `10.1); remove the occupant and restore the session as a plain file ` + + `from version control, or delete it and create the session again ` + `(SPEC 14.21)`, - }; + sessionFilePath(name), + ); } // --------------------------------------------------------------------------- diff --git a/src/core/source-text.ts b/src/core/source-text.ts index 2e71a53..2505fe6 100644 --- a/src/core/source-text.ts +++ b/src/core/source-text.ts @@ -9,6 +9,7 @@ // as a byte offset into the file. import type { Finding } from "./findings.js"; +import { locatedFinding } from "./findings.js"; /** Decoder for byte sequences already validated by `firstInvalidUtf8`. */ const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); @@ -94,30 +95,26 @@ export function decodeSourceBytes( ) { return { ok: false, - finding: { - condition: 20, - file: path, - range: { start: 0, end: 3 }, - message: - "unparseable source: the file begins with a UTF-8 byte-order " + + finding: locatedFinding( + 20, + "unparseable source: the file begins with a UTF-8 byte-order " + "mark (bytes 0-3) — source files are BOM-free UTF-8; remove the " + "byte-order mark (SPEC 1.6, 14.20)", - }, + [{ file: path, range: { start: 0, end: 3 } }], + ), }; } const invalidAt = firstInvalidUtf8(bytes); if (invalidAt !== -1) { return { ok: false, - finding: { - condition: 20, - file: path, - range: { start: invalidAt, end: invalidAt + 1 }, - message: - `unparseable source: the file is not valid UTF-8 (first invalid ` + + finding: locatedFinding( + 20, + `unparseable source: the file is not valid UTF-8 (first invalid ` + `byte at offset ${String(invalidAt)}) — re-encode the file as ` + `UTF-8 (SPEC 1.6, 14.20)`, - }, + [{ file: path, range: { start: invalidAt, end: invalidAt + 1 } }], + ), }; } return { ok: true, text: utf8Decoder.decode(bytes) }; diff --git a/src/core/spec-references.ts b/src/core/spec-references.ts index 098a4c6..85c7ec2 100644 --- a/src/core/spec-references.ts +++ b/src/core/spec-references.ts @@ -25,6 +25,7 @@ import ts from "typescript"; import type { ByteRange } from "./bytes.js"; import { Utf8Offsets } from "./bytes.js"; import type { Finding } from "./findings.js"; +import { compareFindings, locatedFinding } from "./findings.js"; import type { SpecDocument, SpecEmbedding, @@ -288,33 +289,33 @@ export function analyzeSpecImports( const valid = defects.length === 0; if (!valid) { // SPEC 14.15: one finding per invalid import, listing its defects. - findings.push({ - condition: 15, - file: document.path, - range: statement.range, - message: + findings.push( + locatedFinding( + 15, `invalid import: ${defects.join("; ")} — the only permitted ` + - `import is a single default binding of a relative "./"/"../" ` + - `specifier ending in ".xspec" that designates a discovered ` + - `spec-group file, e.g. import BASE from "./BASE.xspec" ` + - `(SPEC 2.1, 14.15)`, - }); + `import is a single default binding of a relative "./"/"../" ` + + `specifier ending in ".xspec" that designates a discovered ` + + `spec-group file, e.g. import BASE from "./BASE.xspec" ` + + `(SPEC 2.1, 14.15)`, + [{ file: document.path, range: statement.range }], + ), + ); targetPath = null; } // SPEC 2.1: no two imports in a file may bind the same identifier. for (const name of names) { if (seenNames.has(name)) { - findings.push({ - condition: 15, - file: document.path, - range: statement.range, - message: + findings.push( + locatedFinding( + 15, `invalid import: the identifier ${JSON.stringify(name)} is ` + - `already bound by another import in this file — no two ` + - `imports in an xspec source file may bind the same ` + - `identifier; rename one binding (SPEC 2.1, 14.15)`, - }); + `already bound by another import in this file — no two ` + + `imports in an xspec source file may bind the same ` + + `identifier; rename one binding (SPEC 2.1, 14.15)`, + [{ file: document.path, range: statement.range }], + ), + ); bindings.set(name, { kind: "poisoned" }); } else { seenNames.add(name); @@ -531,12 +532,9 @@ class ReferenceAnalyzer { ) {} private addFinding(range: ByteRange, message: string): void { - this.findings.push({ - condition: 8, - file: this.document.path, - range, - message, - }); + this.findings.push( + locatedFinding(8, message, [{ file: this.document.path, range }]), + ); } /** @@ -691,12 +689,16 @@ class ReferenceAnalyzer { if (classified.kind === "dynamic") { return { outcome: "finding", - finding: { - condition: 8, - file: this.document.path, - range: translate.range(classified.span), - message: `invalid argument: ${classified.reason} — ${expectation}`, - }, + finding: locatedFinding( + 8, + `invalid argument: ${classified.reason} — ${expectation}`, + [ + { + file: this.document.path, + range: translate.range(classified.span), + }, + ], + ), }; } if (classified.kind === "string") { @@ -711,15 +713,18 @@ class ReferenceAnalyzer { // module; a root no import binds makes the reference dynamic. return { outcome: "finding", - finding: { - condition: 8, - file: this.document.path, - range: translate.range(classified.span), - message: - `invalid argument: the property chain is rooted at ` + + finding: locatedFinding( + 8, + `invalid argument: the property chain is rooted at ` + `${JSON.stringify(classified.rootName)}, which no spec-module ` + `import in this file binds — ${expectation}`, - }, + [ + { + file: this.document.path, + range: translate.range(classified.span), + }, + ], + ), }; } if (binding.kind === "poisoned") { @@ -777,12 +782,7 @@ class ReferenceAnalyzer { } } -/** Deterministic finding order (SPEC 12.0): by location, then condition. */ +/** Deterministic finding order (SPEC 12.0, 12.7). */ function sortFindings(findings: readonly Finding[]): Finding[] { - return [...findings].sort( - (a, b) => - (a.range?.start ?? 0) - (b.range?.start ?? 0) || - (a.range?.end ?? 0) - (b.range?.end ?? 0) || - a.condition - b.condition, - ); + return [...findings].sort(compareFindings); } diff --git a/src/workspace/baseline.ts b/src/workspace/baseline.ts index 0b110ee..69a7202 100644 --- a/src/workspace/baseline.ts +++ b/src/workspace/baseline.ts @@ -186,10 +186,9 @@ function invalidBaselineMessage( findings: readonly Finding[], ): string { const lines = findings.map((finding) => { - const file = finding.file === undefined ? "" : `${finding.file}: `; - const correction = - finding.correction === undefined ? "" : ` — ${finding.correction}`; - return `\n ${file}${finding.message}${correction}`; + const concerned = finding.locations[0]?.file ?? finding.path; + const file = concerned === null ? "" : `${concerned}: `; + return `\n ${file}${finding.message}`; }); return ( `the workspace content at baseline ref '${ref}' cannot be parsed and ` + diff --git a/src/workspace/check.ts b/src/workspace/check.ts index 0a29a74..ded693d 100644 --- a/src/workspace/check.ts +++ b/src/workspace/check.ts @@ -22,6 +22,7 @@ import * as fsp from "node:fs/promises"; import * as path from "node:path"; import type { BuildOutputs } from "../core/build.js"; import type { Finding } from "../core/findings.js"; +import { pathFinding } from "../core/findings.js"; import { GRAPH_DATA_PATH, graphDataMatchesCurrent, @@ -47,26 +48,24 @@ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { /** SPEC 14.10: a stale generated file — names the file, instructs rebuild. */ function staleFinding(rel: string, state: string): Finding { - return { - condition: 10, - file: rel, - message: - `stale generated output: ${rel} ${state} what the current sources ` + + return pathFinding( + 10, + `stale generated output: ${rel} ${state} what the current sources ` + `and configuration generate; run \`xspec build\` to regenerate every ` + `derived file (SPEC 14.10)`, - }; + rel, + ); } /** SPEC 14.10: a recorded derived file at a no-longer-generated path. */ function orphanFinding(rel: string): Finding { - return { - condition: 10, - file: rel, - message: - `stale generated output: the recorded derived file ${rel} remains at ` + + return pathFinding( + 10, + `stale generated output: the recorded derived file ${rel} remains at ` + `a path the current sources and configuration no longer generate; ` + `run \`xspec build\` to remove it (SPEC 14.10)`, - }; + rel, + ); } /** diff --git a/src/workspace/config.ts b/src/workspace/config.ts index 044dfd1..4e47b8d 100644 --- a/src/workspace/config.ts +++ b/src/workspace/config.ts @@ -22,6 +22,7 @@ import type { Configuration, ConfigurationResult } from "../core/config.js"; import { parseConfiguration } from "../core/config.js"; import type { Finding } from "../core/findings.js"; +import { pathFinding } from "../core/findings.js"; import { sha256Hex } from "../core/hash.js"; import type { LocatedWorkspace } from "./locate.js"; import { locateWorkspace } from "./locate.js"; @@ -108,13 +109,12 @@ export function parseConfigurationBytes( return { ok: false, findings: [ - { - condition: 14, - file: configFileName, - message: - `not valid UTF-8 — the configuration must be well-formed ` + + pathFinding( + 14, + `not valid UTF-8 — the configuration must be well-formed ` + `TypeScript (SPEC 7, 14.14)`, - }, + configFileName, + ), ], }; } diff --git a/src/workspace/journal.ts b/src/workspace/journal.ts index d538cf4..731feb7 100644 --- a/src/workspace/journal.ts +++ b/src/workspace/journal.ts @@ -17,6 +17,7 @@ import * as fsp from "node:fs/promises"; import * as path from "node:path"; import type { Finding } from "../core/findings.js"; +import { pathFinding } from "../core/findings.js"; import type { JournalEntry, PositionedJournalEntry } from "../core/journal.js"; import { Journal, @@ -95,17 +96,16 @@ export function journalFromBytes(bytes: Uint8Array | null): LoadedJournal { * the ref) instead of a filesystem occupant. */ export function occupiedJournal(occupant: PathOccupant): LoadedJournal { - const finding: Finding = { - condition: 13, - file: JOURNAL_PATH, - message: - `journal error: the journal path ${JOURNAL_PATH} is occupied by ` + + const finding: Finding = pathFinding( + 13, + `journal error: the journal path ${JOURNAL_PATH} is occupied by ` + `${describeOccupant(occupant)}, not a plain file — a durable file's ` + `path occupied by anything other than a plain file is never read, ` + `appended to, or replaced (SPEC 6.1, 13.4); remove the occupant ` + `and restore the journal as a plain file from version control ` + `(SPEC 14.13)`, - }; + JOURNAL_PATH, + ); return { fileState: "occupied", journal: new Journal([]), diff --git a/src/workspace/locate.ts b/src/workspace/locate.ts index 053b3aa..6ce24aa 100644 --- a/src/workspace/locate.ts +++ b/src/workspace/locate.ts @@ -18,6 +18,7 @@ import * as fsp from "node:fs/promises"; import * as path from "node:path"; import type { Finding } from "../core/findings.js"; +import { pathFinding } from "../core/findings.js"; /** SPEC 7: the configuration file name the upward search looks for. */ export const CONFIG_FILE_NAME = "xspec.config.ts"; @@ -40,7 +41,7 @@ export type WorkspaceLocateResult = | { readonly ok: false; readonly findings: readonly Finding[] }; function failure(message: string, file?: string): WorkspaceLocateResult { - return { ok: false, findings: [{ condition: 14, message, file }] }; + return { ok: false, findings: [pathFinding(14, message, file ?? null)] }; } /** Whether a plain-stat of the path reaches a regular file. */ diff --git a/src/workspace/pipeline.ts b/src/workspace/pipeline.ts index f680b9b..b373dab 100644 --- a/src/workspace/pipeline.ts +++ b/src/workspace/pipeline.ts @@ -39,7 +39,11 @@ import type { Configuration } from "../core/config.js"; import type { SourceClassification } from "../core/discovery.js"; import { markdownEmitDestinations } from "../core/discovery.js"; import type { Finding } from "../core/findings.js"; -import { conditionExitClass } from "../core/findings.js"; +import { + codeExitClass, + locatedFinding, + orderFindings, +} from "../core/findings.js"; import { configurationToStored } from "../core/config-data.js"; import type { StoredInputs } from "../core/graph-data.js"; import type { SpecFileAnalysis } from "../core/graph.js"; @@ -100,21 +104,6 @@ function absoluteOf(root: string, rel: string): string { return path.join(root, ...rel.split("/")); } -/** - * SPEC 14: deterministic report order — by file (byte order), then location, - * then condition number. The sort is stable, so equal keys keep their - * collection order (which is already document order within a file). - */ -function orderFindings(findings: readonly Finding[]): Finding[] { - return [...findings].sort( - (a, b) => - compareBytes(a.file ?? "", b.file ?? "") || - (a.range?.start ?? -1) - (b.range?.start ?? -1) || - (a.range?.end ?? -1) - (b.range?.end ?? -1) || - a.condition - b.condition, - ); -} - /** * A workspace's content, however sourced: the classified file listing, a * byte reader for the discovered sources, and the journal. The filesystem @@ -172,7 +161,7 @@ export async function analyzeWorkspaceContent( // precede all source analysis — with one present, no source is parsed and // no finding-class condition is reported. const configurationErrors = classification.findings.filter( - (finding) => conditionExitClass(finding.condition) === 2, + (finding) => codeExitClass(finding.code) === 2, ); if (configurationErrors.length > 0) { const graph = buildWorkspaceGraph({ specs: [], code: [] }); @@ -245,15 +234,15 @@ export async function analyzeWorkspaceContent( // when the MDX parse itself succeeded) makes the file unparseable — // one finding, the file's contents masked, never a crash (SPEC 12.0). if (!(error instanceof RangeError)) throw error; - findings.push({ - condition: 20, - file: source.path, - range: { start: 0, end: 0 }, - message: + findings.push( + locatedFinding( + 20, `unparseable source: not well-formed MDX — the file's nesting ` + - `exceeds what the analyzer can process, so no location inside ` + - `it can be analyzed; simplify or split the file (SPEC 14.20)`, - }); + `exceeds what the analyzer can process, so no location inside ` + + `it can be analyzed; simplify or split the file (SPEC 14.20)`, + [{ file: source.path, range: { start: 0, end: 0 } }], + ), + ); } } @@ -355,12 +344,13 @@ async function readSourceBytes( * analyzed. */ function unreadableSourceFinding(rel: string): Finding { - return { - condition: 20, - file: rel, - message: - `unparseable source: the discovered file could not be read — it ` + + // SPEC 14.20 locates in source; with no readable content, the failure + // locates at the file start (range [0, 0)). + return locatedFinding( + 20, + `unparseable source: the discovered file could not be read — it ` + `changed or vanished while the command ran; re-run the command ` + `once the workspace is quiescent (SPEC 13.5, 14.20)`, - }; + [{ file: rel, range: { start: 0, end: 0 } }], + ); } diff --git a/src/workspace/writes.ts b/src/workspace/writes.ts index 28f6e58..d3090ad 100644 --- a/src/workspace/writes.ts +++ b/src/workspace/writes.ts @@ -35,6 +35,7 @@ import * as path from "node:path"; import * as process from "node:process"; import { compareBytes } from "../core/bytes.js"; import type { Finding } from "../core/findings.js"; +import { pathFinding } from "../core/findings.js"; /** * What occupies a filesystem path, judged by `lstat` — a symbolic link is @@ -121,17 +122,15 @@ export async function symlinkComponentOf( /** The SPEC 14.22 finding for `rel` traversing the symlink `component`. */ function symlinkFinding(rel: string, component: string): Finding { - return { - condition: 22, - file: rel, - message: - `symbolic link in a write path: writing ${rel} would traverse the ` + + return pathFinding( + 22, + `obstructed write path: writing ${rel} would traverse the ` + `workspace-relative directory component ${component}, which is a ` + - `symbolic link — writes never traverse symbolic links (SPEC 13.4)`, - correction: + `symbolic link — writes never traverse symbolic links (SPEC 13.4); ` + `replace ${component} with a real directory, or redirect the write ` + `so no path xspec writes passes through it (SPEC 14.22)`, - }; + rel, + ); } /** From c0a32cb07fd53ab095d57362d941a71854d66d77 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 21:15:03 +0000 Subject: [PATCH 146/226] sdg(phase-10): present non-UTF-8 finding paths in the 12.7 marked byte form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX_PLAN A2. New src/core/path-text.ts is the one internal path representation and shared path-value renderer (SPEC 12.0, 12.7, 14.19): PathText = string | PathBytes, constructed from raw bytes only through pathTextOf so a valid-UTF-8 path can never take the marked form; pathTextJson renders the JSON value form ({"bytes": lowercase hex} for a path with no plain string form), renderPathText the deterministic human spelling <bytes HEX>, comparePathTexts one byte order over both forms. Finding locations[].file and path are PathText; compareFindings and compareLocations compare paths by exact bytes whatever the presentation form; discovery's 14.19/14.14 findings carry the offending path's exact bytes instead of the lossy U+FFFD decode; the human report, JSON report, configuration-error diagnostic, and baseline diagnostic all render through the shared helpers. Verified: T12.7-2's build/query arms now emit {"bytes":"73706563732f41ff2e6d6478"} sorted before the plain "specs/ha#…" paths (remaining deltas in section-12.7 are A3 cardinality / A4 error document / Stage B surfaces); section-14 (5 failed / 3 passed) and section-11.2 / 12.1-12.2 / 13.3 (12 failed / 4 passed) byte-identical to the pre-change baseline — no regressions; P-7 and sections 1.1-1.2 and 15 pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 35 +++------- src/cli/report.ts | 23 +++++-- src/core/discovery.ts | 23 ++----- src/core/findings.ts | 36 +++++++---- src/core/path-text.ts | 130 ++++++++++++++++++++++++++++++++++++++ src/workspace/baseline.ts | 3 +- 6 files changed, 188 insertions(+), 62 deletions(-) create mode 100644 src/core/path-text.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 7999224..cbc6d93 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -30,32 +30,15 @@ test passes (`npm test` locally and in CI, including the Windows E-6 leg). ## Stage A — SPEC 12.7 report forms and the stable-code model -### A2. Marked byte-form path values in finding JSON - -SPEC 12.7 (path value form), 12.0 (marked byte form), 14.19. Landed already -(with the A1 model rebuild): the five-member finding form with stable code -tokens, the 12.7 findings ordering/collapse choke point -(`orderFindings`/`compareFindings` in `src/core/findings.ts`, applied by every -emitter through `src/cli/report.ts` and `src/workspace/pipeline.ts`), and the -human renderer. Remaining — the path presentation form: - -- A path whose bytes are valid UTF-8 is a JSON string; otherwise the marked - byte form `{"bytes": "<lowercase hex, two digits per byte>"}` — wherever a - JSON output carries a workspace-relative path (finding `locations[].file` - and `path` now; the 11.3–11.6 surfaces reuse the same renderer in Stage B). - Implement one path-value renderer shared by all JSON output. -- This needs the internal path representation at finding sites to preserve raw - bytes for non-UTF-8 discovered paths (14.19) rather than the lossy U+FFFD - string `src/core/discovery.ts` produces today (`fileLabel` from - `lossyUtf8Decoder`) — choose conservatively (e.g. carry bytes alongside the - string on the finding path, or a marker encoding) and note the choice. The - findings comparator must then compare such paths by their exact bytes - (`compareFindings` in `src/core/findings.ts` compares the string spelling - today), and the human renderer must render them deterministically. - -Verify: T12.7-1/2 (`section-12.7.test.ts` — T12.7-2's 14.19 arm expects -`{"bytes":"73706563732f41ff2e6d6478"}` where the product now emits the lossy -string), T14 arms, P-7. +(A2 landed: the shared path-value representation and renderer are +`src/core/path-text.ts` — `PathText` is `string | PathBytes` (a tagged +wrapper holding a non-UTF-8 path's exact bytes, constructed only through +`pathTextOf` so a valid-UTF-8 path can never take the marked form); +`pathTextJson` renders the 12.7 value form, `renderPathText` the +deterministic human spelling `<bytes HEX>`, `comparePathTexts` the one byte +order over both forms, used by `compareFindings`/`compareLocations`. Finding +`locations[].file` and `path` are `PathText` now; Stage B surfaces reuse the +same renderer for their own path members.) ### A3. Multi-location cardinality for jointly-violated conditions diff --git a/src/cli/report.ts b/src/cli/report.ts index 14071cd..cbb66a5 100644 --- a/src/cli/report.ts +++ b/src/cli/report.ts @@ -24,11 +24,16 @@ import { canonicalJson } from "../core/canonical-json.js"; import type { JsonObject, JsonValue } from "../core/canonical-json.js"; import type { Finding, FindingLocation } from "../core/findings.js"; import { orderFindings } from "../core/findings.js"; +import { pathTextJson, renderPathText } from "../core/path-text.js"; import type { CliWriter } from "./io.js"; -/** A location as human text: `FILE:START-END`. */ +/** + * A location as human text: `FILE:START-END` — the file through the shared + * deterministic path spelling (core/path-text.ts): a non-UTF-8 path (SPEC + * 14.19) renders as its exact bytes, never lossily (SPEC 12.0). + */ function renderLocation(location: FindingLocation): string { - return `${location.file}:${String(location.range.start)}-${String(location.range.end)}`; + return `${renderPathText(location.file)}:${String(location.range.start)}-${String(location.range.end)}`; } /** @@ -42,7 +47,7 @@ function renderFindingLine(finding: Finding): string { if (finding.locations.length > 0) { prefix = `${renderLocation(finding.locations[0]!)}: `; } else if (finding.path !== null) { - prefix = `${finding.path}: `; + prefix = `${renderPathText(finding.path)}: `; } const label = finding.code ?? "finding"; const more = @@ -72,17 +77,20 @@ export function renderFindingsHuman(findings: readonly Finding[]): string { /** * One finding as JSON data — exactly the five-member finding form of SPEC * 12.7: `{"code", "message", "locations", "path", "identities"}`, `null` - * never omitted, empty lists `[]`. + * never omitted, empty lists `[]`. Location files and the concerned path go + * through the one shared path-value renderer (core/path-text.ts): a plain + * JSON string, or the marked byte form for a non-UTF-8 path (SPEC 12.0, + * 12.7, 14.19). */ export function findingToJson(finding: Finding): JsonObject { return { code: finding.code, message: finding.message, locations: finding.locations.map((location) => ({ - file: location.file, + file: pathTextJson(location.file), range: { start: location.range.start, end: location.range.end }, })), - path: finding.path, + path: finding.path === null ? null : pathTextJson(finding.path), identities: [...finding.identities], }; } @@ -122,7 +130,8 @@ export function emitFindingsReport( * standard-error content. */ export function renderConfigurationError(finding: Finding): string { - const location = finding.path === null ? "" : `${finding.path}: `; + const location = + finding.path === null ? "" : `${renderPathText(finding.path)}: `; return `xspec: configuration error: ${location}${finding.message}\n`; } diff --git a/src/core/discovery.ts b/src/core/discovery.ts index 89cf397..493fedf 100644 --- a/src/core/discovery.ts +++ b/src/core/discovery.ts @@ -19,13 +19,12 @@ import type { Configuration, ConfiguredGroup } from "./config.js"; import type { Finding } from "./findings.js"; import { pathFinding } from "./findings.js"; +import { pathTextOf } from "./path-text.js"; const SLASH = 0x2f; // "/" const HASH = 0x23; // "#" — reserved by node identities (SPEC 1.5) const utf8Encoder = new TextEncoder(); -const strictUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); -const lossyUtf8Decoder = new TextDecoder("utf-8"); /** SPEC 13.4: derived-file name marker — `.xspec.` within the file name. */ const XSPEC_NAME_INFIX = utf8Encoder.encode(".xspec."); @@ -132,15 +131,6 @@ function byteKey(bytes: Uint8Array): string { return key; } -/** The decoded path, or null when the bytes are not valid UTF-8 (SPEC 7). */ -function decodeStrict(bytes: Uint8Array): string | null { - try { - return strictUtf8Decoder.decode(bytes); - } catch { - return null; - } -} - /** * The configured groups whose globs match the path, in written order * (SPEC 7: matching is byte-wise against the workspace-relative path). @@ -326,11 +316,12 @@ export function classifySources( const findings: Finding[] = []; for (const candidate of matched) { if (destinationKeys.has(byteKey(candidate.bytes))) continue; - const decoded = decodeStrict(candidate.bytes); - // Findings name the file by its decoded workspace-relative path; a - // non-UTF-8 path has no exact string spelling, so it renders lossily - // (U+FFFD) — SPEC.md fixes no spelling for it. - const fileLabel = decoded ?? lossyUtf8Decoder.decode(candidate.bytes); + // Findings name the file by its workspace-relative path as a PathText: + // the decoded string where the bytes are valid UTF-8, otherwise the + // exact bytes — presented downstream in the marked byte form, never a + // lossy string (SPEC 12.0, 12.7, 14.19). + const fileLabel = pathTextOf(candidate.bytes); + const decoded = typeof fileLabel === "string" ? fileLabel : null; let valid = true; if (candidate.specGroups.length > 0 && candidate.codeGroups.length > 0) { // SPEC 7.2 → 14.14: a file matched by both a spec and a code group diff --git a/src/core/findings.ts b/src/core/findings.ts index e50d16b..844a121 100644 --- a/src/core/findings.ts +++ b/src/core/findings.ts @@ -12,6 +12,8 @@ import type { ByteRange } from "./bytes.js"; import { compareBytes } from "./bytes.js"; +import type { PathText } from "./path-text.js"; +import { comparePathTexts } from "./path-text.js"; /** * SPEC 12.0: exit codes partition all outcomes — 0 success, 1 findings @@ -108,10 +110,12 @@ export function conditionCode(condition: ConditionNumber): ConditionCode { /** * One offending construct's location: the containing file (workspace- * relative, `/`-separated, SPEC 1.5) and its byte range (SPEC 1.7). The - * observable form is `{"file", "range"}` (SPEC 12.7). + * observable form is `{"file", "range"}` (SPEC 12.7). The file is a + * `PathText`: a plain string except for a file whose path is not valid + * UTF-8 (SPEC 14.19), presented in the marked byte form (SPEC 12.0, 12.7). */ export interface FindingLocation { - readonly file: string; + readonly file: PathText; readonly range: ByteRange; } @@ -128,7 +132,9 @@ export interface FindingLocation { * in-source locations (SPEC 14, 12.7). * - `path`: the concerned file or path for non-located conditions * (configuration, path-level, journal, session, and record conditions); - * null for located ones (SPEC 14). + * null for located ones (SPEC 14). A `PathText`: a non-UTF-8 concerned + * path (SPEC 14.19) carries its exact bytes, presented in the marked + * byte form (SPEC 12.0, 12.7). * - `identities`: the identities or other context strings the condition * names, empty where none — contractual exactly where 14 states it * (14.12's enumeration, 14.11's foreign module, a refusal reason's @@ -138,7 +144,7 @@ export interface Finding { readonly code: FindingCode | null; readonly message: string; readonly locations: readonly FindingLocation[]; - readonly path: string | null; + readonly path: PathText | null; readonly identities: readonly string[]; } @@ -171,7 +177,7 @@ export function locatedFinding( export function pathFinding( condition: ConditionNumber, message: string, - path: string | null, + path: PathText | null, identities: readonly string[] = [], ): Finding { return { @@ -207,13 +213,17 @@ export function codeExitClass(code: FindingCode | null): 1 | 2 { return code === "configuration-error" ? 2 : 1; } -/** The pinned within-finding location order (SPEC 12.7). */ +/** + * The pinned within-finding location order (SPEC 12.7). Files compare by + * their exact path bytes whatever their presentation form (SPEC 12.0): a + * marked byte-form path and a plain string sort in one byte order. + */ export function compareLocations( a: FindingLocation, b: FindingLocation, ): number { return ( - compareBytes(a.file, b.file) || + comparePathTexts(a.file, b.file) || a.range.start - b.range.start || a.range.end - b.range.end ); @@ -248,10 +258,12 @@ function compareSequences<T>( * numeric order, then refusal reasons in 14's listed order, then code-less * findings), then by locations element-wise (file path bytes, range start, * range end; proper prefix first), then by concerned path (null before any - * path; byte-wise), then by identities element-wise under the same prefix - * rule (byte-wise elements), then by message. Returns 0 exactly for - * findings identical in every member — which collapse to one (12.7) — so - * the order is total. + * path; paths compare byte-wise whatever their presentation form — a + * marked byte-form path and a plain string sort in one byte order, SPEC + * 12.0), then by identities element-wise under the same prefix rule + * (byte-wise elements), then by message. Returns 0 exactly for findings + * identical in every member — which collapse to one (12.7) — so the order + * is total. */ export function compareFindings(a: Finding, b: Finding): number { const byCode = codeOrdinal(a.code) - codeOrdinal(b.code); @@ -264,7 +276,7 @@ export function compareFindings(a: Finding, b: Finding): number { if (byLocations !== 0) return byLocations; if ((a.path === null) !== (b.path === null)) return a.path === null ? -1 : 1; if (a.path !== null && b.path !== null) { - const byPath = compareBytes(a.path, b.path); + const byPath = comparePathTexts(a.path, b.path); if (byPath !== 0) return byPath; } const byIdentities = compareSequences(a.identities, b.identities, (x, y) => diff --git a/src/core/path-text.ts b/src/core/path-text.ts new file mode 100644 index 0000000..e47fd68 --- /dev/null +++ b/src/core/path-text.ts @@ -0,0 +1,130 @@ +// Path values with and without a plain string form (SPEC 12.0, 12.7, 14.19). +// +// SPEC 12.0: a workspace-relative path that is not valid UTF-8 (14.19) has +// no plain string form — wherever an output carries one, it is presented in +// an explicitly marked byte form that carries the path's exact bytes and is +// distinguishable from every plain path string, deterministically; a +// valid-UTF-8 path is never presented in the marked form. SPEC 12.7 fixes +// the JSON value form: a path is a string where its bytes are valid UTF-8, +// and otherwise `{"bytes": "…"}` — the path's exact bytes as lowercase +// hexadecimal, two digits per byte — an object, equal to no path string. +// +// This module is the one internal representation and the one shared +// path-value renderer (IMPLEMENTATION cross-cutting rules: findings and +// reports are built as data and rendered once per output form). Every +// output-facing path is a `PathText`; every JSON output renders it through +// `pathTextJson`, every human output through `renderPathText`, and every +// path comparison in output ordering goes through `comparePathTexts` — +// byte-wise, one order over both presentation forms (SPEC 12.0, 12.7). + +import { compareBytes } from "./bytes.js"; +import type { JsonValue } from "./canonical-json.js"; + +const strictUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); +const utf8Encoder = new TextEncoder(); + +/** + * A path with no plain string form: its exact bytes (SPEC 12.0, 14.19). + * Constructed only by `pathTextOf`, which guarantees the bytes are NOT + * valid UTF-8 — so rendering a `PathBytes` in the marked byte form never + * presents a valid-UTF-8 path that way (SPEC 12.7). + */ +export interface PathBytes { + readonly kind: "path-bytes"; + /** The path's exact bytes. Treated as immutable. */ + readonly bytes: Uint8Array; +} + +/** + * A path as data — workspace-relative, or in the anchoring form of 11.6: + * its string spelling where its bytes are valid UTF-8 (the common case, so + * plain strings remain paths everywhere), and otherwise its exact bytes. + * Valid discovered source paths are always plain strings (SPEC 7 → 14.19); + * only the paths of files 14.19 rejects, reachable in outputs through + * findings and the surfaces of 11.3–11.6, take the `PathBytes` arm. + */ +export type PathText = string | PathBytes; + +/** + * The `PathText` of a byte path: the decoded string exactly when the bytes + * are valid UTF-8, otherwise the exact bytes (copied — the result never + * aliases the caller's buffer). The single constructor of `PathBytes` + * values, keeping the marked-form invariant by construction (SPEC 12.7: a + * valid-UTF-8 path is never presented in the marked form). + */ +export function pathTextOf(bytes: Uint8Array): PathText { + try { + return strictUtf8Decoder.decode(bytes); + } catch { + return { kind: "path-bytes", bytes: bytes.slice() }; + } +} + +/** Whether a `PathText` is the byte arm (no plain string form). */ +export function isPathBytes(path: PathText): path is PathBytes { + return typeof path !== "string"; +} + +/** The exact bytes a `PathText` denotes (paths compare byte-wise, 12.0). */ +export function pathTextBytes(path: PathText): Uint8Array { + return typeof path === "string" ? utf8Encoder.encode(path) : path.bytes; +} + +/** Three-way lexicographic comparison of two byte arrays. */ +function compareByteArrays(a: Uint8Array, b: Uint8Array): -1 | 0 | 1 { + const shorter = Math.min(a.length, b.length); + for (let index = 0; index < shorter; index += 1) { + if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1; + } + if (a.length === b.length) return 0; + return a.length < b.length ? -1 : 1; +} + +/** + * SPEC 12.0/12.7: paths compare byte-wise whatever their presentation form + * — a marked byte-form path and a plain string sort in one byte order. + * Equivalent to lexicographic comparison of `pathTextBytes` on both sides; + * the all-strings case runs on `compareBytes` without materializing bytes. + */ +export function comparePathTexts(a: PathText, b: PathText): -1 | 0 | 1 { + if (typeof a === "string" && typeof b === "string") { + return compareBytes(a, b); + } + return compareByteArrays(pathTextBytes(a), pathTextBytes(b)); +} + +/** The path's exact bytes as lowercase hexadecimal, two digits per byte. */ +function lowercaseHex(bytes: Uint8Array): string { + let hex = ""; + for (let index = 0; index < bytes.length; index += 1) { + hex += bytes[index].toString(16).padStart(2, "0"); + } + return hex; +} + +/** + * The one shared JSON path-value renderer (SPEC 12.7): a plain JSON string + * for a valid-UTF-8 path, and for a path with no plain string form the + * marked byte form `{"bytes": "…"}` — its exact bytes as lowercase + * hexadecimal, two digits per byte. + */ +export function pathTextJson(path: PathText): JsonValue { + return typeof path === "string" ? path : { bytes: lowercaseHex(path.bytes) }; +} + +/** + * The deterministic human spelling of a path value (SPEC 12.0: outputs are + * byte-deterministic; 14: human and JSON reports carry the same + * information): the path string itself, or — for a path with no plain + * string form — an explicitly marked spelling of its exact bytes, + * `<bytes HEX>`, distinguishable from every plain workspace-relative path + * (which never contains `<` at a spelling boundary the renderer produces + * and is never spelled this way by xspec). SPEC.md fixes no human spelling + * for such paths; the hex form is chosen because it is injective and + * mirrors the JSON marked byte form's information exactly. + */ +export function renderPathText(path: PathText): string { + return typeof path === "string" + ? path + : `<bytes ${lowercaseHex(path.bytes)}>`; +} diff --git a/src/workspace/baseline.ts b/src/workspace/baseline.ts index 69a7202..0dcc65c 100644 --- a/src/workspace/baseline.ts +++ b/src/workspace/baseline.ts @@ -33,6 +33,7 @@ import * as path from "node:path"; import { classifySources } from "../core/discovery.js"; import type { Finding } from "../core/findings.js"; import type { Journal } from "../core/journal.js"; +import { renderPathText } from "../core/path-text.js"; import { computeJournalReplay, JOURNAL_PATH } from "../core/journal.js"; import type { LoadedWorkspace } from "./config.js"; import { parseConfigurationBytes } from "./config.js"; @@ -187,7 +188,7 @@ function invalidBaselineMessage( ): string { const lines = findings.map((finding) => { const concerned = finding.locations[0]?.file ?? finding.path; - const file = concerned === null ? "" : `${concerned}: `; + const file = concerned === null ? "" : `${renderPathText(concerned)}: `; return `\n ${file}${finding.message}`; }); return ( From dfc35f8020d1f08c66172c087c34ef8ac30f4967 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 21:37:12 +0000 Subject: [PATCH 147/226] sdg(phase-10): locate every participant of jointly-violated conditions SPEC 14 location cardinality (FIX_PLAN A3): - Duplicate IDs (14.3): one finding per duplicated identity locating every bearer, the first included, instead of one finding per repeated occurrence (mdx.ts validateStructure). - Import-binding collisions (14.15): one finding per collided identifier locating every colliding declaration, in xspec sources (spec-references.ts) and TypeScript files (code-analysis.ts) alike; the collision needs two distinct declarations. - Cycles (14.9): a dependency cycle locates its full path through every reference spelling recording a participating dependency edge (graph.ts records an edge-to-spellings map beside edge assembly; d = the entry's own expression, MDX embedding = the full braced container, 5.7); a spec import cycle locates every participating import declaration. The cycle path no longer travels as identities - it stays in the message. - A no-occurrence spelling of the MDX embedding form (14.6/14.8) is located by the full braced container, opening through closing brace - the span its occurrence would occupy (5.7). Also normalizes the raw NUL separator bytes in graph.ts edge keys to the escaped spelling (same value; keeps the file text for tooling). Verified: T14-8 passes; T7.5-2..6, T1.3-*, T2.1-*, T2.2-2.3, T2.4-*, T4-*, T5.1-5.3, T5.4-5.6, T3, T15, P1-P6 all pass. Remaining failures in sections 5.7/6.3/7.4-7.5/12-14 predate this change and belong to later FIX_PLAN tasks (A4 error document, Stage B surfaces, C-stage fixes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 31 ++++----- src/core/code-analysis.ts | 40 ++++++++---- src/core/graph.ts | 122 +++++++++++++++++++++++++++--------- src/core/mdx.ts | 37 +++++++---- src/core/spec-references.ts | 79 +++++++++++++++-------- 5 files changed, 207 insertions(+), 102 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index cbc6d93..f26ee54 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -40,26 +40,17 @@ order over both forms, used by `compareFindings`/`compareLocations`. Finding `locations[].file` and `path` are `PathText` now; Stage B surfaces reuse the same renderer for their own path members.) -### A3. Multi-location cardinality for jointly-violated conditions - -SPEC 14 (location-cardinality paragraph). Prereq A1. One finding carries a -location for every participating construct — no representative: - -- Condition 3 (duplicate ID): one finding per duplicated identity locating every - bearer (`src/core/spec-references.ts` / structure validation). -- Condition 15 import-binding collision: locate every colliding declaration. -- Condition 9 (cycle): locate the full path in source — a dependency cycle by - every reference spelling recording a participating dependency edge, a spec - import cycle by each participating import declaration — `path` null; the cycle - is locations now, not `identities` (replace A1's interim). Producer: - `src/core/graph.ts` cycle detection must map edges back to spellings. -- A reference spelling of the MDX embedding form that records no occurrence - (unknown target / invalid argument, 14.5–14.8): the finding's range is the - full braced container, opening brace through closing brace - (`SpecEmbedding.range` in `src/core/mdx.ts` already holds it) — not the inner - expression. - -Verify: T14-8 (`section-14.test.ts`), T7.5 arms. +(A3 landed: jointly-violated conditions carry every participant's location — +duplicate IDs one 14.3 finding per identity locating every bearer (`mdx.ts` +`validateStructure`), import-binding collisions one 14.15 locating every +colliding declaration (`spec-references.ts` `analyzeSpecImports`, +`code-analysis.ts` `scanModuleLinks`), cycles located through their full +in-source path with identities dropped — `graph.ts` keeps an `edgeSpellings` +map (requirement-side depends/embeds edge → its 5.7 spelling spans; `d` = +the entry's own expression, MDX embedding = the full braced container), +which B8's `refused-cycle` can reuse; import cycles locate every +participating import declaration. Embedding-form no-occurrence findings +(14.6, 14.8) span the full braced container, `SpecEmbedding.range`.) ### A4. Exit-2 JSON error document diff --git a/src/core/code-analysis.ts b/src/core/code-analysis.ts index 8fae1ea..7cffc16 100644 --- a/src/core/code-analysis.ts +++ b/src/core/code-analysis.ts @@ -501,8 +501,11 @@ class CodeAnalyzer { } } // SPEC 4/2.1 → 14.15: no import may bind an identifier already bound - // by another import, when either import is a spec module import; one - // finding per re-binding import, every colliding binding masked. + // by ANOTHER import, when either import is a spec module import — one + // condition the declarations jointly violate: ONE finding per collided + // identifier, locating every colliding declaration (SPEC 14 location + // cardinality; no representative chosen), every colliding binding + // masked. const byName = new Map<string, BoundName[]>(); for (const entry of bound) { const entries = byName.get(entry.name); @@ -510,17 +513,30 @@ class CodeAnalyzer { else entries.push(entry); } for (const [name, entries] of byName) { - if (entries.length < 2 || !entries.some((entry) => entry.spec)) continue; - for (const entry of entries.slice(1)) { - this.addFinding( - 15, - entry.statement, - `invalid import: the identifier ${JSON.stringify(name)} is ` + - `already bound by another import in this file — no two imports ` + - `may bind the same identifier when either is a spec module ` + - `import; rename one binding (SPEC 4, 2.1, 14.15)`, - ); + const statements: ts.Statement[] = []; + for (const entry of entries) { + if (!statements.includes(entry.statement)) { + statements.push(entry.statement); + } } + // The collision is between imports (SPEC 4: "already bound by + // another import"): it needs two distinct declarations. + if (statements.length < 2 || !entries.some((entry) => entry.spec)) { + continue; + } + this.findings.push( + locatedFinding( + 15, + `invalid import: the identifier ${JSON.stringify(name)} is bound ` + + `by ${String(statements.length)} imports in this file — no two ` + + `imports may bind the same identifier when either is a spec ` + + `module import; rename all but one binding (SPEC 4, 2.1, 14.15)`, + statements.map((declaration) => ({ + file: this.path, + range: this.rangeOf(declaration), + })), + ), + ); for (const entry of entries) { this.declarations.set(entry.declaration, { kind: "poisoned" }); } diff --git a/src/core/graph.ts b/src/core/graph.ts index 6514873..200d766 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -34,7 +34,7 @@ import { compareBytes, sortByBytes } from "./bytes.js"; import type { ByteRange } from "./bytes.js"; import type { CodeAnalysis } from "./code-analysis.js"; -import type { Finding } from "./findings.js"; +import type { Finding, FindingLocation } from "./findings.js"; import { compareFindings, locatedFinding } from "./findings.js"; import type { SpecDocument, SpecEmbedding, SpecSection } from "./mdx.js"; import type { @@ -365,7 +365,7 @@ export function buildWorkspaceGraph( source: string, target: string, ): void => { - const key = `${kind}�${source}�${target}`; + const key = `${kind}\u0000${source}\u0000${target}`; if (!edgeByKey.has(key)) edgeByKey.set(key, { kind, source, target }); }; @@ -385,6 +385,23 @@ export function buildWorkspaceGraph( const findings: Finding[] = []; const resolution = new Resolver(parsedByPath, requirementIndex, idIndex); + // The reference spellings behind each requirement-side dependency edge + // (SPEC 5.7 spans), keyed source → target: a cycle locates its full path + // in source through every spelling recording a participating edge + // (SPEC 14 location cardinality, 14.9). + const edgeSpellings = new Map<string, FindingLocation[]>(); + const addSpelling = ( + source: string, + target: string, + file: string, + range: ByteRange, + ): void => { + const key = `${source}\u0000${target}`; + let spellings = edgeSpellings.get(key); + if (spellings === undefined) edgeSpellings.set(key, (spellings = [])); + spellings.push({ file, range }); + }; + // SPEC 5.2/2.2: `depends` — declared by the `d` prop; unknown targets // are 14.5. for (const spec of specs) { @@ -410,6 +427,13 @@ export function buildWorkspaceGraph( const source = sectionIndex.get(dependency.section); if (source !== undefined) { addEdge("depends", source.identity, resolved.node.identity); + // SPEC 5.7: a `d` reference's spelling spans its own expression. + addSpelling( + source.identity, + resolved.node.identity, + spec.document.path, + dependency.reference.range, + ); } } } @@ -432,11 +456,14 @@ export function buildWorkspaceGraph( ); if (!resolved.ok) { embeddingIndex.set(embedded.embedding, null); + // SPEC 14: a no-occurrence spelling of the MDX embedding form is + // located by the full braced container, opening brace through + // closing brace — the span its occurrence would occupy (5.7). findings.push( unresolvedFinding( 6, spec.document.path, - embedded.reference.range, + embedded.embedding.range, `unknown text target: the text(...) reference to ` + `${resolution.describe(spec.document, embedded.reference.target)} ` + `does not resolve — ${resolved.reason}; declare the target ` + @@ -449,6 +476,14 @@ export function buildWorkspaceGraph( const source = sectionIndex.get(embedded.embedding.section); if (source !== undefined) { addEdge("embeds", source.identity, resolved.node.identity); + // SPEC 5.7: an MDX embedding's spelling spans the entire braced + // container, opening brace through closing brace. + addSpelling( + source.identity, + resolved.node.identity, + spec.document.path, + embedded.embedding.range, + ); } } } @@ -496,7 +531,12 @@ export function buildWorkspaceGraph( // --- cycles (SPEC 5.3, 2.1 → 14.9) --------------------------------------- findings.push( - ...dependencyCycleFindings(requirementNodes, requirementIndex, edges), + ...dependencyCycleFindings( + requirementNodes, + requirementIndex, + edges, + edgeSpellings, + ), ); findings.push(...importCycleFindings(specs, parsedByPath)); @@ -633,12 +673,16 @@ class Resolver { * `depends`, and `embeds` edges on requirement nodes (`references` edges * and code-sourced `embeds` edges have code-location sources and do not * participate). One 14.9 finding per cyclic strongly connected component, - * carrying a full cycle path within it. + * carrying a full cycle path within it — located in source through every + * reference spelling recording a participating dependency edge (SPEC 14 + * location cardinality; `contains` steps arise from document structure and + * spell nothing), the full identity path carried in the message. */ function dependencyCycleFindings( requirementNodes: readonly RequirementNode[], requirementIndex: ReadonlyMap<string, RequirementNode>, edges: readonly GraphEdge[], + edgeSpellings: ReadonlyMap<string, readonly FindingLocation[]>, ): Finding[] { const adjacency = new Map<string, Set<string>>(); for (const edge of edges) { @@ -656,17 +700,28 @@ function dependencyCycleFindings( if (start === undefined) { throw new Error("xspec internal error: cycle through an unknown node"); } - // SPEC 14.9/12.7: the full cycle path travels as identity context (a - // closed walk, first identity repeated at the end); the finding locates - // the cycle's starting section. + // SPEC 14/14.9: locate the cycle's full path in source — every + // reference spelling recording a participating dependency edge (a + // walk step covered only by `contains` contributes no spelling). + const locations: FindingLocation[] = []; + for (let step = 0; step + 1 < cycle.length; step += 1) { + const spellings = edgeSpellings.get( + `${cycle[step]}\u0000${cycle[step + 1]}`, + ); + if (spellings !== undefined) locations.push(...spellings); + } return locatedFinding( 9, `dependency cycle: ${cycle.join(" → ")} — the combined ` + `contains/depends/embeds graph over requirement nodes must be ` + `acyclic; break the cycle by removing or retargeting one of its ` + `depends or embeds references (SPEC 5.3, 14.9)`, - [{ file: start.path, range: start.section.range }], - cycle, + locations.length > 0 + ? locations + : // Unreachable in practice — `contains` alone cannot cycle — but + // a located condition must locate (SPEC 14): fall back to the + // cycle's starting section. + [{ file: start.path, range: start.section.range }], ); }); } @@ -676,8 +731,10 @@ function dependencyCycleFindings( * designated files, whether or not the bindings are used (an unused * import records no edges, but the import itself still relates the * files). A file importing itself is a cycle of length one. One 14.9 - * finding per cyclic component, locating the import that closes the - * reported cycle. + * finding per cyclic component, locating each participating import + * declaration — every import recording a step of the reported cycle + * (SPEC 14 location cardinality), the full file path carried in the + * message. */ function importCycleFindings( specs: readonly SpecFileAnalysis[], @@ -697,30 +754,35 @@ function importCycleFindings( const paths = specs.map((spec) => spec.document.path); const cycles = findCycles(paths, adjacency); return cycles.map((cycle) => { - // Locate the closing import: the first import of cycle[0] designating - // cycle[1] (for a self-import, cycle[1] === cycle[0]). - const spec = parsedByPath.get(cycle[0]); - const closing = spec?.imports.imports.find( - (declared) => declared.targetPath === cycle[1], - ); - // SPEC 14.9/12.7: the participating files travel as identity context; - // the finding locates the import that closes the reported cycle. + // SPEC 14/14.9: locate each participating import declaration — for + // every step of the closed walk, every import of the step's source + // file designating the step's target (for a self-import cycle, the + // self-designating imports). + const locations: FindingLocation[] = []; + for (let step = 0; step + 1 < cycle.length; step += 1) { + const spec = parsedByPath.get(cycle[step]); + if (spec === undefined) continue; + for (const declared of spec.imports.imports) { + if (declared.targetPath === cycle[step + 1]) { + locations.push({ + file: spec.document.path, + range: declared.statement.range, + }); + } + } + } return locatedFinding( 9, `spec import cycle: ${cycle.join(" → ")} — import cycles among ` + `spec source files are invalid, even when no requirement-level ` + `dependency cycle exists; remove one of the participating imports ` + `(SPEC 2.1, 14.9)`, - [ - { - file: cycle[0], - range: - closing !== undefined - ? closing.statement.range - : { start: 0, end: 0 }, - }, - ], - cycle, + locations.length > 0 + ? locations + : // Unreachable — every step of a reported import cycle came from + // a recorded import — but a located condition must locate + // (SPEC 14). + [{ file: cycle[0], range: { start: 0, end: 0 } }], ); }); } diff --git a/src/core/mdx.ts b/src/core/mdx.ts index 891887a..bbd7d40 100644 --- a/src/core/mdx.ts +++ b/src/core/mdx.ts @@ -1754,9 +1754,15 @@ class DocumentBuilder { * the immediate children of a section without a usable ID it is masked, * while their other conditions, and the check for their own children * (against their declared IDs), report normally. + * + * Location cardinality (SPEC 14): a duplicated ID is one condition the + * bearers jointly violate — ONE 14.3 finding per duplicated identity, + * carrying a location for every bearer, the first included; no + * representative is chosen. */ validateStructure(): void { - const seen = new Set<string>(); + /** Declared ID → the location of every bearer, in document order. */ + const bearers = new Map<string, ByteRange[]>(); for (const section of this.sections) { if (!section.idPresent) { // SPEC 1.3 → 14.1: a non-root section without `id`. @@ -1800,19 +1806,24 @@ class DocumentBuilder { ); } } - if (seen.has(section.id)) { - // SPEC 1.3 → 14.3: IDs unique within a source file; reported at - // each repeated occurrence. - this.addFinding( + const locations = bearers.get(section.id); + if (locations === undefined) bearers.set(section.id, [location]); + else locations.push(location); + } + for (const [id, locations] of bearers) { + if (locations.length < 2) continue; + // SPEC 1.3 → 14.3: IDs unique within a source file. One finding per + // duplicated identity, locating every bearer (SPEC 14 cardinality). + this.findings.push( + locatedFinding( 3, - location, - `duplicate ID ${JSON.stringify(section.id)}: IDs must be unique ` + - `within a source file — rename one of the sections ` + - `(SPEC 1.3, 14.3)`, - ); - } else { - seen.add(section.id); - } + `duplicate ID ${JSON.stringify(id)}: ` + + `${String(locations.length)} sections bear this ID — IDs must ` + + `be unique within a source file; rename all but one of the ` + + `sections (SPEC 1.3, 14.3)`, + locations.map((range) => ({ file: this.path, range })), + ), + ); } } diff --git a/src/core/spec-references.ts b/src/core/spec-references.ts index 85c7ec2..15bb991 100644 --- a/src/core/spec-references.ts +++ b/src/core/spec-references.ts @@ -177,9 +177,10 @@ function boundIdentifiers(clause: ts.ImportClause | undefined): string[] { * 7.1): an import must designate one of them — whether the designated * file parses does not matter here (references through it report as * unresolved, SPEC 14.20, 14.5–14.7). Each invalid import yields exactly - * one 14.15 finding listing its defects; identifiers bound by two - * imports yield one 14.15 per re-binding import (SPEC 2.1: no two - * imports in a file may bind the same identifier). + * one 14.15 finding listing its defects; an identifier bound by more + * than one import (SPEC 2.1: no two imports in a file may bind the same + * identifier) yields ONE 14.15 finding locating every colliding + * declaration, the first included (SPEC 14 location cardinality). */ export function analyzeSpecImports( document: SpecDocument, @@ -188,8 +189,8 @@ export function analyzeSpecImports( const imports: SpecImport[] = []; const bindings = new Map<string, SpecImportBinding>(); const findings: Finding[] = []; - /** name → whether any import already bound it (duplicate rule). */ - const seenNames = new Set<string>(); + /** name → the distinct import declarations binding it (duplicate rule). */ + const declarationsByName = new Map<string, SpecImportStatement[]>(); for (const block of document.esmBlocks) { for (const statement of block.imports) { @@ -303,28 +304,22 @@ export function analyzeSpecImports( targetPath = null; } - // SPEC 2.1: no two imports in a file may bind the same identifier. + // SPEC 2.1: no two imports in a file may bind the same identifier — + // declarations are recorded here and the collision judged once every + // declaration is seen (SPEC 14 cardinality: one finding locating + // every colliding declaration). for (const name of names) { - if (seenNames.has(name)) { - findings.push( - locatedFinding( - 15, - `invalid import: the identifier ${JSON.stringify(name)} is ` + - `already bound by another import in this file — no two ` + - `imports in an xspec source file may bind the same ` + - `identifier; rename one binding (SPEC 2.1, 14.15)`, - [{ file: document.path, range: statement.range }], - ), - ); - bindings.set(name, { kind: "poisoned" }); - } else { - seenNames.add(name); + const declared = declarationsByName.get(name); + if (declared === undefined) { + declarationsByName.set(name, [statement]); bindings.set( name, valid && targetPath !== null && name === clause?.name?.text ? { kind: "module", targetPath } : { kind: "poisoned" }, ); + } else if (!declared.includes(statement)) { + declared.push(statement); } } @@ -345,6 +340,26 @@ export function analyzeSpecImports( } } + // SPEC 2.1 → 14.15: an identifier bound by more than one import is one + // condition the declarations jointly violate — ONE finding per collided + // identifier, locating every colliding declaration (SPEC 14: no + // representative is chosen); the identifier's binding is poisoned, so + // references rooted at it are masked (SPEC 14). + for (const [name, declared] of declarationsByName) { + if (declared.length < 2) continue; + findings.push( + locatedFinding( + 15, + `invalid import: the identifier ${JSON.stringify(name)} is bound ` + + `by ${String(declared.length)} imports in this file — no two ` + + `imports in an xspec source file may bind the same identifier; ` + + `rename all but one binding (SPEC 2.1, 14.15)`, + declared.map((decl) => ({ file: document.path, range: decl.range })), + ), + ); + bindings.set(name, { kind: "poisoned" }); + } + return { imports, bindings, @@ -654,11 +669,11 @@ class ReferenceAnalyzer { embedding.expressionRange.start, ); if (ts.isSpreadElement(argument)) { + // SPEC 14: a no-occurrence spelling of the MDX embedding form is + // located by the full braced container (the span its occurrence + // would occupy, 5.7). this.addFinding( - translate.range({ - start: argument.getStart(sourceFile), - end: argument.getEnd(), - }), + embedding.range, `invalid argument: a spread element is not a static reference ` + `(SPEC 2.3, 2.4, 14.8)`, ); @@ -670,6 +685,9 @@ class ReferenceAnalyzer { `the text(...) argument must be a static string literal naming a ` + `same-file ID or a static property chain rooted at an imported ` + `spec module (SPEC 2.3, 2.4, 14.8)`, + // SPEC 14: an embedding-form finding's range is the full braced + // container — the span its occurrence would occupy (5.7). + embedding.range, ); if (resolved.outcome === "reference") { return resolved.reference; @@ -680,11 +698,18 @@ class ReferenceAnalyzer { return null; } - /** Turn one classification into a reference, a 14.8, or a mask. */ + /** + * Turn one classification into a reference, a 14.8, or a mask. + * `containerRange` — set for a `text(...)` embedding argument — is the + * embedding's full braced container: an embedding-form finding's range + * is that container, the span its occurrence would occupy (SPEC 14, + * 5.7); a `d` reference's finding keeps its own expression's span. + */ private resolveClassified( classified: ClassifiedReference, translate: SpanTranslator, expectation: string, + containerRange: ByteRange | null = null, ): ResolvedReference { if (classified.kind === "dynamic") { return { @@ -695,7 +720,7 @@ class ReferenceAnalyzer { [ { file: this.document.path, - range: translate.range(classified.span), + range: containerRange ?? translate.range(classified.span), }, ], ), @@ -721,7 +746,7 @@ class ReferenceAnalyzer { [ { file: this.document.path, - range: translate.range(classified.span), + range: containerRange ?? translate.range(classified.span), }, ], ), From ec820b464a54e13f4ccf042c4227ff3345cdd285 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 22:11:22 +0000 Subject: [PATCH 148/226] sdg(phase-10): emit the exit-2 JSON error document with anchored config paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC 12.0/12.7/14: with JSON output in effect — --json among the arguments (even when the arguments are themselves the error) or a JSON-only surface (query, review export; CommandSpec.jsonOnly carries the marking for the Stage B registrations) — an exit-2 usage or configuration error emits {"error": <finding form>} as its entire stdout. Plain usage errors carry code/path null; configuration errors carry code configuration-error and the concerned path in the 11.6 anchoring form relative to the invocation cwd (the found or --config-named file, or "." for a failed upward search), one merged finding however many defects. The anchoring spelling is the shared helper src/workspace/anchor.ts (inventory reuses it in B7); stderr diagnostics and exit codes are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 38 +++------- src/cli/args.ts | 115 ++++++++++++++++++++++++----- src/cli/commands/build.ts | 11 ++- src/cli/commands/check.ts | 11 ++- src/cli/commands/common.ts | 27 ++++--- src/cli/commands/coverage.ts | 4 +- src/cli/commands/ids.ts | 4 +- src/cli/commands/impact.ts | 2 +- src/cli/commands/move.ts | 37 +++++++--- src/cli/commands/query-core.ts | 19 ++--- src/cli/commands/query-fast.ts | 2 +- src/cli/commands/query.ts | 2 +- src/cli/commands/rename.ts | 25 +++++-- src/cli/commands/review-mutate.ts | 8 +- src/cli/commands/review-session.ts | 10 +-- src/cli/commands/review.ts | 24 ++++-- src/cli/commands/show.ts | 2 +- src/cli/io.ts | 12 +++ src/cli/main.ts | 38 +++++++--- src/cli/prepare.ts | 11 ++- src/cli/report.ts | 73 ++++++++++++++++-- src/core/config.ts | 6 +- src/workspace/anchor.ts | 40 ++++++++++ src/workspace/config.ts | 27 +++++-- src/workspace/locate.ts | 49 ++++++++++-- 25 files changed, 457 insertions(+), 140 deletions(-) create mode 100644 src/workspace/anchor.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index f26ee54..6fcdb7d 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -52,32 +52,18 @@ which B8's `refused-cycle` can reuse; import cycles locate every participating import declaration. Embedding-form no-occurrence findings (14.6, 14.8) span the full braced container, `SpecEmbedding.range`.) -### A4. Exit-2 JSON error document - -SPEC 12.0 (JSON-in-effect rule), 12.7 (error document), 14.14. Prereq A1. Today -every exit-2 outcome leaves stdout empty (`src/cli/main.ts` usage path; -`emitConfigurationErrors` in `src/cli/report.ts` writes stderr only). Required: - -- JSON output is in effect exactly when `--json` appears among the invocation's - arguments — even when the arguments are themselves the error: unknown command, - unknown flag, malformed value — or when the invoked surface is JSON-only - (`query`, `occurrences`, `view`, `at`, `inventory` (11), `version` (12.6), - `review export` (10.7)). When in effect, an exit-2 usage or configuration - error emits as its entire stdout the single document `{"error": <finding - form>}` (one five-member finding form, 12.7). Stderr diagnostics and exit - codes are unchanged; without JSON in effect stdout stays empty. -- Plain usage error: `code` and `path` null (no stable code — 14). -- Configuration error (14.14): `code` `"configuration-error"`; `path` is the - concerned path in the anchoring form of 11.6, relative to the invocation - working directory — the configuration file the upward search found or - `--config` named (canonical `..`-ascend/descend `/`-joined spelling), or `.` - when the upward search failed with no `--config`. One invocation reports one - error: a configuration file with several defects is a single condition-14 - finding with a deterministic message. Implement the cwd→path anchoring - spelling as a shared helper (`inventory`, task B7, reuses it). - -Verify: T12.0-1..6 arms (`section-12.0*.test.ts`), ~25 tests across sections -7–13 that exercise exit-2 under `--json`. +(A4 landed: the exit-2 JSON error document. The 11.6 anchoring spelling is +the shared helper `anchoredPathSpelling` in `src/workspace/anchor.ts` (B7's +inventory reuses it for `root`/`config`); `LocatedWorkspace`/`LoadedWorkspace` +carry `configAnchor`, the concerned path of every condition-14 finding. +JSON-in-effect is `jsonOutputInEffect(invocation)` in `src/cli/args.ts` — +`--json` or a `CommandSpec.jsonOnly` surface; Stage B registrations +(`version`, `occurrences`, `view`, `at`, `inventory`) must set +`jsonOnly: true` and force `{...invocation, json: true}` for their exit-1 +reports as `query`/`review export` do. `usageError(invocation, io, message)` +in `src/cli/commands/common.ts` and `emitConfigurationErrors(io, jsonInEffect, +configAnchor, findings)` in `src/cli/report.ts` are the exit-2 choke points — +route every new exit-2 outcome through them.) ### A5. Review-operation refusals report as code-less findings diff --git a/src/cli/args.ts b/src/cli/args.ts index 507cc41..72be1e0 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -25,13 +25,15 @@ // - Argument values are interpreted as UTF-8; a value that is not valid // UTF-8 is a usage error (12.0). // -// Every parse failure is a usage error: exit 2, diagnostic on stderr, and an -// empty standard output — under `--json` the exit-2 error prevents emitting -// the single JSON document (12.0), and no report is defined for exit-2 -// outcomes in human form either. Diagnostics echo only argv tokens and static -// text, never resolved filesystem paths, keeping all output byte-deterministic -// for identical input (12.0: no absolute paths, no environment-dependent -// content). +// Every parse failure is a usage error: exit 2 with the diagnostic on +// stderr. Standard output is empty unless JSON output is in effect — +// `--json` among the arguments (even when the arguments are themselves the +// error) or a JSON-only surface — in which case the exit-2 error emits the +// 12.7 error document as the entire standard output (12.0); the parse +// result carries that determination for the caller. Diagnostics echo only +// argv tokens and static text, never resolved filesystem paths, keeping all +// output byte-deterministic for identical input (12.0: no absolute paths, +// no environment-dependent content). /** One flag a command accepts, and how its value (if any) is validated. */ interface FlagSpec { @@ -68,6 +70,13 @@ interface CommandSpec { * none or more than one is a usage error (12.0). */ readonly exactlyOneOf?: readonly (readonly string[])[]; + /** + * SPEC 12.0: the surface is JSON-only — a single JSON document is its + * only output form, with or without `--json` (10.7 `review export`, 11, + * 12.6), so JSON output is in effect for every invocation of it, its + * exit-2 errors included (the 12.7 error document). + */ + readonly jsonOnly?: boolean; } /** SPEC 12.0: every command supports `--json` and `--config <path>` (7). */ @@ -188,12 +197,15 @@ const COMMANDS: readonly CommandSpec[] = [ TEST_HOLD_FLAG, ], }, - { path: "review export", positionals: ["<name>"], flags: [] }, - // SPEC 11: the six query subcommands. - { path: "query node", positionals: ["<node>"], flags: [] }, + // SPEC 10.7: `export` is JSON-only — the entire session as a single JSON + // document, its only output form with or without `--json` (12.0). + { path: "review export", positionals: ["<name>"], flags: [], jsonOnly: true }, + // SPEC 11: the six query subcommands — JSON-only surfaces (12.0). + { path: "query node", positionals: ["<node>"], flags: [], jsonOnly: true }, { path: "query nodes", positionals: [], + jsonOnly: true, flags: [ { name: "--group", takesValue: true, valueName: "<g>" }, { name: "--file", takesValue: true, valueName: "<glob>" }, @@ -210,6 +222,7 @@ const COMMANDS: readonly CommandSpec[] = [ { path: "query edges", positionals: [], + jsonOnly: true, flags: [ { name: "--from", takesValue: true, valueName: "<graph-node>" }, { name: "--to", takesValue: true, valueName: "<graph-node>" }, @@ -222,11 +235,17 @@ const COMMANDS: readonly CommandSpec[] = [ }, ], }, - { path: "query subtree", positionals: ["<node>"], flags: [] }, - { path: "query ancestors", positionals: ["<node>"], flags: [] }, + { path: "query subtree", positionals: ["<node>"], flags: [], jsonOnly: true }, + { + path: "query ancestors", + positionals: ["<node>"], + flags: [], + jsonOnly: true, + }, { path: "query reachable", positionals: [], + jsonOnly: true, flags: [ { name: "--from", @@ -264,6 +283,11 @@ export const COMMAND_PATHS: readonly string[] = COMMANDS.map( (spec) => spec.path, ); +/** The dispatch keys of the JSON-only surfaces (SPEC 12.0; 10.7, 11, 12.6). */ +const JSON_ONLY_PATHS: ReadonlySet<string> = new Set( + COMMANDS.filter((spec) => spec.jsonOnly === true).map((spec) => spec.path), +); + /** A parsed flag value: boolean presence, one value, or a `--kinds` list. */ export type FlagValue = true | string | readonly string[]; @@ -287,10 +311,23 @@ export interface Invocation { export type ParseResult = | { readonly ok: true; readonly invocation: Invocation } - | { readonly ok: false; readonly message: string }; + | { + readonly ok: false; + /** The diagnostic, without the `xspec: ` program prefix. */ + readonly message: string; + /** + * SPEC 12.0: whether JSON output is in effect for the failed + * invocation — `--json` appears among the arguments (even when the + * arguments are themselves the error), or the invoked surface, as + * far as the arguments identify one, is JSON-only. Governs error + * delivery: with it, the exit-2 error emits the 12.7 error document + * as the entire standard output. + */ + readonly jsonInEffect: boolean; + }; -function usageError(message: string): ParseResult { - return { ok: false, message: `xspec: ${message}` }; +function usageError(message: string, jsonInEffect: boolean): ParseResult { + return { ok: false, message, jsonInEffect }; } /** @@ -365,10 +402,21 @@ const TABLE = buildTable(); /** * Parse one invocation's argv (the elements after the executable name) * against the SPEC 12.0 conventions and the SPEC 12.5 command table. Returns - * the parsed invocation, or the usage-error diagnostic the caller must write - * to stderr before exiting 2 (12.0). + * the parsed invocation, or the usage-error failure the caller reports + * before exiting 2 (12.0): the diagnostic for stderr (the caller prefixes + * the program name) and whether JSON output is in effect — with it, the + * caller emits the 12.7 error document as the entire standard output. */ export function parseArgv(argv: readonly string[]): ParseResult { + // SPEC 12.0: `--json` among the invocation's arguments puts JSON output + // in effect even when the arguments are themselves the error — the parse + // may fail before every token's role is assigned, so the presence scan + // is literal over the argument vector — and a JSON-only surface puts it + // in effect regardless, as soon as the arguments identify one. + const jsonToken = argv.includes("--json"); + let jsonOnlySurface = false; + const inEffect = (): boolean => jsonToken || jsonOnlySurface; + // SPEC 12.0: argument values are interpreted as UTF-8, and a value that is // not valid UTF-8 is a usage error. Checked per token below, because the // `move` command's positionals are exempt (SPEC 6.5: a destination path @@ -379,11 +427,13 @@ export function parseArgv(argv: readonly string[]): ParseResult { usageError( `argument ${String(indexInArgv + 1)} is not valid UTF-8 — argument ` + `values are interpreted as UTF-8`, + inEffect(), ); if (argv.length === 0) { return usageError( `missing command (expected one of: ${commandNameList()})`, + inEffect(), ); } const commandToken = argv[0]!; @@ -394,6 +444,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { return usageError( `expected a command before any flags (expected one of: ` + `${commandNameList()})`, + inEffect(), ); } const entry = TABLE.get(commandToken); @@ -401,17 +452,24 @@ export function parseArgv(argv: readonly string[]): ParseResult { return usageError( `unknown command '${commandToken}' (expected one of: ` + `${commandNameList()})`, + inEffect(), ); } let spec: CommandSpec; let tokens: readonly string[]; if (entry instanceof Map) { + // SPEC 12.0: a command group all of whose subcommands are JSON-only + // (`query`, 11) is a JSON-only surface already at the group name. + jsonOnlySurface = [...entry.values()].every( + (subcommand) => subcommand.jsonOnly === true, + ); const subToken = argv.length > 1 ? argv[1]! : undefined; if (subToken === undefined || subToken.startsWith("--")) { return usageError( `${commandToken}: missing subcommand (expected one of: ` + `${subcommandNameList(entry)})`, + inEffect(), ); } if (!isValidUtf8ArgumentValue(subToken)) { @@ -422,6 +480,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { return usageError( `${commandToken}: unknown subcommand '${subToken}' (expected one ` + `of: ${subcommandNameList(entry)})`, + inEffect(), ); } spec = subcommand; @@ -430,6 +489,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { spec = entry; tokens = argv.slice(1); } + jsonOnlySurface = spec.jsonOnly === true; const flagSpecs = new Map<string, FlagSpec>(); for (const flag of GLOBAL_FLAGS) flagSpecs.set(flag.name, flag); @@ -465,7 +525,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { const flag = flagSpecs.get(token); if (flag === undefined) { // SPEC 12.0: unknown flags are usage errors. - return usageError(`${spec.path}: unknown flag '${token}'`); + return usageError(`${spec.path}: unknown flag '${token}'`, inEffect()); } // SPEC 12.0: a flag may be given at most once per invocation; repeating a // flag is a usage error — identical values included. @@ -473,6 +533,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { return usageError( `${spec.path}: flag '${token}' given more than once — a flag may be ` + `given at most once per invocation`, + inEffect(), ); } seen.add(token); @@ -486,6 +547,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { return usageError( `${spec.path}: flag '${token}' requires a value` + (flag.valueName === undefined ? "" : ` ${flag.valueName}`), + inEffect(), ); } const value = tokens[index]!; @@ -501,6 +563,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { return usageError( `${spec.path}: invalid value '${value}' for '${token}' — one ` + `comma-separated list of: ${flag.list.join(", ")}`, + inEffect(), ); } } @@ -512,6 +575,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { return usageError( `${spec.path}: invalid value '${value}' for '${token}' (expected ` + `one of: ${flag.allowed.join(", ")})`, + inEffect(), ); } if (token === "--config") config = value; @@ -524,6 +588,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { return usageError( `${spec.path}: missing required flag '${flag.name}'` + (flag.valueName === undefined ? "" : ` ${flag.valueName}`), + inEffect(), ); } } @@ -534,6 +599,7 @@ export function parseArgv(argv: readonly string[]): ParseResult { return usageError( `${spec.path}: exactly one of ${group.join(", ")} is required` + (given.length === 0 ? "" : ` (got ${given.join(" and ")})`), + inEffect(), ); } } @@ -544,12 +610,14 @@ export function parseArgv(argv: readonly string[]): ParseResult { return usageError( `${spec.path}: missing required argument ` + `${spec.positionals[positionals.length]!}`, + inEffect(), ); } if (positionals.length > spec.positionals.length) { return usageError( `${spec.path}: unexpected argument ` + `'${positionals[spec.positionals.length]!}'`, + inEffect(), ); } @@ -586,6 +654,17 @@ export function flagPresent(invocation: Invocation, name: string): boolean { return true; } +/** + * SPEC 12.0: whether JSON output is in effect for a parsed invocation — + * `--json` appears among its arguments, or the invoked surface is + * JSON-only, a single JSON document its only output form with or without + * `--json` (10.7 `review export`, 11, 12.6). Governs the whole output + * form, the exit-2 error document included (12.7). + */ +export function jsonOutputInEffect(invocation: Invocation): boolean { + return invocation.json || JSON_ONLY_PATHS.has(invocation.command); +} + /** The elements of a list-valued flag, or undefined when it was not given. */ export function flagList( invocation: Invocation, diff --git a/src/cli/commands/build.ts b/src/cli/commands/build.ts index 4017478..0bee64f 100644 --- a/src/cli/commands/build.ts +++ b/src/cli/commands/build.ts @@ -24,6 +24,7 @@ import { } from "../../workspace/pipeline.js"; import { symlinkWritePathFindings } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; +import { jsonOutputInEffect } from "../args.js"; import type { CommandContext } from "../io.js"; import { emitConfigurationErrors, emitFindingsReport } from "../report.js"; @@ -38,9 +39,15 @@ export async function buildCommand( // SPEC 14.14/12.0: a discovery-level configuration error (a file matched // by both a spec and a code group, 7.2) is a usage error preceding all // source analysis — exit 2, diagnostics on standard error, nothing - // modified, and with `--json` an empty standard output. + // modified, and with JSON output in effect the 12.7 error document as + // the entire standard output. if (analysis.configurationErrors.length > 0) { - emitConfigurationErrors(context.stderr, analysis.configurationErrors); + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + workspace.configAnchor, + analysis.configurationErrors, + ); return 2; } diff --git a/src/cli/commands/check.ts b/src/cli/commands/check.ts index 96961c0..4a2623c 100644 --- a/src/cli/commands/check.ts +++ b/src/cli/commands/check.ts @@ -37,6 +37,7 @@ import { import { loadAllSessions } from "../../workspace/reviews.js"; import { symlinkWritePathFindings } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; +import { jsonOutputInEffect } from "../args.js"; import type { CommandContext } from "../io.js"; import { emitConfigurationErrors, emitFindingsReport } from "../report.js"; @@ -50,9 +51,15 @@ export async function checkCommand( // SPEC 14.14/12.0: a discovery-level configuration error is a usage error // preceding all source analysis — exit 2, diagnostics on standard error, - // and with `--json` an empty standard output. + // and with JSON output in effect the 12.7 error document on standard + // output. if (analysis.configurationErrors.length > 0) { - emitConfigurationErrors(context.stderr, analysis.configurationErrors); + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + workspace.configAnchor, + analysis.configurationErrors, + ); return 2; } diff --git a/src/cli/commands/common.ts b/src/cli/commands/common.ts index 37ca7a1..0dd9d36 100644 --- a/src/cli/commands/common.ts +++ b/src/cli/commands/common.ts @@ -17,22 +17,31 @@ import { } from "../../core/text.js"; import type { TestHoldSpec } from "../../workspace/lock.js"; import type { Invocation } from "../args.js"; -import { flagValue } from "../args.js"; -import type { CliWriter } from "../io.js"; +import { flagValue, jsonOutputInEffect } from "../args.js"; +import type { CliWriter, CommandIo } from "../io.js"; +import { emitErrorDocument, usageErrorFinding } from "../report.js"; /** * SPEC 12.0: usage errors — unknown identities, unknown groups, invalid - * flag values — exit 2 with the diagnostic on standard error and nothing on - * standard output (the exit-2 error prevents emitting the single JSON - * document). Diagnostics echo argv tokens and static text only, keeping - * output byte-deterministic (SPEC 12.0). + * flag values — exit 2 with the diagnostic on standard error. With JSON + * output in effect (`--json` among the arguments, or a JSON-only surface), + * the 12.7 error document — `{"error": …}` holding one code-less, + * path-less finding form — is the entire standard output; without it, + * standard output stays empty. Diagnostics echo argv tokens and static + * text only, keeping output byte-deterministic (SPEC 12.0). */ export function usageError( - stderr: CliWriter, - command: string, + invocation: Invocation, + io: CommandIo, message: string, ): 2 { - stderr.write(`xspec: ${command}: ${message}\n`); + io.stderr.write(`xspec: ${invocation.command}: ${message}\n`); + if (jsonOutputInEffect(invocation)) { + emitErrorDocument( + io.stdout, + usageErrorFinding(`${invocation.command}: ${message}`), + ); + } return 2; } diff --git a/src/cli/commands/coverage.ts b/src/cli/commands/coverage.ts index db6dbb7..5075a74 100644 --- a/src/cli/commands/coverage.ts +++ b/src/cli/commands/coverage.ts @@ -101,8 +101,8 @@ export async function coverageCommand( ); if (named === undefined) { return usageError( - stderr, - invocation.command, + invocation, + context, `unknown profile '${name}' — no configured coverage profile has ` + `that name (SPEC 8.2, 7.4, 12.0)`, ); diff --git a/src/cli/commands/ids.ts b/src/cli/commands/ids.ts index 92a4e90..dd376c9 100644 --- a/src/cli/commands/ids.ts +++ b/src/cli/commands/ids.ts @@ -179,8 +179,8 @@ export async function idsCommand( if (!compiled.ok) { // Plain mode has one compile error: outside-root (SPEC 7). return usageError( - stderr, - invocation.command, + invocation, + context, `invalid value '${filePattern}' for '--file' — the pattern ` + `resolves outside the workspace root (SPEC 12.3, 7, 12.0)`, ); diff --git a/src/cli/commands/impact.ts b/src/cli/commands/impact.ts index e4b6f0d..ae111a2 100644 --- a/src/cli/commands/impact.ts +++ b/src/cli/commands/impact.ts @@ -134,7 +134,7 @@ export async function impactCommand( // current sources also fail build validation. const resolution = await resolveBaseline(context.workspace, ref); if (!resolution.ok) { - return usageError(context.stderr, invocation.command, resolution.message); + return usageError(invocation, context, resolution.message); } const { baseline } = resolution; diff --git a/src/cli/commands/move.ts b/src/cli/commands/move.ts index 1a5f31f..4274572 100644 --- a/src/cli/commands/move.ts +++ b/src/cli/commands/move.ts @@ -98,7 +98,7 @@ import { writeSourceFile, } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; -import { isValidUtf8ArgumentValue } from "../args.js"; +import { isValidUtf8ArgumentValue, jsonOutputInEffect } from "../args.js"; import type { CliWriter, CommandContext } from "../io.js"; import { emitConfigurationErrors, emitFindingsReport } from "../report.js"; import { requirementIdProblem, testHoldSpecOf, usageError } from "./common.js"; @@ -415,8 +415,8 @@ async function runMove( // A section origin with a bare-file destination matches neither form // (SPEC 6.5): a malformed invocation, a usage error (12.0). return usageError( - stderr, - invocation.command, + invocation, + context, `'${destinationArg}' names no target section — the forms are ` + `\`move <old-file> <new-file>\` and \`move <file>#<id> ` + `<target-file>#<new-id>\` (SPEC 6.5)`, @@ -428,7 +428,12 @@ async function runMove( // SPEC 14.14/12.0: configuration errors precede all source analysis — // usage class, exit 2, diagnostics on standard error, nothing modified. if (analysis.configurationErrors.length > 0) { - emitConfigurationErrors(stderr, analysis.configurationErrors); + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + workspace.configAnchor, + analysis.configurationErrors, + ); return 2; } @@ -439,8 +444,8 @@ async function runMove( !analysis.classification.specSources.some((s) => s.path === origin.file) ) { return usageError( - stderr, - invocation.command, + invocation, + context, `unknown file '${origin.file}' — the origin must name a discovered ` + `source file of a configured spec group, workspace-relative ` + `(SPEC 6.5, 12.0)`, @@ -466,8 +471,8 @@ async function runMove( ); if (section === undefined) { return usageError( - stderr, - invocation.command, + invocation, + context, `unknown ID '${origin.id}' in '${origin.file}' — <id> must name an ` + `existing requirement ID of that file (SPEC 6.5, 12.0)`, ); @@ -553,7 +558,12 @@ async function runMoveFile( // Unreachable: the destination was validated against the same group // rules discovery applies. Guarded so a regression reports rather than // corrupts. - emitConfigurationErrors(stderr, rewritten.configurationErrors); + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + workspace.configAnchor, + rewritten.configurationErrors, + ); return 2; } if (rewritten.findings.length > 0) { @@ -855,7 +865,12 @@ async function runMoveSection( // Unreachable: the configuration is untouched and a created target was // validated against the same group rules discovery applies. Guarded so // a regression reports rather than corrupts. - emitConfigurationErrors(stderr, rewritten.configurationErrors); + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + workspace.configAnchor, + rewritten.configurationErrors, + ); return 2; } if (rewritten.findings.length > 0) { @@ -988,7 +1003,7 @@ export async function moveCommand( () => runMove(invocation, context, originArg, destinationArg), ); if (!outcome.ok) { - return usageError(context.stderr, invocation.command, outcome.usageMessage); + return usageError(invocation, context, outcome.usageMessage); } return outcome.value; } diff --git a/src/cli/commands/query-core.ts b/src/cli/commands/query-core.ts index 3c11f00..8e707a3 100644 --- a/src/cli/commands/query-core.ts +++ b/src/cli/commands/query-core.ts @@ -25,7 +25,7 @@ import type { ExitCode } from "../../core/findings.js"; import { shortestWitnessPath } from "../../core/paths.js"; import type { Invocation } from "../args.js"; import { flagList, flagValue } from "../args.js"; -import type { CliWriter } from "../io.js"; +import type { CliWriter, CommandIo } from "../io.js"; import { emitDocument, rangeJson, usageError } from "./common.js"; /** One requirement node as the query subcommands consume it (SPEC 11). */ @@ -352,7 +352,7 @@ function kindSet( export function prevalidateQuery( invocation: Invocation, groups: GroupsView, - stderr: CliWriter, + io: CommandIo, ): { readonly ok: true } | { readonly ok: false; readonly exit: ExitCode } { if (invocation.command !== "query nodes") { return { ok: true }; @@ -361,7 +361,7 @@ export function prevalidateQuery( if (!resolved.ok) { return { ok: false, - exit: usageError(stderr, invocation.command, resolved.message), + exit: usageError(invocation, io, resolved.message), }; } return { ok: true }; @@ -380,11 +380,12 @@ export function answerQuery( stdout: CliWriter, stderr: CliWriter, ): ExitCode { + const io: CommandIo = { stdout, stderr }; switch (invocation.command) { case "query node": { const resolved = resolveRow(view, invocation.positionals[0]); if (!resolved.ok) { - return usageError(stderr, invocation.command, resolved.message); + return usageError(invocation, io, resolved.message); } return emitDocument(stdout, nodeReportOf(view, resolved.row)); } @@ -393,7 +394,7 @@ export function answerQuery( if (!resolved.ok) { // Unreachable after prevalidateQuery; kept total so the answering // is correct standalone. - return usageError(stderr, invocation.command, resolved.message); + return usageError(invocation, io, resolved.message); } const filters = resolved.filters; // SPEC 11/12.0: deterministic order — the graph's requirement-node @@ -416,7 +417,7 @@ export function answerQuery( } const message = unknownGraphNodeMessage(view, flag, raw); if (message !== null) { - return usageError(stderr, invocation.command, message); + return usageError(invocation, io, message); } } const edges = view.edges.filter( @@ -430,7 +431,7 @@ export function answerQuery( case "query subtree": { const resolved = resolveRow(view, invocation.positionals[0]); if (!resolved.ok) { - return usageError(stderr, invocation.command, resolved.message); + return usageError(invocation, io, resolved.message); } return emitDocument( stdout, @@ -440,7 +441,7 @@ export function answerQuery( case "query ancestors": { const resolved = resolveRow(view, invocation.positionals[0]); if (!resolved.ok) { - return usageError(stderr, invocation.command, resolved.message); + return usageError(invocation, io, resolved.message); } return emitDocument( stdout, @@ -464,7 +465,7 @@ export function answerQuery( ] as const) { const message = unknownGraphNodeMessage(view, flag, raw); if (message !== null) { - return usageError(stderr, invocation.command, message); + return usageError(invocation, io, message); } } const adjacency = new Map<string, Set<string>>(); diff --git a/src/cli/commands/query-fast.ts b/src/cli/commands/query-fast.ts index 27be7c5..a55ff80 100644 --- a/src/cli/commands/query-fast.ts +++ b/src/cli/commands/query-fast.ts @@ -75,7 +75,7 @@ export async function tryFastQuery( return null; } const groups = groupsViewOfConfiguration(verified.configuration); - const prevalidated = prevalidateQuery(invocation, groups, stderr); + const prevalidated = prevalidateQuery(invocation, groups, { stdout, stderr }); if (!prevalidated.ok) { return prevalidated.exit; } diff --git a/src/cli/commands/query.ts b/src/cli/commands/query.ts index ec5b1d5..1e524b1 100644 --- a/src/cli/commands/query.ts +++ b/src/cli/commands/query.ts @@ -29,7 +29,7 @@ export async function queryCommand( // SPEC 11: configuration-level flag validation precedes source analysis, // like its 14.14 counterparts (query-core.ts). - const prevalidated = prevalidateQuery(invocation, groups, stderr); + const prevalidated = prevalidateQuery(invocation, groups, context); if (!prevalidated.ok) { return prevalidated.exit; } diff --git a/src/cli/commands/rename.ts b/src/cli/commands/rename.ts index b996157..b617449 100644 --- a/src/cli/commands/rename.ts +++ b/src/cli/commands/rename.ts @@ -66,6 +66,7 @@ import { writeSourceFile, } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; +import { jsonOutputInEffect } from "../args.js"; import type { CliWriter, CommandContext } from "../io.js"; import { emitConfigurationErrors, emitFindingsReport } from "../report.js"; import { requirementIdProblem, testHoldSpecOf, usageError } from "./common.js"; @@ -158,7 +159,12 @@ async function runRename( // SPEC 14.14/12.0: configuration errors precede all source analysis — // usage class, exit 2, diagnostics on standard error, nothing modified. if (analysis.configurationErrors.length > 0) { - emitConfigurationErrors(stderr, analysis.configurationErrors); + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + workspace.configAnchor, + analysis.configurationErrors, + ); return 2; } @@ -167,8 +173,8 @@ async function runRename( // (workspace-relative, SPEC 12.0, 1.5; byte-wise comparison). if (!analysis.classification.specSources.some((s) => s.path === file)) { return usageError( - stderr, - invocation.command, + invocation, + context, `unknown file '${file}' — <file> must name a discovered source file ` + `of a configured spec group, workspace-relative (SPEC 6.4, 12.0)`, ); @@ -187,8 +193,8 @@ async function runRename( const section = origin.document.sections.find((s) => s.id === oldId); if (section === undefined) { return usageError( - stderr, - invocation.command, + invocation, + context, `unknown ID '${oldId}' in '${file}' — <old-id> must name an existing ` + `requirement ID of that file (SPEC 6.4, 12.0)`, ); @@ -247,7 +253,12 @@ async function runRename( if (rewritten.configurationErrors.length > 0) { // Unreachable: the configuration and file set are unchanged. Guarded so // a regression reports rather than corrupts. - emitConfigurationErrors(stderr, rewritten.configurationErrors); + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + workspace.configAnchor, + rewritten.configurationErrors, + ); return 2; } if (rewritten.findings.length > 0) { @@ -355,7 +366,7 @@ export async function renameCommand( () => runRename(invocation, context, file, oldId, newId), ); if (!outcome.ok) { - return usageError(context.stderr, invocation.command, outcome.usageMessage); + return usageError(invocation, context, outcome.usageMessage); } return outcome.value; } diff --git a/src/cli/commands/review-mutate.ts b/src/cli/commands/review-mutate.ts index e2991b6..76211b0 100644 --- a/src/cli/commands/review-mutate.ts +++ b/src/cli/commands/review-mutate.ts @@ -65,8 +65,8 @@ function unknownItemError( itemId: string, ): ExitCode { return usageError( - context.stderr, - invocation.command, + invocation, + context, `unknown item '${itemId}' in session '${name}' — no item of the ` + `session has that id (SPEC 10.7, 12.0)`, ); @@ -204,7 +204,7 @@ export async function reviewSplitCommand( () => runSplit(invocation, context, name, itemId), ); if (!outcome.ok) { - return usageError(context.stderr, invocation.command, outcome.usageMessage); + return usageError(invocation, context, outcome.usageMessage); } return outcome.value; } @@ -328,7 +328,7 @@ export async function reviewResolveCommand( ), ); if (!outcome.ok) { - return usageError(context.stderr, invocation.command, outcome.usageMessage); + return usageError(invocation, context, outcome.usageMessage); } return outcome.value; } diff --git a/src/cli/commands/review-session.ts b/src/cli/commands/review-session.ts index d888173..1ef227e 100644 --- a/src/cli/commands/review-session.ts +++ b/src/cli/commands/review-session.ts @@ -393,8 +393,8 @@ export async function loadSessionForCommand( return { ok: false, exit: usageError( - context.stderr, - invocation.command, + invocation, + context, `the recorded baseline of session '${name}' cannot be ` + `reconstructed: ${resolution.message}`, ), @@ -427,7 +427,7 @@ export function requireValidSessionName( if (problem === null) { return null; } - return usageError(context.stderr, invocation.command, problem); + return usageError(invocation, context, problem); } /** SPEC 10.7 → 12.0: an unknown session named in arguments. */ @@ -437,8 +437,8 @@ export function unknownSessionError( context: CommandContext, ): ExitCode { return usageError( - context.stderr, - invocation.command, + invocation, + context, `unknown session '${name}' — no session file ` + `.xspec/reviews/${name}.json exists; session names compare byte-wise ` + `and case-sensitively (SPEC 10.1, 10.7, 12.0)`, diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index 62d5979..028b92d 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -94,7 +94,7 @@ async function runCreate( // SPEC 10.1 → 12.0: an invalid session name is a usage error. const nameProblem = sessionNameProblem(name); if (nameProblem !== null) { - return usageError(stderr, invocation.command, nameProblem); + return usageError(invocation, context, nameProblem); } // SPEC 10.7: exactly one of `--base`, `--strategy audit`, `--coverage` @@ -115,8 +115,8 @@ async function runCreate( ); if (profile === undefined) { return usageError( - stderr, - invocation.command, + invocation, + context, `unknown profile '${profileName}' — no configured coverage profile ` + `has that name (SPEC 10.7, 7.4, 12.0)`, ); @@ -131,7 +131,7 @@ async function runCreate( // even when the current sources also fail build validation. const resolution = await resolveBaseline(workspace, baseRef); if (!resolution.ok) { - return usageError(stderr, invocation.command, resolution.message); + return usageError(invocation, context, resolution.message); } baseline = resolution.baseline; // SPEC 10.7: a baseline session records the commit identity `--base` @@ -272,7 +272,7 @@ export async function reviewCreateCommand( () => runCreate(invocation, context, name), ); if (!outcome.ok) { - return usageError(context.stderr, invocation.command, outcome.usageMessage); + return usageError(invocation, context, outcome.usageMessage); } return outcome.value; } @@ -457,8 +457,8 @@ export async function reviewShowCommand( // SPEC 10.7 → 12.0: an unknown item ID in any `review` command's // arguments is a usage error. return usageError( - context.stderr, - invocation.command, + invocation, + context, `unknown item '${itemId}' in session '${name}' — no item of the ` + `session has that id (SPEC 10.7, 12.0)`, ); @@ -487,7 +487,15 @@ export async function reviewExportCommand( // Unreachable: the parser enforces the positional (SPEC 10.7). throw new Error("xspec internal error: review export without <name>"); } - const opened = await openSessionForRead(name, invocation, context); + // SPEC 10.7/12.0: `export` is a JSON-only surface — a single JSON + // document is its only output form, with or without `--json`, the + // findings report of a failed gate or a corrupt session included — so + // every report path runs with JSON output forced on (as `query` does). + const opened = await openSessionForRead( + name, + { ...invocation, json: true }, + context, + ); if (!opened.ok) { return opened.exit; } diff --git a/src/cli/commands/show.ts b/src/cli/commands/show.ts index dfa01cc..e65ebe5 100644 --- a/src/cli/commands/show.ts +++ b/src/cli/commands/show.ts @@ -88,7 +88,7 @@ export async function showCommand( const resolved = resolveRow(view, invocation.positionals[0]); if (!resolved.ok) { - return usageError(stderr, invocation.command, resolved.message); + return usageError(invocation, context, resolved.message); } if (invocation.json) { // SPEC 12.4/11: the machine form is `query node`'s document exactly. diff --git a/src/cli/io.ts b/src/cli/io.ts index a50f4be..fbb3162 100644 --- a/src/cli/io.ts +++ b/src/cli/io.ts @@ -17,6 +17,18 @@ export interface CliWriter { import type { LoadedWorkspace } from "../workspace/config.js"; +/** + * The two output streams of one invocation (SPEC 12.0): the report goes to + * standard output, diagnostics to standard error. Exit-2 emitters take this + * pair — the stderr diagnostic always, and with JSON output in effect the + * 12.7 error document as the entire standard output. `CommandContext` + * satisfies it structurally. + */ +export interface CommandIo { + readonly stdout: CliWriter; + readonly stderr: CliWriter; +} + /** Per-invocation context handed to command handlers. */ export interface CommandContext { /** diff --git a/src/cli/main.ts b/src/cli/main.ts index 64f125d..67da0a5 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -21,10 +21,14 @@ import type { ExitCode } from "../core/findings.js"; import { locateWorkspace } from "../workspace/locate.js"; import type { Invocation } from "./args.js"; -import { COMMAND_PATHS, parseArgv } from "./args.js"; +import { COMMAND_PATHS, jsonOutputInEffect, parseArgv } from "./args.js"; import { tryFastQuery } from "./commands/query-fast.js"; import type { CliWriter, CommandContext } from "./io.js"; -import { emitConfigurationErrors } from "./report.js"; +import { + emitConfigurationErrors, + emitErrorDocument, + usageErrorFinding, +} from "./report.js"; /** One command's implementation, dispatched by `Invocation.command`. */ export type CommandHandler = ( @@ -180,9 +184,15 @@ export async function main( if (!result.ok) { // SPEC 12.0: usage errors — unknown commands or flags, missing required // flags or arguments, invalid flag values, repeated flags, non-UTF-8 - // argument values — exit 2 with the diagnostic on standard error and - // nothing on standard output. - stderr.write(`${result.message}\n`); + // argument values — exit 2 with the diagnostic on standard error. With + // JSON output in effect (`--json` among the arguments even when the + // arguments are themselves the error, or a JSON-only surface), the + // 12.7 error document — one code-less, path-less finding — is the + // entire standard output; otherwise standard output stays empty. + stderr.write(`xspec: ${result.message}\n`); + if (result.jsonInEffect) { + emitErrorDocument(stdout, usageErrorFinding(result.message)); + } return 2; } const loadHandler = HANDLERS.get(result.invocation.command); @@ -197,11 +207,16 @@ export async function main( // upward search from the working directory, or the `--config <path>` // value resolved against it (12.0). A missing or invalid configuration // is a configuration error, reported as a usage error (exit 2) preceding - // all source analysis; with `--json`, the exit-2 error prevents emitting - // the single JSON document, so standard output stays empty (12.0). + // all source analysis — with JSON output in effect, the 12.7 error + // document as the entire standard output (12.0). const location = await locateWorkspace(cwd, result.invocation.config); if (!location.ok) { - emitConfigurationErrors(stderr, location.findings); + emitConfigurationErrors( + { stdout, stderr }, + jsonOutputInEffect(result.invocation), + location.configAnchor, + location.findings, + ); return 2; } @@ -226,7 +241,12 @@ export async function main( const { parseLocatedWorkspace } = await import("../workspace/config.js"); const loaded = parseLocatedWorkspace(location.located); if (!loaded.ok) { - emitConfigurationErrors(stderr, loaded.findings); + emitConfigurationErrors( + { stdout, stderr }, + jsonOutputInEffect(result.invocation), + location.located.configAnchor, + loaded.findings, + ); return 2; } const handler = await loadHandler(); diff --git a/src/cli/prepare.ts b/src/cli/prepare.ts index d2c8c5d..58e7e00 100644 --- a/src/cli/prepare.ts +++ b/src/cli/prepare.ts @@ -10,7 +10,8 @@ // output (with `--json`, the single JSON document), exit 1, nothing // answered, nothing modified; // - configuration errors (SPEC 14.14) — usage class: diagnostics on -// standard error, exit 2, and with `--json` an empty standard output. +// standard error, exit 2, and with JSON output in effect the 12.7 error +// document as the entire standard output (12.0). // // `check` must not use this: it never refreshes (SPEC 13.3, 14.10). @@ -19,6 +20,7 @@ import type { GraphData } from "../core/graph-data.js"; import type { WorkspaceAnalysis } from "../workspace/pipeline.js"; import { prepareWorkspaceForRead } from "../workspace/refresh.js"; import type { Invocation } from "./args.js"; +import { jsonOutputInEffect } from "./args.js"; import type { CommandContext } from "./io.js"; import { emitConfigurationErrors, emitFindingsReport } from "./report.js"; @@ -51,7 +53,12 @@ export async function prepareGraphForRead( const prepared = await prepareWorkspaceForRead(context.workspace); switch (prepared.kind) { case "configuration": - emitConfigurationErrors(context.stderr, prepared.errors); + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + context.workspace.configAnchor, + prepared.errors, + ); return { ok: false, exit: 2 }; case "findings": emitFindingsReport(invocation.json, context.stdout, prepared.findings); diff --git a/src/cli/report.ts b/src/cli/report.ts index cbb66a5..117f704 100644 --- a/src/cli/report.ts +++ b/src/cli/report.ts @@ -25,7 +25,7 @@ import type { JsonObject, JsonValue } from "../core/canonical-json.js"; import type { Finding, FindingLocation } from "../core/findings.js"; import { orderFindings } from "../core/findings.js"; import { pathTextJson, renderPathText } from "../core/path-text.js"; -import type { CliWriter } from "./io.js"; +import type { CliWriter, CommandIo } from "./io.js"; /** * A location as human text: `FILE:START-END` — the file through the shared @@ -124,6 +124,56 @@ export function emitFindingsReport( ); } +/** + * A plain usage error as the finding form of SPEC 12.7: `code` and `path` + * null — SPEC 14 assigns usage errors no stable code and no concerned + * workspace path (they describe the invocation the consuming tool itself + * composed) — locations and identities empty, the diagnostic as the + * message. + */ +export function usageErrorFinding(message: string): Finding { + return { code: null, message, locations: [], path: null, identities: [] }; +} + +/** + * The exit-2 error document of SPEC 12.0/12.7 — `{"error": …}` holding one + * finding form — as the entire standard output. Emitted exactly when JSON + * output is in effect (`--json` among the arguments, or a JSON-only + * surface); the caller writes the stderr diagnostics and exits 2 either + * way. + */ +export function emitErrorDocument(stdout: CliWriter, finding: Finding): void { + const document: JsonValue = { error: findingToJson(finding) }; + stdout.write(canonicalJson(document)); +} + +/** + * The one condition-14 finding of an exit-2 configuration error (SPEC 12.7: + * "One invocation reports one error" — a configuration file with several + * distinct defects is a single finding, its message deterministic but + * otherwise unpinned). The concerned path is the configuration file in the + * anchoring form of 11.6, relative to the invocation working directory, or + * `.` for a failed upward search with no `--config` (SPEC 14); locations + * stay empty — a configuration error is an unlocated condition (SPEC 14). + */ +export function configurationErrorFinding( + findings: readonly Finding[], + configAnchor: string, +): Finding { + // The per-defect messages joined in the pinned findings order (SPEC 12.7) + // keep the merged message deterministic (SPEC 12.0). + const message = orderFindings(findings) + .map((finding) => finding.message) + .join("; "); + return { + code: "configuration-error", + message, + locations: [], + path: configAnchor, + identities: [], + }; +} + /** * SPEC 12.0/14.14: render one configuration-error finding as a diagnostic * line. Configuration errors are usage errors: the message is @@ -137,16 +187,25 @@ export function renderConfigurationError(finding: Finding): string { /** * Report configuration errors (SPEC 14.14) the way every command must: each - * as a standard-error diagnostic line. The caller exits 2. (The exit-2 JSON - * error document of 12.0/12.7 — the standard-output half when JSON output - * is in effect — is emitted by the caller's error path, not here; stderr - * diagnostics are identical either way.) + * defect as a standard-error diagnostic line and, when JSON output is in + * effect, the exit-2 error document of 12.0/12.7 as the entire standard + * output — one finding however many defects, its concerned path the + * anchored configuration path (SPEC 14). The caller exits 2; stderr + * diagnostics are identical whatever the output form (SPEC 12.0). */ export function emitConfigurationErrors( - stderr: CliWriter, + io: CommandIo, + jsonInEffect: boolean, + configAnchor: string, findings: readonly Finding[], ): void { for (const finding of findings) { - stderr.write(renderConfigurationError(finding)); + io.stderr.write(renderConfigurationError(finding)); + } + if (jsonInEffect) { + emitErrorDocument( + io.stdout, + configurationErrorFinding(findings, configAnchor), + ); } } diff --git a/src/core/config.ts b/src/core/config.ts index 7cc75cb..0c158df 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1349,8 +1349,10 @@ function validateSchema( /** * Parse and validate configuration text (SPEC 7). `fileName` names the file - * in findings (its base name — never an absolute path, SPEC 12.0). The text - * is analyzed statically and never executed or imported (IMPLEMENTATION). + * in the findings' concerned-path member (the caller's label — the anchored + * spelling of SPEC 14 for the current configuration; never an + * environment-dependent absolute path, SPEC 12.0). The text is analyzed + * statically and never executed or imported (IMPLEMENTATION). */ export function parseConfiguration( text: string, diff --git a/src/workspace/anchor.ts b/src/workspace/anchor.ts new file mode 100644 index 0000000..8abaa4c --- /dev/null +++ b/src/workspace/anchor.ts @@ -0,0 +1,40 @@ +// The invocation-anchored path spelling of SPEC 11.6 — shared by every +// output that identifies a file relative to the invocation working +// directory: configuration-error concerned paths (SPEC 14) and the +// inventory's `root`/`config` anchoring (SPEC 11.6). +// +// SPEC 11.6: the spelling is canonical — the segments ascending from the +// working directory to the nearest common ancestor, each spelled `..`, then +// the segments descending to the identified file or directory, joined with +// `/` on every platform; no `.` segments, no trailing separator; the +// working directory itself spelled `.`. Only when the platform admits no +// relative path between the two (roots on different Windows drives) is the +// anchoring the platform's absolute drive-qualified form — the sole +// absolute-path case and the sole output spelling whose separator is the +// platform's (SPEC 12.0). The result is a pure function of the invocation +// input (SPEC 12.0: invocation-anchored content, deterministic per +// invocation). + +import * as path from "node:path"; + +/** + * Spell `target` relative to the invocation working directory `cwd` in the + * canonical anchoring form of SPEC 11.6. Both arguments are filesystem + * paths; relative ones resolve against the process semantics of + * `path.resolve` (callers pass absolute paths in practice). + */ +export function anchoredPathSpelling(cwd: string, target: string): string { + const from = path.resolve(cwd); + const to = path.resolve(target); + const relative = path.relative(from, to); + // The working directory itself is spelled `.` (SPEC 11.6). + if (relative === "") return "."; + // SPEC 11.6: where the platform admits no relative path (different + // Windows drives), `path.relative` yields the target's absolute form — + // reported drive-qualified in the platform's own spelling. + if (path.isAbsolute(relative)) return to; + // `path.relative` is exactly the `..`-ascend-then-descend segment walk of + // SPEC 11.6, in the platform's separator; the canonical spelling joins + // the segments with `/` on every platform. + return relative.split(path.sep).join("/"); +} diff --git a/src/workspace/config.ts b/src/workspace/config.ts index 4e47b8d..5a334ad 100644 --- a/src/workspace/config.ts +++ b/src/workspace/config.ts @@ -8,9 +8,12 @@ // // IMPLEMENTATION (Architecture): this workspace-layer module owns the I/O — // locating and reading the file; parsing and validation are the pure core's -// (src/core/config.ts). Diagnostics never carry absolute paths (SPEC 12.0): -// findings name the configuration file by its base name, and the `--config` -// value is echoed as given. +// (src/core/config.ts). Findings name the configuration file by its +// anchored spelling relative to the invocation working directory (SPEC 14: +// a configuration error's concerned path is the 11.6 anchoring form) — a +// pure function of invocation input, never an environment-dependent +// absolute path (SPEC 12.0; the Windows drive-mismatch case of 11.6 is the +// sole absolute form). // // This module statically imports the TypeScript-based parser, so it is // loaded on demand (cli/main.ts imports it dynamically): the store-backed @@ -36,8 +39,14 @@ export interface LoadedWorkspace { * file's directory (SPEC 7). Never rendered into output (SPEC 12.0). */ readonly root: string; - /** The configuration file's base name, for diagnostics. */ + /** The configuration file's base name, for workspace-relative reads. */ readonly configFileName: string; + /** + * The configuration file in the anchoring form of 11.6, relative to the + * invocation working directory — the concerned path of every + * configuration error this invocation reports (SPEC 14, 12.0). + */ + readonly configAnchor: string; /** * SHA-256 (hex) of the configuration file's exact bytes — the graph * data's recorded-parse key (SPEC 13.3; ./fast-read.ts). @@ -58,9 +67,12 @@ export type WorkspaceLoadResult = export function parseLocatedWorkspace( located: LocatedWorkspace, ): WorkspaceLoadResult { + // SPEC 14: the parse findings' concerned path is the configuration file + // in the anchoring form of 11.6, relative to the invocation working + // directory. const parsed = parseConfigurationBytes( located.configBytes, - located.configFileName, + located.configAnchor, ); if (!parsed.ok) { return { ok: false, findings: parsed.findings }; @@ -70,6 +82,7 @@ export function parseLocatedWorkspace( workspace: { root: located.root, configFileName: located.configFileName, + configAnchor: located.configAnchor, configHash: sha256Hex(located.configBytes), configuration: parsed.configuration, }, @@ -96,7 +109,9 @@ export async function loadWorkspace( * Decode and parse a configuration file's exact bytes (SPEC 7, 14.14) — the * I/O-free tail of `loadWorkspace`, shared with baseline reconstruction * (SPEC 6.3), which reads the configuration content as it stood at a git - * ref instead of from the filesystem. + * ref instead of from the filesystem. `configFileName` labels the file in + * the findings' concerned-path member: the current configuration passes its + * anchored spelling (SPEC 14), the baseline its tree-relative name. */ export function parseConfigurationBytes( bytes: Uint8Array, diff --git a/src/workspace/locate.ts b/src/workspace/locate.ts index 6ce24aa..840fc25 100644 --- a/src/workspace/locate.ts +++ b/src/workspace/locate.ts @@ -9,6 +9,14 @@ // configuration error (14.14), reported by every command as a usage error // (exit 2, 12.0) preceding all source analysis. // +// SPEC 14: a configuration error's concerned path is reported in the +// anchoring form of 11.6, identified relative to the invocation working +// directory — the configuration file the upward search found or `--config` +// named, or `.` for a failed upward search with no `--config`. This module +// computes that spelling once (./anchor.ts) and hands it to every consumer: +// the located workspace carries it for later parse and discovery errors, +// and a locate failure's findings carry it directly. +// // The store-backed read fast path (./fast-read.ts) starts from this // module's result: with the configuration file's exact bytes in hand, a // stored parse recorded under the same content hash substitutes for @@ -19,6 +27,7 @@ import * as fsp from "node:fs/promises"; import * as path from "node:path"; import type { Finding } from "../core/findings.js"; import { pathFinding } from "../core/findings.js"; +import { anchoredPathSpelling } from "./anchor.js"; /** SPEC 7: the configuration file name the upward search looks for. */ export const CONFIG_FILE_NAME = "xspec.config.ts"; @@ -30,18 +39,39 @@ export interface LocatedWorkspace { * file's directory (SPEC 7). Never rendered into output (SPEC 12.0). */ readonly root: string; - /** The configuration file's base name, for diagnostics. */ + /** The configuration file's base name, for workspace-relative reads. */ readonly configFileName: string; + /** + * The configuration file in the anchoring form of 11.6, relative to the + * invocation working directory (SPEC 14: a configuration error's + * concerned path) — a pure function of invocation input (SPEC 12.0). + */ + readonly configAnchor: string; /** The configuration file's exact bytes. */ readonly configBytes: Uint8Array; } export type WorkspaceLocateResult = | { readonly ok: true; readonly located: LocatedWorkspace } - | { readonly ok: false; readonly findings: readonly Finding[] }; + | { + readonly ok: false; + readonly findings: readonly Finding[]; + /** + * SPEC 14: the concerned path of the failure in the 11.6 anchoring + * form — the `--config`-named file, the found-but-unreadable file, or + * `.` for a failed upward search with no `--config`. + */ + readonly configAnchor: string; + }; -function failure(message: string, file?: string): WorkspaceLocateResult { - return { ok: false, findings: [pathFinding(14, message, file ?? null)] }; +function failure(message: string, configAnchor: string): WorkspaceLocateResult { + // SPEC 14: configuration errors carry the file or path they concern — + // the anchored configuration path (or `.`) — with no in-source location. + return { + ok: false, + findings: [pathFinding(14, message, configAnchor)], + configAnchor, + }; } /** Whether a plain-stat of the path reaches a regular file. */ @@ -85,31 +115,39 @@ export async function locateWorkspace( configPath = path.resolve(cwd, configFlag); configFileName = path.basename(configPath); if (!(await isFile(configPath))) { + // SPEC 14: missing configuration WITH `--config` given concerns the + // named file (never `.` — that is the failed upward search's case). return failure( `--config ${configFlag}: no configuration file exists at this ` + `path, resolved against the working directory (SPEC 7, 12.0)`, + anchoredPathSpelling(cwd, configPath), ); } } else { const found = await searchUpward(path.resolve(cwd)); if (found === undefined) { + // SPEC 14: a failed upward search with no `--config` concerns the + // directory it started from — the invocation working directory, + // spelled `.` (11.6). return failure( `no ${CONFIG_FILE_NAME} found by upward search from the working ` + `directory — create one in the project root or pass --config ` + `<path> (SPEC 7)`, + ".", ); } configPath = found; configFileName = CONFIG_FILE_NAME; } + const configAnchor = anchoredPathSpelling(cwd, configPath); let bytes: Uint8Array; try { bytes = await fsp.readFile(configPath); } catch { return failure( `the configuration file cannot be read (SPEC 7)`, - configFileName, + configAnchor, ); } return { @@ -117,6 +155,7 @@ export async function locateWorkspace( located: { root: path.dirname(configPath), configFileName, + configAnchor, configBytes: bytes, }, }; From 2c11bceb18867b69ea85d50bf771a292c38a8e24 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 22:28:22 +0000 Subject: [PATCH 149/226] sdg(phase-10): report review-operation refusals as code-less findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refused review operation (split on a wrong-kind or childless item, resolve on a blocked item, create with an existing or case-colliding name) now reports the findings-only document {"findings": [...]} with one finding — code null (SPEC 14: review-operation refusals carry no stable code), locations empty, path null, identities the session name (+ item id / colliding name, informational) — exit 1, nothing modified, human form through the shared findings renderer (SPEC 10.7, 12.0, 12.7). Plan: A5 removed (verified: section-10.2-10.3, 10.7-i all green; the 10.7-ii and 10.1 refusal arms green — remaining reds there are C6's T10.7-7/12 and the newly-diagnosed T10.1-5); C2 extended with the corrupt-session-behind-the-gate ordering (T10.1-5, observed 14.21 reported in the gate's place); new task B2a added for parse-local validation inside invalid-path (14.19) sources (T12.7-1 arm E, observed the located 14.1 dropped with the file). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 73 ++++++++++++++++++++++++------ src/cli/commands/review-mutate.ts | 20 ++++---- src/cli/commands/review-session.ts | 32 ++++++++----- src/cli/commands/review.ts | 9 +++- 4 files changed, 97 insertions(+), 37 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 6fcdb7d..a985040 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,18 +65,11 @@ in `src/cli/commands/common.ts` and `emitConfigurationErrors(io, jsonInEffect, configAnchor, findings)` in `src/cli/report.ts` are the exit-2 choke points — route every new exit-2 outcome through them.) -### A5. Review-operation refusals report as code-less findings - -SPEC 10.7, 12.7, 14 ("review-operation refusals likewise carry none"). Prereq -A1. `emitReviewRefusal` (`src/cli/commands/review-session.ts:789`) emits -`{"refused": {"command", "message"}}` / ad-hoc text. Required: a refused review -operation (`split` on a wrong-kind or childless item, `resolve` on a blocked -item, `create` with an existing name) is a findings-class outcome whose report -is `{"findings": [...]}` — one finding, `code` null, `locations` empty, -`identities` per 14 (informational), exit 1, nothing modified; human form the -same information. - -Verify: T10.3-1, T10.7 refusal arms (`section-10*.test.ts`). +(A5 landed: review-operation refusals are code-less findings — +`emitReviewRefusal(json, stdout, message, identities)` in +`src/cli/commands/review-session.ts` emits `{"findings": [...]}` through +`emitFindingsReport`, one finding with `code`/`path` null, `locations` empty, +`identities` the session name (+ item id / colliding name) — informational.) ### A6. `rename`/`move` success report is the applied mapping @@ -114,6 +107,46 @@ exits 2 unknown-command today. Add the command and handler (`src/cli/main.ts`): Verify: T12.6-1/2 (`section-12.6.test.ts`), T12.0-9 arm; unblocks the Windows CI leg's leading edge (E-6). +### B2a. Invalid-path sources enter per-file analysis (parse-local findings) + +(Positioned before B2/B4/B5 deliberately — they consume this parse.) SPEC 11.2 +("Structure is parse-local": a discovered file whose own path is invalid +(14.19) keeps its parse-local structure, its located findings, and its +occurrence positions — while no identity over an invalid path is ever emitted +or resolved against), 14 (only an unparseable file (14.20) masks its content; +14.19 does not — `build`/`check` "MUST report each" condition), 12.0/12.7 +(marked byte-form paths in every output). Today `SourceClassification` +(`src/core/discovery.ts` ~63: "A file with any finding here is no source: it +appears in neither list") drops every 14.19 file before parsing, so located +conditions inside such files are never reported. Observed: T12.7-1 arm E +(`runBytePathsArm`, `test/suite/registry/section-12.7.ts`) stages a non-UTF-8 +directory `specs/d<0xFF>/` holding `In.mdx` (valid imports of `../OK.xspec` +and `./Tgt.xspec`, a resolving `{text(OK.ok)}` embedding, an id-less `<S>`) +plus `Tgt.mdx` (path-only defect): `build --json` must report exactly +`14.1 x1` (located in `In.mdx`, its location `file` the marked byte form) and +`14.19 x2` (each concerned path marked) — the product reports `14.19 x2` only. +Required: + +- Discovery keeps 14.19 files visible to analysis (condition-19 findings + intact) and core analysis parses and per-file validates them per 11.2: + their located findings report from `build`/`check` beside the 14.19, + location files rendered through `pathTextOf` (A2). +- No identity of such a file is defined (11.2, 1.5): no graph nodes, no + resolution *into* the file (references to it stay unresolved), no journal + or derived-file interaction — `build` on such a workspace fails on the + findings and modifies nothing (12.1). A valid import designating such a + file is no finding (arm E: `./Tgt.xspec` reports nothing), and constructs + inside it are judged on their own terms. +- Reference spellings inside such files whose targets resolve record + occurrences with the source datum explicitly unavailable (11.2, 5.7) — + the recording itself lands with B2 (its "explicitly unavailable when 11.2 + leaves the containing node's identity undefined" datum); B4/B5 then answer + for these files from this same per-file analysis (an invalid-path file + keeps its view, B5). + +Verify: T12.7-1 arm E's `build --json` leg (`section-12.7.test.ts` — its +`occurrences`/`view`/`inventory` legs additionally need B2/B4/B5/B7). + ### B2. Record reference occurrences in core analysis and graph data SPEC 5.7, 13.3, 12.7 (occurrence record form), 11.2 (existence/source-datum @@ -419,7 +452,21 @@ behind the gate (judged against session content, which gated commands do not read there). Files: gate sequencing in `src/workspace/pipeline.ts` and the command handlers under `src/cli/commands/`. -Verify: T12.0-10 (`section-12.0*.test.ts`). +The corrupt-session report is likewise gated (SPEC 14.21: reported "only on a +workspace passing `build`'s validations — on a failing one the gate's findings +are reported without any session being read"): `loadSessionForCommand` +(`src/cli/commands/review-session.ts`) currently reports the 14.21 corruption +before the refresh, so `review status <corrupt>` on a failing workspace emits +`corrupt-session` instead of the gate's findings (observed: T10.1-5 expects +`14.1 x1`, got `14.21 x1`). Required order there: session-name validity and +existence (exit 2, judged against the directory) still precede the gate; the +corruption *report* moves behind it — gate failing → gate findings alone, exit +1; gate passing → the 14.21 finding as today. Recorded-baseline resolution +(6.3, exit 2 before source validation) applies only to a readable session; +a corrupt one has no readable parameters — the corruption (or, failing +workspace, the gate) reports instead. `review list` already gates first. + +Verify: T12.0-10 (`section-12.0*.test.ts`), T10.1-5 (`section-10.1.test.ts`). ### C3. Obstructed write path: any non-directory component, refused before modifying diff --git a/src/cli/commands/review-mutate.ts b/src/cli/commands/review-mutate.ts index 76211b0..192bcd0 100644 --- a/src/cli/commands/review-mutate.ts +++ b/src/cli/commands/review-mutate.ts @@ -153,13 +153,12 @@ async function runSplit( baseline: generation.baseline, }); if (!split.ok) { - // SPEC 10.7: the refusal — exit 1, nothing modified. - return emitReviewRefusal( - invocation.json, - context.stdout, - invocation.command, - split.refusal, - ); + // SPEC 10.7/14: the refusal — one code-less finding, exit 1, nothing + // modified; the identities name the session and item (informational). + return emitReviewRefusal(invocation.json, context.stdout, split.refusal, [ + name, + itemId, + ]); } // The write re-records the journal's entry count as the session's // write-moment bound (core/review.ts identity policy: every stored @@ -246,15 +245,16 @@ async function runResolve( return unknownItemError(invocation, context, name, itemId); } if (view.blocked.get(item.id) ?? false) { - // SPEC 10.7: resolving a blocked item is refused — exit 1, nothing - // modified. Any *unblocked* item is resolvable regardless of status. + // SPEC 10.7/14: resolving a blocked item is refused — one code-less + // finding, exit 1, nothing modified. Any *unblocked* item is resolvable + // regardless of status. return emitReviewRefusal( invocation.json, context.stdout, - invocation.command, `item '${itemId}' of session '${name}' is blocked — an item is ` + `blocked while any item in its blockedBy is not resolved, and ` + `resolving a blocked item is refused (SPEC 10.3, 10.7)`, + [name, itemId], ); } // SPEC 10.7/10.4: set the status, record the current relevant state; an diff --git a/src/cli/commands/review-session.ts b/src/cli/commands/review-session.ts index 1ef227e..c485ebe 100644 --- a/src/cli/commands/review-session.ts +++ b/src/cli/commands/review-session.ts @@ -44,9 +44,8 @@ import { generateAuditItems } from "../../core/audit.js"; import type { JsonObject } from "../../core/canonical-json.js"; -import { canonicalJson } from "../../core/canonical-json.js"; import { generateCoverageSessionItems } from "../../core/coverage-session.js"; -import type { ExitCode } from "../../core/findings.js"; +import type { ExitCode, Finding } from "../../core/findings.js"; import { generatePathBlocksItems } from "../../core/path-blocks.js"; import type { ItemKind, @@ -774,21 +773,30 @@ export function renderCountsHuman( // --------------------------------------------------------------------------- /** - * SPEC 10.7/12.0: a refused review operation is a findings-class outcome — - * exit 1, the refusal report on standard output (with `--json`, one JSON - * document as the entire standard output). + * SPEC 10.7/12.0/14: a refused review operation (`split` on a wrong-kind or + * childless item, `resolve` on a blocked item, `create` with an existing + * name) is a findings-class outcome — its report is the findings-only + * document `{"findings": […]}` (SPEC 12.7), one finding per refusal, and the + * human form presents the same information through the shared findings + * renderer (SPEC 12.0). Review-operation refusals carry no stable code + * (SPEC 14: "review-operation refusals likewise carry none"): `code` null, + * no in-source locations, no concerned path; `identities` carry the context + * strings the refusal names (session name, item id) — informational, + * deterministic per SPEC 12.7. Exit 1, nothing modified. */ export function emitReviewRefusal( json: boolean, stdout: CliWriter, - command: string, message: string, + identities: readonly string[], ): ExitCode { - if (json) { - // The canonical serializer keeps the document byte-deterministic. - stdout.write(canonicalJson({ refused: { command, message } })); - } else { - stdout.write(`${command} refused: ${message}\n`); - } + const finding: Finding = { + code: null, + message, + locations: [], + path: null, + identities, + }; + emitFindingsReport(json, stdout, [finding]); return 1; } diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index 028b92d..51f441a 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -165,12 +165,14 @@ async function runCreate( return 1; } if (occupant.state === "ok") { + // SPEC 10.7/14: one code-less finding, exit 1, nothing created; the + // identities name the session (informational). return emitReviewRefusal( invocation.json, stdout, - invocation.command, `a session named '${name}' already exists — \`review create\` with ` + `the name of an existing session is refused (SPEC 10.1, 10.7)`, + [name], ); } const collision = existingNameIgnoringAsciiCase( @@ -178,13 +180,16 @@ async function runCreate( name, ); if (collision !== null) { + // SPEC 10.1/10.7/14: one code-less finding, exit 1, nothing created; + // the identities name the requested and the colliding session + // (informational). return emitReviewRefusal( invocation.json, stdout, - invocation.command, `the name '${name}' matches the existing session '${collision}' ` + `ignoring ASCII case, so it is treated as the name of an existing ` + `session and refused (SPEC 10.1, 10.7)`, + [name, collision], ); } From d1b3bdd89bf3db0abd3d7b1d9a3a4a761414603a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 22:40:24 +0000 Subject: [PATCH 150/226] sdg(phase-10): report rename/move success as the applied mapping Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 19 ++++++------------- src/cli/commands/move.ts | 33 +++++++++++++++++++-------------- src/cli/commands/rename.ts | 23 ++++++++++++++--------- src/cli/report.ts | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 36 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index a985040..f9d715c 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -71,19 +71,12 @@ route every new exit-2 outcome through them.) `emitFindingsReport`, one finding with `code`/`path` null, `locations` empty, `identities` the session name (+ item id / colliding name) — informational.) -### A6. `rename`/`move` success report is the applied mapping - -SPEC 6.4 ("A successful rename's report is the applied mapping … the -information of the preview's `mapping` (6.6), carried in JSON per 12.0"), 6.5 -(move "reports its applied mapping, as rename does"). Today both emit -`{"findings":[]}` (JSON) / nothing (human) — verify run: "missing `mapping` key". -Report the complete identity mapping the operation journaled: JSON carries a -`mapping` member of `{"from", "to"}` entries ordered by `from` bytes (the -preview `mapping` form of 12.7); human output lists the same mapping. Files: -`src/cli/commands/rename.ts`, `src/cli/commands/move.ts`. - -Verify: T6.4-1, T6.5-1 success arms (`section-6.4.test.ts`, -`section-6.5*.test.ts`). +(A6 landed: a successful `rename`/`move` reports its applied mapping — +`emitAppliedMappingReport(json, stdout, mapping)` in `src/cli/report.ts`, +fed `plan.entry.mapping` (the journal entry's canonical `from`-byte order): +JSON `{"findings": [], "mapping": [{"from", "to"}...]}` — the preview +`mapping` member encoding of 12.7, which B9's success document can reuse — +human one `FROM -> TO` line per pair plus a count line.) --- diff --git a/src/cli/commands/move.ts b/src/cli/commands/move.ts index 4274572..be43e33 100644 --- a/src/cli/commands/move.ts +++ b/src/cli/commands/move.ts @@ -56,8 +56,10 @@ // modifying anything. // // Success writes the rewritten sources, removes the origin (file form), -// appends the journal entry, and regenerates; the report is the (empty) -// findings list — with `--json`, the single JSON document (SPEC 12.0). +// appends the journal entry, and regenerates; the report is the applied +// mapping — the complete identity mapping the operation journaled, the +// information of the preview's `mapping` (SPEC 6.5, 6.4, 6.6) — with +// `--json`, the single JSON document (SPEC 12.0). import * as path from "node:path"; import { computeBuildOutputs } from "../../core/build.js"; @@ -100,7 +102,11 @@ import { import type { Invocation } from "../args.js"; import { isValidUtf8ArgumentValue, jsonOutputInEffect } from "../args.js"; import type { CliWriter, CommandContext } from "../io.js"; -import { emitConfigurationErrors, emitFindingsReport } from "../report.js"; +import { + emitAppliedMappingReport, + emitConfigurationErrors, + emitFindingsReport, +} from "../report.js"; import { requirementIdProblem, testHoldSpecOf, usageError } from "./common.js"; /** @@ -616,12 +622,11 @@ async function runMoveFile( await appendJournalEntry(workspace.root, plan.entry); await executeBuildOutputs(workspace.root, outputs); - if (invocation.json) { - // SPEC 12.0: one JSON document as the entire standard output — the - // successful move's report is its (empty) findings list, as for - // `build` (SPEC 12.1) and `rename` (SPEC 6.4). - emitFindingsReport(true, stdout, []); - } + // SPEC 6.5/6.4/12.0: a successful move reports its applied mapping, as + // rename does — the complete identity mapping the operation journaled, in + // both output forms; the journal entry's mapping is that mapping in its + // canonical `from`-byte order. + emitAppliedMappingReport(invocation.json, stdout, plan.entry.mapping); return 0; } @@ -919,11 +924,11 @@ async function runMoveSection( await appendJournalEntry(workspace.root, plan.entry); await executeBuildOutputs(workspace.root, outputs); - if (invocation.json) { - // SPEC 12.0: one JSON document as the entire standard output — the - // successful move's report is its (empty) findings list. - emitFindingsReport(true, stdout, []); - } + // SPEC 6.5/6.4/12.0: a successful move reports its applied mapping, as + // rename does — the complete identity mapping the operation journaled, in + // both output forms; the journal entry's mapping is that mapping in its + // canonical `from`-byte order. + emitAppliedMappingReport(invocation.json, stdout, plan.entry.mapping); return 0; } diff --git a/src/cli/commands/rename.ts b/src/cli/commands/rename.ts index b617449..f63b2df 100644 --- a/src/cli/commands/rename.ts +++ b/src/cli/commands/rename.ts @@ -36,8 +36,10 @@ // modifying anything. // // Success writes the rewritten sources, appends the journal entry, and -// regenerates; the report is the (empty) findings list — with `--json`, the -// single JSON document (SPEC 12.0). +// regenerates; the report is the applied mapping — the complete identity +// mapping the operation journaled, the information of the preview's +// `mapping` (SPEC 6.4, 6.6) — with `--json`, the single JSON document +// (SPEC 12.0). import { computeBuildOutputs } from "../../core/build.js"; import { canonicalJson } from "../../core/canonical-json.js"; @@ -68,7 +70,11 @@ import { import type { Invocation } from "../args.js"; import { jsonOutputInEffect } from "../args.js"; import type { CliWriter, CommandContext } from "../io.js"; -import { emitConfigurationErrors, emitFindingsReport } from "../report.js"; +import { + emitAppliedMappingReport, + emitConfigurationErrors, + emitFindingsReport, +} from "../report.js"; import { requirementIdProblem, testHoldSpecOf, usageError } from "./common.js"; /** @@ -303,12 +309,11 @@ async function runRename( await appendJournalEntry(workspace.root, plan.entry); await executeBuildOutputs(workspace.root, outputs); - if (invocation.json) { - // SPEC 12.0: one JSON document as the entire standard output — the - // successful rename's report is its (empty) findings list, as for - // `build` (SPEC 12.1). - emitFindingsReport(true, stdout, []); - } + // SPEC 6.4/12.0: a successful rename's report is the applied mapping — + // the complete identity mapping the operation journaled, the information + // of the preview's `mapping` (6.6), in both output forms. The journal + // entry's mapping is that mapping in its canonical `from`-byte order. + emitAppliedMappingReport(invocation.json, stdout, plan.entry.mapping); return 0; } diff --git a/src/cli/report.ts b/src/cli/report.ts index 117f704..95df573 100644 --- a/src/cli/report.ts +++ b/src/cli/report.ts @@ -24,6 +24,7 @@ import { canonicalJson } from "../core/canonical-json.js"; import type { JsonObject, JsonValue } from "../core/canonical-json.js"; import type { Finding, FindingLocation } from "../core/findings.js"; import { orderFindings } from "../core/findings.js"; +import type { IdentityMapping } from "../core/journal.js"; import { pathTextJson, renderPathText } from "../core/path-text.js"; import type { CliWriter, CommandIo } from "./io.js"; @@ -124,6 +125,38 @@ export function emitFindingsReport( ); } +/** + * The applied-mapping report of a successful `rename`/`move` (SPEC 6.4, + * 6.5): the complete identity mapping the operation journaled — the + * information of the preview's `mapping` (6.6), carried in JSON per 12.0. + * The JSON document carries the mapping under the preview's pinned + * `mapping` member encoding (SPEC 12.7): one `{"from", "to"}` per mapped + * identity, ordered by `from` bytes — exactly the journal entry's canonical + * order (core/journal.ts) — beside the consulted domain's (empty) findings. + * The human form presents the same information (SPEC 12.0): one + * `FROM -> TO` line per pair in the same order, closed by a one-line count. + * Identities and paths are workspace-relative and the mapping order is + * canonical, so both forms are byte-deterministic (SPEC 12.0). + */ +export function emitAppliedMappingReport( + json: boolean, + stdout: CliWriter, + mapping: readonly IdentityMapping[], +): void { + if (json) { + const document: JsonValue = { + findings: [], + mapping: mapping.map((pair) => ({ from: pair.from, to: pair.to })), + }; + stdout.write(canonicalJson(document)); + return; + } + const lines = mapping.map((pair) => `${pair.from} -> ${pair.to}\n`); + const count = mapping.length; + lines.push(`${String(count)} identit${count === 1 ? "y" : "ies"} mapped\n`); + stdout.write(lines.join("")); +} + /** * A plain usage error as the finding form of SPEC 12.7: `code` and `path` * null — SPEC 14 assigns usage errors no stable code and no concerned From c8fc353bb15f41b6489bd6cdba1cb3e601144f23 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 22:46:31 +0000 Subject: [PATCH 151/226] sdg(phase-10): register the workspace-independent version command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC 12.6/12.7: `xspec version` is a JSON-only surface emitting the {"product", "interface"} document — interface exactly the string "1", product read from the package's own metadata, fixed per build. It loads no configuration and consults no workspace: main dispatches it before workspace location, so configuration-error precedence (14.14) never reaches it and the answer is byte-identical in any working directory. Usage errors keep exit 2 and emit the 12.7 error document. Verified: T12.6-1/2 pass; T12.0-9 advances past its version row (now red at the unimplemented occurrences surface, task B4). Removes task B1 from specs/tmp/FIX_PLAN.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 18 --- src/cli/args.ts | 13 +- src/cli/commands/version.ts | 67 +++++++++ src/cli/main.ts | 264 +++++++++++++++++++----------------- 4 files changed, 220 insertions(+), 142 deletions(-) create mode 100644 src/cli/commands/version.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index f9d715c..dab382b 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -82,24 +82,6 @@ human one `FROM -> TO` line per pair plus a count line.) ## Stage B — missing command surfaces (patch 0001) -### B1. `xspec version` - -SPEC 12.6, 12.7, 12.0. Not in the command table (`src/cli/args.ts` `COMMANDS`); -exits 2 unknown-command today. Add the command and handler (`src/cli/main.ts`): - -- JSON-only: `{"product": <string>, "interface": "1"}` is its only output form, - with or without `--json`. Interface is exactly the string `"1"`. Product - version fixed per build (read from the package's own metadata at build or - startup — byte-deterministic, no environment-dependent content). -- Loads no configuration and consults no workspace: `--config` accepted, not - consulted; identical output in any working directory, missing/invalid - configuration included — configuration-error precedence (14.14) never reaches - it. Usage errors (unknown flag) keep exit 2 and, being a JSON-only surface, - emit the A4 error document. - -Verify: T12.6-1/2 (`section-12.6.test.ts`), T12.0-9 arm; unblocks the Windows CI -leg's leading edge (E-6). - ### B2a. Invalid-path sources enter per-file analysis (parse-local findings) (Positioned before B2/B4/B5 deliberately — they consume this parse.) SPEC 11.2 diff --git a/src/cli/args.ts b/src/cli/args.ts index 72be1e0..35a67c4 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -10,8 +10,9 @@ // // - The first argv element names a command from the known table (12.5): // `build`, `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, -// `rename`, `move`. `review` and `query` take a subcommand as the next -// element. Unknown commands and subcommands are usage errors (12.0). +// `rename`, `move`, `version`. `review` and `query` take a subcommand as +// the next element. Unknown commands and subcommands are usage errors +// (12.0). // - Tokens beginning `--` are flags; a value flag consumes the following // element, verbatim, as its value. The specification writes only the // space-separated form, so a token like `--config=x` is an unknown flag. @@ -113,7 +114,8 @@ const TEST_HOLD_FLAG: FlagSpec = { /** * The known command table (SPEC 12.5), in specification order. Argument * forms: `build` 12.1, `check` 12.2, `ids` 12.3, `show` 12.4, `coverage` 8.2, - * `impact` 9, `review` 10.7, `query` 11, `rename` 6.4, `move` 6.5. + * `impact` 9, `review` 10.7, `query` 11, `rename` 6.4, `move` 6.5, + * `version` 12.6. */ const COMMANDS: readonly CommandSpec[] = [ // SPEC 12.1. @@ -276,6 +278,11 @@ const COMMANDS: readonly CommandSpec[] = [ // SPEC 6.5: `move <old-file> <new-file>` or // `move <file>#<id> <target-file>#<new-id>` — two positionals either way. { path: "move", positionals: ["<old>", "<new>"], flags: [TEST_HOLD_FLAG] }, + // SPEC 12.6: `version` — JSON-only (a single JSON document is its only + // output form, with or without `--json`); workspace-independent, so + // `--config` (a global) is accepted and never consulted — `main` + // dispatches it before configuration location. + { path: "version", positionals: [], flags: [], jsonOnly: true }, ]; /** Every dispatch key (`CommandSpec.path`), in specification order. */ diff --git a/src/cli/commands/version.ts b/src/cli/commands/version.ts new file mode 100644 index 0000000..37ececd --- /dev/null +++ b/src/cli/commands/version.ts @@ -0,0 +1,67 @@ +// The `xspec version` command (SPEC 12.6). +// +// Reports the product version and the machine-interface version as a single +// JSON document — the surface is JSON-only: the 12.7 version form +// `{"product", "interface"}` is its only output form, with or without +// `--json` (SPEC 12.0). Both values are fixed per build: the +// machine-interface version is the literal string "1" (SPEC 12.6, 12.7), +// and the product version is read from the package's own metadata +// (package.json, resolved relative to this module — never the working +// directory), so the answer is byte-identical in any working directory +// (SPEC 12.0). The command is workspace-independent: it consults no +// workspace and no configuration — `--config` is accepted and not consulted, +// and configuration-error precedence (SPEC 14.14) never reaches it — so +// `main` dispatches it before configuration location. + +import { readFileSync } from "node:fs"; +import { canonicalJson } from "../../core/canonical-json.js"; +import type { ExitCode } from "../../core/findings.js"; +import type { CliWriter } from "../io.js"; + +/** + * SPEC 12.6/12.7: the machine-interface version — the string form of 12.6's + * stated value `1`, naming the JSON contract of 12.0 and 12.7 that this + * build implements. + */ +const MACHINE_INTERFACE_VERSION = "1"; + +/** + * The product version from the package's own metadata (SPEC 12.6 "fixed per + * build"): the `version` field of the package.json this module ships in — + * three directory levels above `cli/commands/` in the source and compiled + * layouts alike. Resolved relative to the module, never the working + * directory or any environment value, so one build reports one value + * wherever it runs (SPEC 12.0: no environment-dependent content). + */ +function productVersion(): string { + const packageJsonUrl = new URL("../../../package.json", import.meta.url); + const metadata: unknown = JSON.parse(readFileSync(packageJsonUrl, "utf8")); + if ( + typeof metadata !== "object" || + metadata === null || + typeof (metadata as { readonly version?: unknown }).version !== "string" + ) { + // The package's own metadata is part of the build: a missing version + // string is a broken installation, an internal error outside the SPEC + // 12.0 exit partition (the bin maps it out of 0/1/2), never a defined + // workspace or configuration failure (SPEC 12.6). + throw new Error("xspec package metadata carries no version string"); + } + return (metadata as { readonly version: string }).version; +} + +/** + * `xspec version` (SPEC 12.6): emit the 12.7 version document as the entire + * standard output and succeed — an informational report, exit 0 (SPEC + * 12.0). It cannot fail for workspace or configuration reasons; usage + * errors (exit 2) are the parser's, upstream of this handler. + */ +export function versionCommand(stdout: CliWriter): ExitCode { + stdout.write( + canonicalJson({ + product: productVersion(), + interface: MACHINE_INTERFACE_VERSION, + }), + ); + return 0; +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 67da0a5..a08d23b 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -39,129 +39,139 @@ export type CommandHandler = ( /** * The dispatch table: one lazily imported handler per SPEC 12.5 command * path, so an invocation loads only its own command's implementation. + * `version` (SPEC 12.6) is absent by design: it loads no configuration and + * consults no workspace, so `main` dispatches it before workspace location, + * upstream of this workspace-bound table. */ const HANDLERS: ReadonlyMap<string, () => Promise<CommandHandler>> = new Map( - COMMAND_PATHS.map((path): [string, () => Promise<CommandHandler>] => { - switch (path) { - case "build": - // SPEC 12.1. - return [ - path, - async () => (await import("./commands/build.js")).buildCommand, - ]; - case "check": - // SPEC 12.2. - return [ - path, - async () => (await import("./commands/check.js")).checkCommand, - ]; - case "ids": - // SPEC 12.3. - return [ - path, - async () => (await import("./commands/ids.js")).idsCommand, - ]; - case "show": - // SPEC 12.4. - return [ - path, - async () => (await import("./commands/show.js")).showCommand, - ]; - case "coverage": - // SPEC 8.2. - return [ - path, - async () => (await import("./commands/coverage.js")).coverageCommand, - ]; - case "impact": - // SPEC 9. - return [ - path, - async () => (await import("./commands/impact.js")).impactCommand, - ]; - case "query node": - case "query nodes": - case "query edges": - case "query subtree": - case "query ancestors": - case "query reachable": - // SPEC 11. - return [ - path, - async () => (await import("./commands/query.js")).queryCommand, - ]; - case "review create": - // SPEC 10.7. - return [ - path, - async () => - (await import("./commands/review.js")).reviewCreateCommand, - ]; - case "review list": - // SPEC 10.7. - return [ - path, - async () => (await import("./commands/review.js")).reviewListCommand, - ]; - case "review status": - // SPEC 10.7. - return [ - path, - async () => - (await import("./commands/review.js")).reviewStatusCommand, - ]; - case "review next": - // SPEC 10.7. - return [ - path, - async () => (await import("./commands/review.js")).reviewNextCommand, - ]; - case "review show": - // SPEC 10.7. - return [ - path, - async () => (await import("./commands/review.js")).reviewShowCommand, - ]; - case "review split": - // SPEC 10.7. - return [ - path, - async () => - (await import("./commands/review-mutate.js")).reviewSplitCommand, - ]; - case "review resolve": - // SPEC 10.7. - return [ - path, - async () => - (await import("./commands/review-mutate.js")).reviewResolveCommand, - ]; - case "review export": - // SPEC 10.7. - return [ - path, - async () => - (await import("./commands/review.js")).reviewExportCommand, - ]; - case "rename": - // SPEC 6.4. - return [ - path, - async () => (await import("./commands/rename.js")).renameCommand, - ]; - case "move": - // SPEC 6.5. - return [ - path, - async () => (await import("./commands/move.js")).moveCommand, - ]; - default: - // Unreachable: every SPEC 12.5 command path is cased above. - // Guarded so a command-table addition without a handler fails - // loudly at module load. - throw new Error(`no handler implemented for command '${path}'`); - } - }), + COMMAND_PATHS.filter((path) => path !== "version").map( + (path): [string, () => Promise<CommandHandler>] => { + switch (path) { + case "build": + // SPEC 12.1. + return [ + path, + async () => (await import("./commands/build.js")).buildCommand, + ]; + case "check": + // SPEC 12.2. + return [ + path, + async () => (await import("./commands/check.js")).checkCommand, + ]; + case "ids": + // SPEC 12.3. + return [ + path, + async () => (await import("./commands/ids.js")).idsCommand, + ]; + case "show": + // SPEC 12.4. + return [ + path, + async () => (await import("./commands/show.js")).showCommand, + ]; + case "coverage": + // SPEC 8.2. + return [ + path, + async () => + (await import("./commands/coverage.js")).coverageCommand, + ]; + case "impact": + // SPEC 9. + return [ + path, + async () => (await import("./commands/impact.js")).impactCommand, + ]; + case "query node": + case "query nodes": + case "query edges": + case "query subtree": + case "query ancestors": + case "query reachable": + // SPEC 11. + return [ + path, + async () => (await import("./commands/query.js")).queryCommand, + ]; + case "review create": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review.js")).reviewCreateCommand, + ]; + case "review list": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review.js")).reviewListCommand, + ]; + case "review status": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review.js")).reviewStatusCommand, + ]; + case "review next": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review.js")).reviewNextCommand, + ]; + case "review show": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review.js")).reviewShowCommand, + ]; + case "review split": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review-mutate.js")).reviewSplitCommand, + ]; + case "review resolve": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review-mutate.js")) + .reviewResolveCommand, + ]; + case "review export": + // SPEC 10.7. + return [ + path, + async () => + (await import("./commands/review.js")).reviewExportCommand, + ]; + case "rename": + // SPEC 6.4. + return [ + path, + async () => (await import("./commands/rename.js")).renameCommand, + ]; + case "move": + // SPEC 6.5. + return [ + path, + async () => (await import("./commands/move.js")).moveCommand, + ]; + default: + // Unreachable: every workspace-bound SPEC 12.5 command path is + // cased above. Guarded so a command-table addition without a + // handler fails loudly at module load. + throw new Error(`no handler implemented for command '${path}'`); + } + }, + ), ); /** Whether the invocation is a `query` subcommand (SPEC 11). */ @@ -195,6 +205,18 @@ export async function main( } return 2; } + + // SPEC 12.6: `version` is workspace-independent — it consults no + // workspace and no configuration (`--config` accepted, not consulted; + // SPEC 7: every command *except* `version` locates the configuration), so + // it dispatches before workspace location and cannot fail for workspace + // or configuration reasons: configuration-error precedence (SPEC 14.14) + // never reaches it. + if (result.invocation.command === "version") { + const { versionCommand } = await import("./commands/version.js"); + return versionCommand(stdout); + } + const loadHandler = HANDLERS.get(result.invocation.command); if (loadHandler === undefined) { // Unreachable: the dispatch table is built from the same command table From 39f7b693003ad43e9663cef347b3483fedc30e3e Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 29 Aug 2026 23:41:47 +0000 Subject: [PATCH 152/226] sdg(phase-10): analyze invalid-path sources per-file with identities undefined FIX_PLAN B2a (SPEC 11.2, 14.19, 12.0/12.7, 14): a discovered file whose path is invalid stays visible to analysis. Discovery carries such files as classification.invalidSources; the pipeline parses them into invalidPathSpecs/invalidPathCode (document.file/analysis.file the real PathText, path a never-rendered lossy stand-in), reporting their parse-local findings beside the 14.19 while defining no identity: no graph nodes, no hashes, no recorded inputs, no derived-file interaction. Import designation consults the whole discovered set through SpecSourceDomain (string- and byte-space designators; WorkspaceContent.readInvalidSource reads content by exact path bytes): a valid import of a 14.19 member is no finding, targetFile carries every valid import's member as PathText, and references rooted at such bindings report 14.5/14.6/14.7 at analysis. References from invalid-path files resolve in buildWorkspaceGraph (findings only; the local form never resolves), and spec import cycles run over exact path bytes with invalid-path files participating. Verified: T12.7-1 arm E's build --json leg passes (14.1 x1 located in the non-UTF-8-named file, marked byte forms; 14.19 x2); the arm's occurrences/view/inventory legs still need B2/B4/B5/B7. Full suite 63 failed / 574 passed, every failure in a pending B2-C7 task's scope; no regression in sections 1-5, 7, or certification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 61 +++--- src/cli/commands/move.ts | 10 + src/cli/commands/rename.ts | 3 + src/core/code-analysis.ts | 236 +++++++++++++++++------ src/core/discovery.ts | 63 ++++++- src/core/graph.ts | 166 ++++++++++++++--- src/core/mdx.ts | 38 +++- src/core/path-text.ts | 16 ++ src/core/source-text.ts | 3 +- src/core/spec-references.ts | 362 +++++++++++++++++++++++++++++++++--- src/workspace/baseline.ts | 29 ++- src/workspace/pipeline.ts | 165 +++++++++++++++- 12 files changed, 984 insertions(+), 168 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index dab382b..aee753d 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -82,45 +82,28 @@ human one `FROM -> TO` line per pair plus a count line.) ## Stage B — missing command surfaces (patch 0001) -### B2a. Invalid-path sources enter per-file analysis (parse-local findings) - -(Positioned before B2/B4/B5 deliberately — they consume this parse.) SPEC 11.2 -("Structure is parse-local": a discovered file whose own path is invalid -(14.19) keeps its parse-local structure, its located findings, and its -occurrence positions — while no identity over an invalid path is ever emitted -or resolved against), 14 (only an unparseable file (14.20) masks its content; -14.19 does not — `build`/`check` "MUST report each" condition), 12.0/12.7 -(marked byte-form paths in every output). Today `SourceClassification` -(`src/core/discovery.ts` ~63: "A file with any finding here is no source: it -appears in neither list") drops every 14.19 file before parsing, so located -conditions inside such files are never reported. Observed: T12.7-1 arm E -(`runBytePathsArm`, `test/suite/registry/section-12.7.ts`) stages a non-UTF-8 -directory `specs/d<0xFF>/` holding `In.mdx` (valid imports of `../OK.xspec` -and `./Tgt.xspec`, a resolving `{text(OK.ok)}` embedding, an id-less `<S>`) -plus `Tgt.mdx` (path-only defect): `build --json` must report exactly -`14.1 x1` (located in `In.mdx`, its location `file` the marked byte form) and -`14.19 x2` (each concerned path marked) — the product reports `14.19 x2` only. -Required: - -- Discovery keeps 14.19 files visible to analysis (condition-19 findings - intact) and core analysis parses and per-file validates them per 11.2: - their located findings report from `build`/`check` beside the 14.19, - location files rendered through `pathTextOf` (A2). -- No identity of such a file is defined (11.2, 1.5): no graph nodes, no - resolution *into* the file (references to it stay unresolved), no journal - or derived-file interaction — `build` on such a workspace fails on the - findings and modifies nothing (12.1). A valid import designating such a - file is no finding (arm E: `./Tgt.xspec` reports nothing), and constructs - inside it are judged on their own terms. -- Reference spellings inside such files whose targets resolve record - occurrences with the source datum explicitly unavailable (11.2, 5.7) — - the recording itself lands with B2 (its "explicitly unavailable when 11.2 - leaves the containing node's identity undefined" datum); B4/B5 then answer - for these files from this same per-file analysis (an invalid-path file - keeps its view, B5). - -Verify: T12.7-1 arm E's `build --json` leg (`section-12.7.test.ts` — its -`occurrences`/`view`/`inventory` legs additionally need B2/B4/B5/B7). +(B2a landed: 14.19 files enter per-file analysis. Discovery carries them as +`classification.invalidSources` (`{path: PathText, bytes, kind, groups}`, +`src/core/discovery.ts`); the pipeline parses them into +`WorkspaceAnalysis.invalidPathSpecs`/`invalidPathCode` — ordinary +`SpecFileAnalysis`/`CodeAnalysis` values whose `document.file` / +`analysis.file` is the real `PathText` while `path` is a never-rendered +lossy stand-in — their per-file findings reported beside the 14.19; they +feed no nodes, hashes, or recorded inputs. Import designation consults the +whole discovered set through `SpecSourceDomain` +(`src/core/spec-references.ts`; byte-space designators for byte-path +importers, `WorkspaceContent.readInvalidSource` reads content by exact +bytes): a valid import of a 14.19 member is no finding, +`SpecImport.targetFile`/`CodeImport.targetFile` carry every valid import's +member as `PathText`, bindings of such members are `undefined-module` (MDX) +/ `target: {defined: false}` (TS), and references rooted there report their +14.5/14.6/14.7 at analysis time. References *from* invalid-path files +resolve in `buildWorkspaceGraph` (the `invalidPathSpecs`/`invalidPathCode` +inputs — findings only, local form never resolves); spec import cycles run +over path bytes with invalid-path files participating. B4/B5 answer for +these files from `invalidPathSpecs`/`invalidPathCode`; B2's occurrence +recording for them hooks into the graph's invalid-path resolution pass, +source datum explicitly unavailable.) ### B2. Record reference occurrences in core analysis and graph data diff --git a/src/cli/commands/move.ts b/src/cli/commands/move.ts index be43e33..8e1d046 100644 --- a/src/cli/commands/move.ts +++ b/src/cli/commands/move.ts @@ -672,6 +672,8 @@ async function reanalyzeMoved( movedSource, ].sort((a, b) => compareBytes(a.path, b.path)), codeSources: analysis.classification.codeSources, + // A valid workspace discovers none (SPEC 14.19 gates move, 6.5). + invalidSources: analysis.classification.invalidSources, findings: [], }; const currentJournal = await readJournalBytes(workspace.root); @@ -682,6 +684,9 @@ async function reanalyzeMoved( return analyzeWorkspaceContent(workspace.configuration, { classification, readSource: (rel) => Promise.resolve(byPath.get(rel) ?? null), + // A valid workspace discovers no invalid-path sources (SPEC 14.19 + // gates move, 6.5), so this reanalysis is never asked for one. + readInvalidSource: () => Promise.resolve(null), loadJournal: () => Promise.resolve(journalFromBytes(journalBytes)), }); } @@ -973,6 +978,8 @@ async function reanalyzeSectionMoved( (a, b) => compareBytes(a.path, b.path), ), codeSources: analysis.classification.codeSources, + // A valid workspace discovers none (SPEC 14.19 gates move, 6.5). + invalidSources: analysis.classification.invalidSources, findings: analysis.classification.findings, }; } @@ -984,6 +991,9 @@ async function reanalyzeSectionMoved( return analyzeWorkspaceContent(workspace.configuration, { classification, readSource: (rel) => Promise.resolve(byPath.get(rel) ?? null), + // A valid workspace discovers no invalid-path sources (SPEC 14.19 + // gates move, 6.5), so this reanalysis is never asked for one. + readInvalidSource: () => Promise.resolve(null), loadJournal: () => Promise.resolve(journalFromBytes(journalBytes)), }); } diff --git a/src/cli/commands/rename.ts b/src/cli/commands/rename.ts index f63b2df..cef22d8 100644 --- a/src/cli/commands/rename.ts +++ b/src/cli/commands/rename.ts @@ -347,6 +347,9 @@ async function reanalyzeRewritten( return analyzeWorkspaceContent(workspace.configuration, { classification: analysis.classification, readSource: (rel) => Promise.resolve(byPath.get(rel) ?? null), + // A valid workspace discovers no invalid-path sources (SPEC 14.19 + // gates rename, 6.4), so this reanalysis is never asked for one. + readInvalidSource: () => Promise.resolve(null), loadJournal: () => Promise.resolve(journalFromBytes(journalBytes)), }); } diff --git a/src/core/code-analysis.ts b/src/core/code-analysis.ts index 7cffc16..45a4ce1 100644 --- a/src/core/code-analysis.ts +++ b/src/core/code-analysis.ts @@ -40,6 +40,9 @@ import { compareFindings, locatedFinding } from "./findings.js"; import type { ClassifiedChain } from "./references.js"; import { classifyReference } from "./references.js"; import { decodeSourceBytes } from "./source-text.js"; +import type { PathText } from "./path-text.js"; +import { pathTextKey, renderPathText } from "./path-text.js"; +import type { DesignateSpecifier } from "./spec-references.js"; import type { ReferenceSpelling } from "./spec-references.js"; import { resolveImportSpecifier } from "./spec-references.js"; @@ -50,12 +53,16 @@ import { resolveImportSpecifier } from "./spec-references.js"; /** What the workspace provides the analysis of one code source. */ export interface CodeAnalysisContext { /** - * The discovered spec-source paths (SPEC 7.1): a spec module import must - * designate one of them (SPEC 4 → 14.15). Whether the designated file - * parses does not matter here — references through it report as - * unresolved during resolution (SPEC 14.20, 14.7). + * Designation of spec module import specifiers over the entire + * discovered spec-source set (SPEC 7.1, 2.1; `SpecSourceDomain`): a + * spec module import must designate a discovered member (SPEC 4 → + * 14.15). Whether the designated file parses does not matter here — + * references through it report as unresolved during resolution + * (SPEC 14.20, 14.7) — and a member whose own path is invalid + * (SPEC 14.19) is designated validly, references through it never + * resolving (SPEC 11.2 → 14.7, reported by this analysis). */ - readonly specPaths: ReadonlySet<string>; + readonly designate: DesignateSpecifier; /** * The configured Markdown emit destinations (SPEC 7.3, * `markdownEmitDestinations`) — empty while emission is disabled — for @@ -101,10 +108,20 @@ export interface CodeImport { readonly specifierRange: ByteRange; /** * The designated source file's workspace-relative path (SPEC 2.1: - * `DIR/NAME.xspec` designates `DIR/NAME.mdx`) when the import is valid; - * null for an invalid import. + * `DIR/NAME.xspec` designates `DIR/NAME.mdx`) when the import is valid + * and the member's identities are defined (a valid source path, + * SPEC 11.2); null for an invalid import — and for a valid import + * designating a member whose path is invalid (SPEC 14.19), whose path + * `targetFile` still carries. */ readonly targetPath: string | null; + /** + * The designated member's path as data (SPEC 12.0, 12.7) for every + * valid import — equal to `targetPath` where that is non-null, the + * 14.19 member's exact path otherwise. Null exactly for an invalid + * import. + */ + readonly targetFile: PathText | null; /** The default-export binding, when present (SPEC 4). */ readonly defaultBinding: CodeImportBinding | null; /** The named `text` bindings, in written order (SPEC 4). */ @@ -138,8 +155,23 @@ export interface CodeReference { /** The analysis of one parseable code source. */ export interface CodeAnalysis { - /** Workspace-relative `/`-separated path (SPEC 1.5). */ + /** + * Workspace-relative `/`-separated path (SPEC 1.5) — the identity-space + * name. For a discovered file whose own path is invalid (SPEC 14.19, + * 11.2) this is a deterministic stand-in (the lossily decoded spelling + * of the path bytes): the unit identities and reference locations built + * over it stay internal — no identity of such a file is ever emitted + * (SPEC 11.2) — and `file` carries the real path. For every valid + * discovered source, `path` equals `file`. + */ readonly path: string; + /** + * The file's real path as data (SPEC 12.0, 12.7): equal to `path` + * except for a file whose path is invalid (SPEC 14.19), where it holds + * the exact path — the marked byte form for a non-UTF-8 path. Every + * finding location of this file renders from it. + */ + readonly file: PathText; /** * The decoded UTF-8 content (SPEC 1.6). Valid, BOM-free UTF-8 re-encodes * to the file's exact bytes, so the recorded reference spans can drive @@ -177,8 +209,9 @@ export function analyzeCodeSource( path: string, bytes: Uint8Array, context: CodeAnalysisContext, + file: PathText = path, ): CodeSourceResult { - const decoded = decodeSourceBytes(path, bytes); + const decoded = decodeSourceBytes(file, bytes); if (!decoded.ok) { return { kind: "unparseable", finding: decoded.finding }; } @@ -199,7 +232,7 @@ export function analyzeCodeSource( return { kind: "unparseable", finding: parseFailureFinding( - path, + file, sourceFile, offsets, tsx, @@ -209,6 +242,7 @@ export function analyzeCodeSource( } const analyzer = new CodeAnalyzer( path, + file, sourceFile, offsets, program.getTypeChecker(), @@ -225,19 +259,19 @@ export function analyzeCodeSource( if (!(error instanceof RangeError)) throw error; return { kind: "unparseable", - finding: stackOverflowFinding(path, tsx ? "TSX" : "plain TypeScript"), + finding: stackOverflowFinding(file, tsx ? "TSX" : "plain TypeScript"), }; } } /** The 14.20 finding for a source the parser cannot process (overflow). */ -function stackOverflowFinding(path: string, grammar: string): Finding { +function stackOverflowFinding(file: PathText, grammar: string): Finding { return locatedFinding( 20, `unparseable source: not well-formed ${grammar} — the file's ` + `nesting exceeds what the parser can process, so no location inside ` + `it can be analyzed; simplify or split the file (SPEC 14.20)`, - [{ file: path, range: { start: 0, end: 0 } }], + [{ file, range: { start: 0, end: 0 } }], ); } @@ -279,7 +313,7 @@ function createSingleFileProgram( /** The 14.20 finding for a parse failure, locating it (SPEC 14.20). */ function parseFailureFinding( - path: string, + file: PathText, sourceFile: ts.SourceFile, offsets: Utf8Offsets, tsx: boolean, @@ -301,7 +335,7 @@ function parseFailureFinding( `syntax at the reported location (SPEC 14.20)`, [ { - file: path, + file, range: { start: offsets.byteOffset(start), end: offsets.byteOffset(end), @@ -315,10 +349,49 @@ function parseFailureFinding( // The per-file analyzer // --------------------------------------------------------------------------- +/** + * The designated member of a valid spec-module binding (SPEC 2.1, 4): a + * member with defined identities (a valid source path), or a 14.19 member + * — designated validly, every identity undefined (SPEC 11.2), so + * references through the binding never resolve (14.7). + */ +type SpecModuleTarget = + | { readonly defined: true; readonly path: string } + | { readonly defined: false; readonly file: PathText }; + +/** Byte-exact comparison key of a designated member (SPEC 12.0). */ +function moduleTargetKey(target: SpecModuleTarget): string { + return pathTextKey(target.defined ? target.path : target.file); +} + +/** The deterministic display spelling of a designated member (messages). */ +function moduleTargetDisplay(target: SpecModuleTarget): string { + return target.defined ? target.path : renderPathText(target.file); +} + +/** A human description of a chain into a designated member (messages). */ +function describeTargetChain( + target: SpecModuleTarget, + segments: readonly string[], +): string { + const display = moduleTargetDisplay(target); + if (segments.length === 0) { + // SPEC 4.5: a bare module reference targets that file's root node. + return `the root node of ${JSON.stringify(display)}`; + } + return JSON.stringify(`${display}#${segments.join(".")}`); +} + +/** The SPEC 14.19/11.2 reason an undefined-member reference never resolves. */ +const UNDEFINED_TARGET_REASON = + `no identity of the designated file is defined because its own path is ` + + `invalid (SPEC 14.19, 11.2); rename that file to a valid source path or ` + + `retarget the reference`; + /** What one import-bound identifier means as a reference root (SPEC 4.5). */ type TrackedBinding = - | { readonly kind: "node"; readonly modulePath: string } - | { readonly kind: "text"; readonly modulePath: string } + | { readonly kind: "node"; readonly target: SpecModuleTarget } + | { readonly kind: "text"; readonly target: SpecModuleTarget } | { /** SPEC 4: a binding introduced type-only is a type-level name. */ readonly kind: "type-level"; @@ -354,6 +427,7 @@ class CodeAnalyzer { constructor( private readonly path: string, + private readonly file: PathText, private readonly sourceFile: ts.SourceFile, private readonly offsets: Utf8Offsets, private readonly checker: ts.TypeChecker, @@ -366,6 +440,7 @@ class CodeAnalyzer { this.walk(this.sourceFile); return { path: this.path, + file: this.file, text: this.sourceFile.text, units: this.units, imports: this.imports, @@ -387,7 +462,7 @@ class CodeAnalyzer { } private addFinding( - condition: 8 | 11 | 15 | 18, + condition: 7 | 8 | 11 | 15 | 18, node: ts.Node, message: string, identities: readonly string[] = [], @@ -396,7 +471,7 @@ class CodeAnalyzer { locatedFinding( condition, message, - [{ file: this.path, range: this.rangeOf(node) }], + [{ file: this.file, range: this.rangeOf(node) }], identities, ), ); @@ -532,7 +607,7 @@ class CodeAnalyzer { `imports may bind the same identifier when either is a spec ` + `module import; rename all but one binding (SPEC 4, 2.1, 14.15)`, statements.map((declaration) => ({ - file: this.path, + file: this.file, range: this.rangeOf(declaration), })), ), @@ -608,24 +683,30 @@ class CodeAnalyzer { ); } let targetPath: string | null = null; + let targetFile: PathText | null = null; + let target: SpecModuleTarget | null = null; if (relative) { - const resolved = resolveImportSpecifier(this.path, specifier); - if (resolved === null) { + // SPEC 2.1/4: `DIR/NAME.xspec` designates `DIR/NAME.mdx`, membership + // judged over the entire discovered spec-source set — a 14.19 member + // is designated validly, its identities all undefined (SPEC 11.2). + const designation = this.context.designate(specifier); + if (designation.kind === "outside-root") { defects.push( `the specifier ${JSON.stringify(specifier)} resolves outside ` + `the workspace root`, ); + } else if (designation.kind === "undiscovered") { + defects.push( + `the designated file ${JSON.stringify(designation.designated)} ` + + `is not a discovered source file of a configured spec group`, + ); + } else if (designation.kind === "defined-member") { + targetPath = designation.path; + targetFile = designation.path; + target = { defined: true, path: designation.path }; } else { - // SPEC 2.1/4: `DIR/NAME.xspec` designates `DIR/NAME.mdx`. - const designated = resolved.slice(0, -XSPEC_SUFFIX.length) + ".mdx"; - if (this.context.specPaths.has(designated)) { - targetPath = designated; - } else { - defects.push( - `the designated file ${JSON.stringify(designated)} is not a ` + - `discovered source file of a configured spec group`, - ); - } + targetFile = designation.file; + target = { defined: false, file: designation.file }; } } if (statement.attributes !== undefined) { @@ -702,11 +783,11 @@ class CodeAnalyzer { for (const { declaration, binding, role } of roles) { this.declarations.set( declaration, - !valid || targetPath === null + !valid || target === null ? { kind: "poisoned" } : binding.typeOnly ? { kind: "type-level" } - : { kind: role, modulePath: targetPath }, + : { kind: role, target }, ); } @@ -721,6 +802,7 @@ class CodeAnalyzer { specifierQuote: quote === "'" ? "'" : '"', specifierRange: this.rangeOf(literal), targetPath: valid ? targetPath : null, + targetFile: valid ? targetFile : null, defaultBinding, textBindings, valid, @@ -808,6 +890,13 @@ class CodeAnalyzer { formLabel: string, ): void { if (!specifier.startsWith("./") && !specifier.startsWith("../")) return; + // For a file whose own path is invalid (SPEC 14.19) `this.path` is the + // lossily decoded stand-in: the `.xspec.`-infix and `.xspec/`-prefix + // rules below stay byte-exact over it (the specifier's own segments + // and the path's structure survive lossy decoding), while the + // Markdown-destination membership is checked over the lossy spelling — + // exact except where a non-UTF-8 directory has a sibling spelled with + // the literal replacement character. const resolved = resolveImportSpecifier(this.path, specifier); if (resolved === null) return; const kind = derivedFilePathKind( @@ -1039,7 +1128,7 @@ class CodeAnalyzer { */ private visitNodeBindingUse( identifier: ts.Identifier, - binding: { readonly kind: "node"; readonly modulePath: string }, + binding: { readonly kind: "node"; readonly target: SpecModuleTarget }, ): void { const use = climbUseExpression(identifier); const parent = use.parent; @@ -1049,14 +1138,31 @@ class CodeAnalyzer { // is a dependency marker recording a `references` edge. const classified = classifyReference(use, this.sourceFile); if (classified.kind === "chain") { - this.references.push( - this.chainReference( - "references", - classified, - binding.modulePath, - this.attributionOf(use), - ), - ); + if (binding.target.defined) { + this.references.push( + this.chainReference( + "references", + classified, + binding.target.path, + this.attributionOf(use), + ), + ); + } else { + // SPEC 14.7: a marker that does not resolve — into a member + // whose identities are all undefined (SPEC 14.19, 11.2), a + // condition decidable per file. + this.addFinding( + 7, + use, + `unknown TypeScript reference: the marker referencing ` + + `${describeTargetChain( + binding.target, + classified.segments.map((segment) => segment.name), + )} ` + + `does not resolve — ${UNDEFINED_TARGET_REASON} ` + + `(SPEC 4.5, 14.7)`, + ); + } } else { // The expression is rooted at `identifier`, so the string // classification is impossible; dynamic is 14.8 (SPEC 4.5, 2.4). @@ -1121,7 +1227,7 @@ class CodeAnalyzer { */ private visitTextBindingUse( identifier: ts.Identifier, - binding: { readonly kind: "text"; readonly modulePath: string }, + binding: { readonly kind: "text"; readonly target: SpecModuleTarget }, ): void { const parent = identifier.parent; if (ts.isCallExpression(parent) && parent.expression === identifier) { @@ -1144,7 +1250,7 @@ class CodeAnalyzer { */ private analyzeTextCall( call: ts.CallExpression, - calleeBinding: { readonly kind: "text"; readonly modulePath: string }, + calleeBinding: { readonly kind: "text"; readonly target: SpecModuleTarget }, ): void { if (call.questionDotToken !== undefined) { this.addFinding( @@ -1251,19 +1357,41 @@ class CodeAnalyzer { ); return; } - if (rootBinding.modulePath !== calleeBinding.modulePath) { - // SPEC 4.4 → 14.11: a node passed to another module's text export. - // The foreign (called) module is identity data on the finding, not a + if ( + moduleTargetKey(rootBinding.target) !== + moduleTargetKey(calleeBinding.target) + ) { + // SPEC 4.4 → 14.11: a node passed to another module's text export + // — modules compared as their files, byte-exact (SPEC 12.0). The + // foreign (called) module is identity data on the finding, not a // further location (SPEC 14, 12.7). this.addFinding( 11, call, `cross-module text call: the argument is a node of module ` + - `${JSON.stringify(rootBinding.modulePath)} but the "text" export ` + - `called belongs to module ` + - `${JSON.stringify(calleeBinding.modulePath)} — pass a node only ` + - `to its own module's "text" export (SPEC 4.4, 14.11)`, - [calleeBinding.modulePath], + `${JSON.stringify(moduleTargetDisplay(rootBinding.target))} but ` + + `the "text" export called belongs to module ` + + `${JSON.stringify(moduleTargetDisplay(calleeBinding.target))} — ` + + `pass a node only to its own module's "text" export ` + + `(SPEC 4.4, 14.11)`, + [moduleTargetDisplay(calleeBinding.target)], + ); + return; + } + if (!rootBinding.target.defined) { + // SPEC 14.7: a text(...) call that does not resolve — into a member + // whose identities are all undefined (SPEC 14.19, 11.2), a + // condition decidable per file. The finding spans the argument + // chain, as an unresolved defined-member argument's would (SPEC 14). + this.addFinding( + 7, + argument, + `unknown TypeScript reference: the text(...) argument referencing ` + + `${describeTargetChain( + rootBinding.target, + classified.segments.map((segment) => segment.name), + )} ` + + `does not resolve — ${UNDEFINED_TARGET_REASON} (SPEC 4.3, 14.7)`, ); return; } @@ -1273,7 +1401,7 @@ class CodeAnalyzer { this.chainReference( "embeds", classified, - rootBinding.modulePath, + rootBinding.target.path, this.attributionOf(call), ), ); diff --git a/src/core/discovery.ts b/src/core/discovery.ts index 493fedf..04f92f6 100644 --- a/src/core/discovery.ts +++ b/src/core/discovery.ts @@ -19,6 +19,7 @@ import type { Configuration, ConfiguredGroup } from "./config.js"; import type { Finding } from "./findings.js"; import { pathFinding } from "./findings.js"; +import type { PathText } from "./path-text.js"; import { pathTextOf } from "./path-text.js"; const SLASH = 0x2f; // "/" @@ -33,6 +34,34 @@ const XSPEC_DIR_PREFIX = utf8Encoder.encode(".xspec/"); /** SPEC 7.1: every spec-group match must have the `.mdx` extension. */ const MDX_SUFFIX = utf8Encoder.encode(".mdx"); +/** + * One discovered file whose workspace-relative path is invalid (SPEC 7, + * 7.1 → 14.19): it stays visible to analysis — structure is parse-local + * (SPEC 11.2), so `build`/`check` report its located conditions beside the + * 14.19 — while no identity over it is ever defined, emitted, or resolved + * against (SPEC 11.2, 1.5) and it never interacts with the journal or any + * derived file (a 14.19 finding fails `build`, which then modifies + * nothing, SPEC 12.1). + */ +export interface InvalidSource { + /** + * The real path as data (SPEC 12.0, 12.7): the decoded string where the + * path bytes are valid UTF-8 (a `#`-containing or non-`.mdx` spec-group + * path), otherwise the exact bytes in the marked form. + */ + readonly path: PathText; + /** The path's exact bytes — the resolution and membership space. */ + readonly bytes: Uint8Array; + /** + * Which analysis the file enters (SPEC 11.2): "spec" when a spec + * group's globs match it (MDX analysis, whatever its extension — + * SPEC 14.20 parses every spec-group file as MDX), otherwise "code". + */ + readonly kind: "spec" | "code"; + /** The matching groups of its kind, in configuration order (SPEC 7). */ + readonly groups: readonly string[]; +} + /** One discovered source file of one kind (spec or code). */ export interface DiscoveredSource { /** @@ -55,12 +84,20 @@ export interface SourceClassification { readonly specSources: readonly DiscoveredSource[]; /** Valid discovered code sources, byte-ordered by path. */ readonly codeSources: readonly DiscoveredSource[]; + /** + * Discovered files whose paths 14.19 rejects (SPEC 7, 7.1), byte-ordered + * by path: no identity of theirs is ever defined, but they stay visible + * to per-file analysis (SPEC 11.2). A file with the 14.14 both-groups + * error is not here — that error precedes all source analysis (SPEC 14). + */ + readonly invalidSources: readonly InvalidSource[]; /** * Discovery-level conditions, as data: 14.14 for a file matched by both * a spec and a code group (SPEC 7.2; usage class — it precedes all * source analysis, SPEC 14) and 14.19 for invalid source paths (SPEC 7, * 7.1). Ordered by the offending path's bytes, then condition order. A - * file with any finding here is no source: it appears in neither list. + * file with any finding here appears in neither source list; a file with + * only 14.19 findings appears in `invalidSources`. */ readonly findings: readonly Finding[]; } @@ -313,6 +350,7 @@ export function classifySources( const specSources: DiscoveredSource[] = []; const codeSources: DiscoveredSource[] = []; + const invalidSources: InvalidSource[] = []; const findings: Finding[] = []; for (const candidate of matched) { if (destinationKeys.has(byteKey(candidate.bytes))) continue; @@ -323,10 +361,12 @@ export function classifySources( const fileLabel = pathTextOf(candidate.bytes); const decoded = typeof fileLabel === "string" ? fileLabel : null; let valid = true; + let bothGroups = false; if (candidate.specGroups.length > 0 && candidate.codeGroups.length > 0) { // SPEC 7.2 → 14.14: a file matched by both a spec and a code group // is a configuration error (usage class; precedes source analysis). valid = false; + bothGroups = true; findings.push( pathFinding( 14, @@ -378,12 +418,29 @@ export function classifySources( ), ); } - if (!valid || decoded === null) continue; + if (!valid || decoded === null) { + // SPEC 11.2: a 14.19 file stays visible to per-file analysis — its + // located conditions report beside the path finding — while the + // 14.14 both-groups error precedes all source analysis (SPEC 14), so + // a file bearing it is analyzed as nothing. + if (!bothGroups) { + invalidSources.push({ + path: fileLabel, + bytes: candidate.bytes.slice(), + kind: candidate.specGroups.length > 0 ? "spec" : "code", + groups: + candidate.specGroups.length > 0 + ? candidate.specGroups + : candidate.codeGroups, + }); + } + continue; + } if (candidate.specGroups.length > 0) { specSources.push({ path: decoded, groups: candidate.specGroups }); } else { codeSources.push({ path: decoded, groups: candidate.codeGroups }); } } - return { specSources, codeSources, findings }; + return { specSources, codeSources, invalidSources, findings }; } diff --git a/src/core/graph.ts b/src/core/graph.ts index 200d766..ab1884b 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -37,6 +37,8 @@ import type { CodeAnalysis } from "./code-analysis.js"; import type { Finding, FindingLocation } from "./findings.js"; import { compareFindings, locatedFinding } from "./findings.js"; import type { SpecDocument, SpecEmbedding, SpecSection } from "./mdx.js"; +import type { PathText } from "./path-text.js"; +import { pathTextKey, renderPathText } from "./path-text.js"; import type { ReferenceTarget, SpecImportModel, @@ -113,6 +115,18 @@ export interface SpecFileAnalysis { export interface WorkspaceGraphInputs { readonly specs: readonly SpecFileAnalysis[]; readonly code: readonly CodeAnalysis[]; + /** + * Per-file analyses of discovered spec sources whose own paths are + * invalid (SPEC 14.19, 11.2): they contribute no nodes and no edges — + * no identity of theirs is defined — but their references are resolved + * here on their own terms (a reference out of such a file into a + * defined identity resolves finding-free; anything else reports + * 14.5/14.6) and their imports participate in the file-level import + * relation (spec import cycles, SPEC 2.1 → 14.9). + */ + readonly invalidPathSpecs?: readonly SpecFileAnalysis[]; + /** The code-source counterpart: references resolve or report 14.7. */ + readonly invalidPathCode?: readonly CodeAnalysis[]; } // --------------------------------------------------------------------------- @@ -279,6 +293,8 @@ export function buildWorkspaceGraph( // workspace-relative path, content in document order. const specs = sortByBytes(inputs.specs, (spec) => spec.document.path); const code = sortByBytes(inputs.code, (analysis) => analysis.path); + const invalidPathSpecs = inputs.invalidPathSpecs ?? []; + const invalidPathCode = inputs.invalidPathCode ?? []; // --- requirement nodes (SPEC 5.1, 1.5) ---------------------------------- const requirementNodes: RequirementNode[] = []; @@ -517,6 +533,94 @@ export function buildWorkspaceGraph( } } + // --- references of invalid-path files (SPEC 14.19, 11.2) ---------------- + // + // A discovered file whose own path is invalid contributes no nodes and + // no edges — no identity of it is defined, and nothing resolves into it + // — but its constructs are judged on their own terms (SPEC 11.2, 14): + // its extracted references resolve against the defined identities, a + // local reference (naming an ID in the invalid-path file itself) never + // resolving, and each unresolved reference reports its 14.5/14.6/14.7 + // located in the file (marked byte form capable). References that DO + // resolve are finding-free; their occurrence recording (source datum + // explicitly unavailable, SPEC 5.7) is the occurrence machinery's. + const invalidPathFailure = ( + target: ReferenceTarget, + ): { readonly described: string; readonly reason: string } | null => { + if (target.kind === "local") { + return { + described: `${JSON.stringify(target.idPath)} in this file`, + reason: + `the reference names an ID in this file, and no identity of ` + + `this file is defined because its own path is invalid ` + + `(SPEC 14.19, 11.2); rename the file to a valid source path`, + }; + } + const resolved = resolution.resolveExternal( + target.modulePath, + target.segments, + ); + if (resolved.ok) return null; + return { + described: describeExternal(target.modulePath, target.segments), + reason: resolved.reason, + }; + }; + for (const spec of invalidPathSpecs) { + const file = spec.document.file; + for (const dependency of spec.references.dependencies) { + const failure = invalidPathFailure(dependency.reference.target); + if (failure === null) continue; + findings.push( + locatedFinding( + 5, + `unknown dependency: the d reference to ${failure.described} ` + + `does not resolve — ${failure.reason}; declare the target ` + + `section or correct the reference (SPEC 2.2, 14.5)`, + [{ file, range: dependency.reference.range }], + ), + ); + } + for (const embedded of spec.references.embeddings) { + if (embedded.reference === null) continue; + const failure = invalidPathFailure(embedded.reference.target); + if (failure === null) continue; + // SPEC 14: an embedding-form finding's range is the full braced + // container — the span its occurrence would occupy (5.7). + findings.push( + locatedFinding( + 6, + `unknown text target: the text(...) reference to ` + + `${failure.described} does not resolve — ${failure.reason}; ` + + `declare the target section or correct the reference ` + + `(SPEC 2.3, 14.6)`, + [{ file, range: embedded.embedding.range }], + ), + ); + } + } + for (const analysis of invalidPathCode) { + for (const reference of analysis.references) { + const resolved = resolution.resolveExternal( + reference.modulePath, + reference.segments, + ); + if (resolved.ok) continue; + const construct = + reference.kind === "references" ? "marker" : "text(...) argument"; + findings.push( + locatedFinding( + 7, + `unknown TypeScript reference: the ${construct} referencing ` + + `${describeExternal(reference.modulePath, reference.segments)} ` + + `does not resolve — ${resolved.reason}; correct or remove the ` + + `reference (SPEC 4.5, 14.7)`, + [{ file: analysis.file, range: reference.range }], + ), + ); + } + } + // SPEC 14/12.7: deterministic finding order. findings.sort(compareFindings); @@ -538,7 +642,7 @@ export function buildWorkspaceGraph( edgeSpellings, ), ); - findings.push(...importCycleFindings(specs, parsedByPath)); + findings.push(...importCycleFindings([...specs, ...invalidPathSpecs])); return new WorkspaceGraph({ requirementNodes, @@ -730,50 +834,70 @@ function dependencyCycleFindings( * SPEC 2.1: spec import cycles — over each parsed file's valid imports' * designated files, whether or not the bindings are used (an unused * import records no edges, but the import itself still relates the - * files). A file importing itself is a cycle of length one. One 14.9 - * finding per cyclic component, locating each participating import - * declaration — every import recording a step of the reported cycle - * (SPEC 14 location cardinality), the full file path carried in the - * message. + * files). A file importing itself is a cycle of length one. The relation + * is between FILES, so discovered spec sources whose own paths are + * invalid (SPEC 14.19) participate — their imports were analyzed + * (SPEC 11.2) and a valid import designates a member whatever that + * member's path validity — and the walk therefore runs over exact path + * bytes (SPEC 12.0), with every location and message path rendered from + * the file's real path (marked byte form capable). One 14.9 finding per + * cyclic component, locating each participating import declaration — + * every import recording a step of the reported cycle (SPEC 14 location + * cardinality), the full file path carried in the message. */ -function importCycleFindings( - specs: readonly SpecFileAnalysis[], - parsedByPath: ReadonlyMap<string, SpecFileAnalysis>, -): Finding[] { +function importCycleFindings(specs: readonly SpecFileAnalysis[]): Finding[] { + /** Byte key of one parsed file (both `PathText` forms, SPEC 12.0). */ + const keyed = new Map<string, SpecFileAnalysis>(); + for (const spec of specs) { + keyed.set(pathTextKey(spec.document.file), spec); + } const adjacency = new Map<string, Set<string>>(); for (const spec of specs) { + const sourceKey = pathTextKey(spec.document.file); for (const declared of spec.imports.imports) { - if (declared.targetPath === null) continue; - let targets = adjacency.get(spec.document.path); + if (declared.targetFile === null) continue; + let targets = adjacency.get(sourceKey); if (targets === undefined) { - adjacency.set(spec.document.path, (targets = new Set())); + adjacency.set(sourceKey, (targets = new Set())); } - targets.add(declared.targetPath); + targets.add(pathTextKey(declared.targetFile)); } } - const paths = specs.map((spec) => spec.document.path); - const cycles = findCycles(paths, adjacency); + const cycles = findCycles([...keyed.keys()], adjacency); return cycles.map((cycle) => { + const fileOf = (key: string): PathText => { + const spec = keyed.get(key); + if (spec === undefined) { + throw new Error("xspec internal error: cycle through unknown file"); + } + return spec.document.file; + }; // SPEC 14/14.9: locate each participating import declaration — for // every step of the closed walk, every import of the step's source // file designating the step's target (for a self-import cycle, the // self-designating imports). const locations: FindingLocation[] = []; for (let step = 0; step + 1 < cycle.length; step += 1) { - const spec = parsedByPath.get(cycle[step]); + const spec = keyed.get(cycle[step]); if (spec === undefined) continue; for (const declared of spec.imports.imports) { - if (declared.targetPath === cycle[step + 1]) { + if ( + declared.targetFile !== null && + pathTextKey(declared.targetFile) === cycle[step + 1] + ) { locations.push({ - file: spec.document.path, + file: spec.document.file, range: declared.statement.range, }); } } } + const renderedCycle = cycle + .map((key) => renderPathText(fileOf(key))) + .join(" → "); return locatedFinding( 9, - `spec import cycle: ${cycle.join(" → ")} — import cycles among ` + + `spec import cycle: ${renderedCycle} — import cycles among ` + `spec source files are invalid, even when no requirement-level ` + `dependency cycle exists; remove one of the participating imports ` + `(SPEC 2.1, 14.9)`, @@ -782,7 +906,7 @@ function importCycleFindings( : // Unreachable — every step of a reported import cycle came from // a recorded import — but a located condition must locate // (SPEC 14). - [{ file: cycle[0], range: { start: 0, end: 0 } }], + [{ file: fileOf(cycle[0]), range: { start: 0, end: 0 } }], ); }); } diff --git a/src/core/mdx.ts b/src/core/mdx.ts index bbd7d40..c2462d2 100644 --- a/src/core/mdx.ts +++ b/src/core/mdx.ts @@ -48,6 +48,7 @@ import type { ByteRange } from "./bytes.js"; import { Utf8Offsets } from "./bytes.js"; import type { ConditionNumber, Finding } from "./findings.js"; import { compareFindings, locatedFinding } from "./findings.js"; +import type { PathText } from "./path-text.js"; import { decodeSourceBytes } from "./source-text.js"; import { containsControl, @@ -191,8 +192,22 @@ export interface SpecEsmBlock { /** The parsed per-file document model. */ export interface SpecDocument { - /** Workspace-relative `/`-separated path (SPEC 1.5). */ + /** + * Workspace-relative `/`-separated path (SPEC 1.5) — the identity-space + * name. For a discovered file whose own path is invalid (SPEC 14.19, + * 11.2) this is a deterministic stand-in (the lossily decoded spelling + * of the path bytes): no identity is ever formed over it, nothing + * resolves against it, and it is never rendered — `file` carries the + * real path. For every valid discovered source, `path` equals `file`. + */ readonly path: string; + /** + * The file's real path as data (SPEC 12.0, 12.7): equal to `path` + * except for a file whose path is invalid (SPEC 14.19), where it holds + * the exact path — the marked byte form for a non-UTF-8 path. Every + * finding location and output-facing path of this file renders from it. + */ + readonly file: PathText; /** The decoded UTF-8 content (SPEC 1.6). */ readonly text: string; /** UTF-16 index ↔ UTF-8 byte offset conversion for `text` (SPEC 1.7). */ @@ -900,8 +915,9 @@ const mdxParser = unified() export function parseSpecSource( path: string, bytes: Uint8Array, + file: PathText = path, ): SpecSourceResult { - const decoded = decodeSourceBytes(path, bytes); + const decoded = decodeSourceBytes(file, bytes); if (!decoded.ok) { return { kind: "unparseable", finding: decoded.finding }; } @@ -915,11 +931,11 @@ export function parseSpecSource( } catch (error) { return { kind: "unparseable", - finding: parseFailureFinding(path, error, text, offsets), + finding: parseFailureFinding(file, error, text, offsets), }; } - const builder = new DocumentBuilder(path, text, offsets); + const builder = new DocumentBuilder(path, file, text, offsets); try { builder.walk(tree); builder.finishTags(); @@ -929,7 +945,7 @@ export function parseSpecSource( // mismatched tags make the file unparseable, masking its contents. return { kind: "unparseable", - finding: parseFailureFinding(path, error, text, offsets), + finding: parseFailureFinding(file, error, text, offsets), }; } if (error instanceof RangeError) { @@ -945,7 +961,7 @@ export function parseSpecSource( `unparseable source: not well-formed MDX — the file's nesting ` + `exceeds what the parser can process, so no location inside ` + `it can be analyzed; simplify or split the file (SPEC 14.20)`, - [{ file: path, range: { start: 0, end: 0 } }], + [{ file, range: { start: 0, end: 0 } }], ), }; } @@ -957,7 +973,7 @@ export function parseSpecSource( /** The 14.20 finding for a thrown MDX parse failure, with its location. */ function parseFailureFinding( - path: string, + file: PathText, error: unknown, text: string, offsets: Utf8Offsets, @@ -1011,7 +1027,7 @@ function parseFailureFinding( 20, `unparseable source: not well-formed MDX${where} — ${reason}. ` + `Correct the syntax at the reported location (SPEC 14.20)`, - [{ file: path, range }], + [{ file, range }], ); } @@ -1141,6 +1157,7 @@ class DocumentBuilder { constructor( private readonly path: string, + private readonly file: PathText, private readonly text: string, private readonly offsets: Utf8Offsets, ) { @@ -1177,7 +1194,7 @@ class DocumentBuilder { message: string, ): void { this.findings.push( - locatedFinding(condition, message, [{ file: this.path, range }]), + locatedFinding(condition, message, [{ file: this.file, range }]), ); } @@ -1821,7 +1838,7 @@ class DocumentBuilder { `${String(locations.length)} sections bear this ID — IDs must ` + `be unique within a source file; rename all but one of the ` + `sections (SPEC 1.3, 14.3)`, - locations.map((range) => ({ file: this.path, range })), + locations.map((range) => ({ file: this.file, range })), ), ); } @@ -1833,6 +1850,7 @@ class DocumentBuilder { const sorted = [...this.findings].sort(compareFindings); return { path: this.path, + file: this.file, text: this.text, offsets: this.offsets, root: this.root, diff --git a/src/core/path-text.ts b/src/core/path-text.ts index e47fd68..ee8e485 100644 --- a/src/core/path-text.ts +++ b/src/core/path-text.ts @@ -70,6 +70,22 @@ export function pathTextBytes(path: PathText): Uint8Array { return typeof path === "string" ? utf8Encoder.encode(path) : path.bytes; } +/** + * An injective string key for a path's exact bytes (one UTF-16 code unit + * per byte), for exact byte-path map and set membership across both + * `PathText` forms (SPEC 12.0: every path comparison is byte-wise). Keys + * of byte sequences 0x00–0xFF compare by `compareBytes` in byte order. + * Never rendered anywhere. + */ +export function pathTextKey(path: PathText): string { + const bytes = pathTextBytes(path); + let key = ""; + for (let index = 0; index < bytes.length; index += 1) { + key += String.fromCharCode(bytes[index]); + } + return key; +} + /** Three-way lexicographic comparison of two byte arrays. */ function compareByteArrays(a: Uint8Array, b: Uint8Array): -1 | 0 | 1 { const shorter = Math.min(a.length, b.length); diff --git a/src/core/source-text.ts b/src/core/source-text.ts index 2505fe6..b62cce6 100644 --- a/src/core/source-text.ts +++ b/src/core/source-text.ts @@ -10,6 +10,7 @@ import type { Finding } from "./findings.js"; import { locatedFinding } from "./findings.js"; +import type { PathText } from "./path-text.js"; /** Decoder for byte sequences already validated by `firstInvalidUtf8`. */ const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); @@ -83,7 +84,7 @@ export type DecodedSource = * decoded content, exactly. */ export function decodeSourceBytes( - path: string, + path: PathText, bytes: Uint8Array, ): DecodedSource { // SPEC 1.6: a source beginning with a byte-order mark is unparseable. diff --git a/src/core/spec-references.ts b/src/core/spec-references.ts index 15bb991..9a63ddb 100644 --- a/src/core/spec-references.ts +++ b/src/core/spec-references.ts @@ -19,13 +19,18 @@ // a dynamic reference (14.8): it is not "rooted at an imported spec // module" (SPEC 2.4). References through a valid import of an // unparseable file are recorded normally and report as unresolved during -// resolution (SPEC 14.20, 14.5–14.7). +// resolution (SPEC 14.20, 14.5–14.7); references through a valid import +// of a member whose own path is invalid (SPEC 14.19) never resolve — +// every identity of such a file is undefined (SPEC 11.2) — a condition +// decidable per file, so their 14.5/14.6 is reported here directly. import ts from "typescript"; import type { ByteRange } from "./bytes.js"; import { Utf8Offsets } from "./bytes.js"; import type { Finding } from "./findings.js"; import { compareFindings, locatedFinding } from "./findings.js"; +import type { PathText } from "./path-text.js"; +import { pathTextKey, pathTextOf, renderPathText } from "./path-text.js"; import type { SpecDocument, SpecEmbedding, @@ -59,10 +64,21 @@ export interface SpecImport { /** * The designated source file's workspace-relative path (SPEC 2.1: * `DIR/NAME.xspec` designates `DIR/NAME.mdx`) when the import is valid - * — the target of the file-level import edge (cycles, SPEC 5.3). Null - * for an invalid import. + * and the designated member's identities are defined (a valid source + * path, SPEC 11.2). Null for an invalid import — and for a valid import + * designating a member whose path is invalid (SPEC 14.19): such a + * member's identities are all undefined, so nothing identity-shaped + * points at it; `targetFile` still carries its path. */ readonly targetPath: string | null; + /** + * The designated member's path as data (SPEC 12.0, 12.7) for every + * valid import — equal to `targetPath` where that is non-null, and the + * 14.19 member's exact path (marked byte form capable) otherwise; the + * file-level import relation (cycles, SPEC 2.1 → 5.3) and the surfaces + * of 11.4 read it. Null exactly for an invalid import. + */ + readonly targetFile: PathText | null; /** Whether the import itself is valid (duplicate bindings are pairwise). */ readonly valid: boolean; } @@ -74,6 +90,16 @@ export type SpecImportBinding = readonly kind: "module"; readonly targetPath: string; } + | { + /** + * A valid spec-module binding of a member whose own path is invalid + * (SPEC 14.19): the import is no finding, but every identity of the + * designated file is undefined (SPEC 11.2), so a reference rooted + * here never resolves — condition 14.5/14.6, decidable per file. + */ + readonly kind: "undefined-module"; + readonly modulePath: PathText; + } | { /** * A binding of an invalid import, or an identifier bound by more @@ -121,6 +147,190 @@ export function resolveImportSpecifier( return segments.join("/"); } +/** + * SPEC 2.1: `resolveImportSpecifier` over exact path bytes, for an + * importing file whose own path has no plain string form (SPEC 14.19): + * the importer's directory bytes joined with the specifier's segments — + * the specifier itself is decoded source text, so its segments enter as + * their UTF-8 bytes. Returns null when the specifier climbs out of the + * workspace root. For a valid-UTF-8 importer this computes exactly what + * the string form computes. + */ +export function resolveImportSpecifierBytes( + importerBytes: Uint8Array, + specifier: string, +): Uint8Array | null { + const SLASH = 0x2f; + const segments: Uint8Array[] = []; + let start = 0; + for (let index = 0; index <= importerBytes.length; index += 1) { + if (index === importerBytes.length || importerBytes[index] === SLASH) { + segments.push(importerBytes.subarray(start, index)); + start = index + 1; + } + } + segments.pop(); // the importing file's own name — resolve from its directory + const encoder = new TextEncoder(); + for (const part of specifier.split("/")) { + if (part === "" || part === ".") { + continue; + } + if (part === "..") { + if (segments.length === 0) { + return null; // resolves outside the workspace root + } + segments.pop(); + continue; + } + segments.push(encoder.encode(part)); + } + let length = 0; + for (const segment of segments) length += segment.length; + const joined = new Uint8Array( + length + (segments.length > 0 ? segments.length - 1 : 0), + ); + let offset = 0; + for (let index = 0; index < segments.length; index += 1) { + if (index > 0) { + joined[offset] = SLASH; + offset += 1; + } + joined.set(segments[index], offset); + offset += segments[index].length; + } + return joined; +} + +/** + * The outcome of designating the file an in-form import specifier names + * (SPEC 2.1: a relative `./`/`../` specifier ending `.xspec`, resolved + * against the importing file's directory; `DIR/NAME.xspec` designates + * `DIR/NAME.mdx`). Membership is over the entire discovered spec-source + * set — an import designating a discovered member whose path is invalid + * (SPEC 14.19) is valid (no 14.15), while the member's identities are all + * undefined (SPEC 11.2), so references through it never resolve. + */ +export type SpecifierDesignation = + | { readonly kind: "outside-root" } + | { + /** Not a discovered spec-group member; `designated` is its + * deterministic display spelling for the 14.15 message. */ + readonly kind: "undiscovered"; + readonly designated: string; + } + | { + /** A member with defined identities: a valid source path. */ + readonly kind: "defined-member"; + readonly path: string; + } + | { + /** A 14.19 member: import valid, every identity undefined (11.2). */ + readonly kind: "undefined-member"; + readonly file: PathText; + }; + +/** + * Designate the member an in-form specifier names from one importing + * file. Callers check the specifier's form first (relative, `.xspec`); + * the designator owns resolution and membership. + */ +export type DesignateSpecifier = (specifier: string) => SpecifierDesignation; + +const XSPEC_SUFFIX_LENGTH = 6; // ".xspec" +const MDX_SUFFIX_BYTES = [0x2e, 0x6d, 0x64, 0x78]; // ".mdx" + +/** One discovered spec source's designation record, either path form. */ +interface SpecMemberRecord { + readonly path: PathText; + readonly defined: boolean; +} + +/** + * The discovered spec-source domain import designation consults (SPEC 2.1, + * 7.1): every discovered spec source, valid or invalid-path (14.19), + * indexed for the two resolution spaces — string space for importing + * files with a plain string path, byte space for importers whose own path + * has none (only reachable inside 14.19 analyses). + */ +export class SpecSourceDomain { + private readonly byString = new Map<string, SpecMemberRecord>(); + private readonly byKey = new Map<string, SpecMemberRecord>(); + + constructor( + definedPaths: Iterable<string>, + invalidSpecPaths: Iterable<{ + readonly path: PathText; + readonly bytes: Uint8Array; + }>, + ) { + for (const path of definedPaths) { + const record: SpecMemberRecord = { path, defined: true }; + this.byString.set(path, record); + this.byKey.set(pathTextKey(path), record); + } + for (const source of invalidSpecPaths) { + const record: SpecMemberRecord = { path: source.path, defined: false }; + if (typeof source.path === "string") { + this.byString.set(source.path, record); + } + this.byKey.set(pathTextKey(source.path), record); + } + } + + private static memberDesignation( + record: SpecMemberRecord | undefined, + display: () => string, + ): SpecifierDesignation { + if (record === undefined) { + return { kind: "undiscovered", designated: display() }; + } + return record.defined && typeof record.path === "string" + ? { kind: "defined-member", path: record.path } + : { kind: "undefined-member", file: record.path }; + } + + /** The designator for an importing file with a plain string path. */ + designatorFor(importerPath: string): DesignateSpecifier { + return (specifier) => { + const resolved = resolveImportSpecifier(importerPath, specifier); + if (resolved === null) { + return { kind: "outside-root" }; + } + // SPEC 2.1: `DIR/NAME.xspec` designates `DIR/NAME.mdx`. + const designated = resolved.slice(0, -XSPEC_SUFFIX_LENGTH) + ".mdx"; + return SpecSourceDomain.memberDesignation( + this.byString.get(designated), + () => designated, + ); + }; + } + + /** + * The designator for an importing file whose own path has no plain + * string form (SPEC 14.19): resolution and membership over exact bytes. + */ + designatorForBytes(importerBytes: Uint8Array): DesignateSpecifier { + return (specifier) => { + const resolved = resolveImportSpecifierBytes(importerBytes, specifier); + if (resolved === null) { + return { kind: "outside-root" }; + } + // SPEC 2.1: `DIR/NAME.xspec` designates `DIR/NAME.mdx`. + const designated = new Uint8Array( + resolved.length - XSPEC_SUFFIX_LENGTH + MDX_SUFFIX_BYTES.length, + ); + designated.set( + resolved.subarray(0, resolved.length - XSPEC_SUFFIX_LENGTH), + ); + designated.set(MDX_SUFFIX_BYTES, resolved.length - XSPEC_SUFFIX_LENGTH); + return SpecSourceDomain.memberDesignation( + this.byKey.get(pathTextKey(pathTextOf(designated))), + () => renderPathText(pathTextOf(designated)), + ); + }; + } +} + /** SPEC 2.1: the compiler-provided names an import may never bind. */ const COMPILER_PROVIDED_NAMES: ReadonlySet<string> = new Set([ "S", @@ -173,18 +383,21 @@ function boundIdentifiers(clause: ts.ImportClause | undefined): string[] { /** * Analyze and validate one file's spec-module imports (SPEC 2.1 → - * 14.15). `specPaths` is the set of discovered spec-source paths (SPEC - * 7.1): an import must designate one of them — whether the designated - * file parses does not matter here (references through it report as - * unresolved, SPEC 14.20, 14.5–14.7). Each invalid import yields exactly - * one 14.15 finding listing its defects; an identifier bound by more - * than one import (SPEC 2.1: no two imports in a file may bind the same - * identifier) yields ONE 14.15 finding locating every colliding - * declaration, the first included (SPEC 14 location cardinality). + * 14.15). `designate` resolves an in-form specifier against the importing + * file and answers membership over the entire discovered spec-source set + * (SPEC 7.1, `SpecSourceDomain`): an import must designate a discovered + * member — whether the designated file parses does not matter here + * (references through it report as unresolved, SPEC 14.20, 14.5–14.7), + * and a member whose own path is invalid (SPEC 14.19) is designated + * validly, its identities all undefined (SPEC 11.2). Each invalid import + * yields exactly one 14.15 finding listing its defects; an identifier + * bound by more than one import (SPEC 2.1: no two imports in a file may + * bind the same identifier) yields ONE 14.15 finding locating every + * colliding declaration, the first included (SPEC 14 cardinality). */ export function analyzeSpecImports( document: SpecDocument, - specPaths: ReadonlySet<string>, + designate: DesignateSpecifier, ): SpecImportModel { const imports: SpecImport[] = []; const bindings = new Map<string, SpecImportBinding>(); @@ -254,24 +467,29 @@ export function analyzeSpecImports( ); } let targetPath: string | null = null; + let targetFile: PathText | null = null; + let undefinedTarget: PathText | null = null; if (relative && specifier.endsWith(XSPEC_SUFFIX)) { - const resolved = resolveImportSpecifier(document.path, specifier); - if (resolved === null) { + const designation = designate(specifier); + if (designation.kind === "outside-root") { defects.push( `the specifier ${JSON.stringify(specifier)} resolves outside ` + `the workspace root`, ); + } else if (designation.kind === "undiscovered") { + defects.push( + `the designated file ${JSON.stringify(designation.designated)} ` + + `is not a discovered source file of a configured spec group`, + ); + } else if (designation.kind === "defined-member") { + targetPath = designation.path; + targetFile = designation.path; } else { - // SPEC 2.1: `DIR/NAME.xspec` designates `DIR/NAME.mdx`. - const designated = resolved.slice(0, -XSPEC_SUFFIX.length) + ".mdx"; - if (specPaths.has(designated)) { - targetPath = designated; - } else { - defects.push( - `the designated file ${JSON.stringify(designated)} is not a ` + - `discovered source file of a configured spec group`, - ); - } + // SPEC 14.19/11.2: a discovered member whose path is invalid is + // designated validly — no 14.15 — while its identities are all + // undefined, so references rooted at this binding never resolve. + targetFile = designation.file; + undefinedTarget = designation.file; } } @@ -298,10 +516,12 @@ export function analyzeSpecImports( `specifier ending in ".xspec" that designates a discovered ` + `spec-group file, e.g. import BASE from "./BASE.xspec" ` + `(SPEC 2.1, 14.15)`, - [{ file: document.path, range: statement.range }], + [{ file: document.file, range: statement.range }], ), ); targetPath = null; + targetFile = null; + undefinedTarget = null; } // SPEC 2.1: no two imports in a file may bind the same identifier — @@ -314,8 +534,12 @@ export function analyzeSpecImports( declarationsByName.set(name, [statement]); bindings.set( name, - valid && targetPath !== null && name === clause?.name?.text - ? { kind: "module", targetPath } + valid && name === clause?.name?.text + ? targetPath !== null + ? { kind: "module", targetPath } + : undefinedTarget !== null + ? { kind: "undefined-module", modulePath: undefinedTarget } + : { kind: "poisoned" } : { kind: "poisoned" }, ); } else if (!declared.includes(statement)) { @@ -335,6 +559,7 @@ export function analyzeSpecImports( end: specifierLiteral.getEnd(), }), targetPath, + targetFile, valid, }); } @@ -354,7 +579,7 @@ export function analyzeSpecImports( `by ${String(declared.length)} imports in this file — no two ` + `imports in an xspec source file may bind the same identifier; ` + `rename all but one binding (SPEC 2.1, 14.15)`, - declared.map((decl) => ({ file: document.path, range: decl.range })), + declared.map((decl) => ({ file: document.file, range: decl.range })), ), ); bindings.set(name, { kind: "poisoned" }); @@ -497,8 +722,39 @@ export interface SpecReferenceModel { type ResolvedReference = | { readonly outcome: "reference"; readonly reference: SpecReference } | { readonly outcome: "finding"; readonly finding: Finding } + | { + /** + * A chain rooted at a valid import of a member whose path is + * invalid (SPEC 14.19): every identity of that file is undefined + * (SPEC 11.2), so the reference never resolves — the caller reports + * its 14.5/14.6 with the span rules of its construct kind. + */ + readonly outcome: "undefined-target"; + readonly modulePath: PathText; + readonly segments: readonly string[]; + readonly span: TextSpan; + } | { readonly outcome: "masked" }; +/** A human description of an undefined-member target (messages only). */ +function describeUndefinedTarget( + modulePath: PathText, + segments: readonly string[], +): string { + const display = renderPathText(modulePath); + if (segments.length === 0) { + // SPEC 2.2: the module itself targets that file's root node. + return `the root node of ${JSON.stringify(display)}`; + } + return JSON.stringify(`${display}#${segments.join(".")}`); +} + +/** The SPEC 14.19/11.2 reason an undefined-member reference never resolves. */ +const UNDEFINED_TARGET_REASON = + `no identity of the designated file is defined because its own path is ` + + `invalid (SPEC 14.19, 11.2); rename that file to a valid source path or ` + + `retarget the reference`; + /** * Extract the file's references (SPEC 2.2, 2.3) through the shared * static-reference analyzer (SPEC 2.4). Every `d` reference and @@ -548,7 +804,7 @@ class ReferenceAnalyzer { private addFinding(range: ByteRange, message: string): void { this.findings.push( - locatedFinding(8, message, [{ file: this.document.path, range }]), + locatedFinding(8, message, [{ file: this.document.file, range }]), ); } @@ -620,6 +876,24 @@ class ReferenceAnalyzer { references.push(resolved.reference); } else if (resolved.outcome === "finding") { this.findings.push(resolved.finding); + } else if (resolved.outcome === "undefined-target") { + // SPEC 14.5: a d reference that does not resolve — here into a + // member whose identities are all undefined (SPEC 14.19, 11.2). + // The finding spans the reference's own expression (SPEC 14). + this.findings.push( + locatedFinding( + 5, + `unknown dependency: the d reference to ` + + `${describeUndefinedTarget(resolved.modulePath, resolved.segments)} ` + + `does not resolve — ${UNDEFINED_TARGET_REASON} (SPEC 2.2, 14.5)`, + [ + { + file: this.document.file, + range: translate.range(resolved.span), + }, + ], + ), + ); } } return references; @@ -694,6 +968,20 @@ class ReferenceAnalyzer { } if (resolved.outcome === "finding") { this.findings.push(resolved.finding); + } else if (resolved.outcome === "undefined-target") { + // SPEC 14.6: a text(...) reference that does not resolve — here + // into a member whose identities are all undefined (SPEC 14.19, + // 11.2). An embedding-form finding's range is the full braced + // container — the span its occurrence would occupy (SPEC 14, 5.7). + this.findings.push( + locatedFinding( + 6, + `unknown text target: the text(...) reference to ` + + `${describeUndefinedTarget(resolved.modulePath, resolved.segments)} ` + + `does not resolve — ${UNDEFINED_TARGET_REASON} (SPEC 2.3, 14.6)`, + [{ file: this.document.file, range: embedding.range }], + ), + ); } return null; } @@ -719,7 +1007,7 @@ class ReferenceAnalyzer { `invalid argument: ${classified.reason} — ${expectation}`, [ { - file: this.document.path, + file: this.document.file, range: containerRange ?? translate.range(classified.span), }, ], @@ -745,13 +1033,25 @@ class ReferenceAnalyzer { `import in this file binds — ${expectation}`, [ { - file: this.document.path, + file: this.document.file, range: containerRange ?? translate.range(classified.span), }, ], ), }; } + if (binding.kind === "undefined-module") { + // SPEC 14.19/11.2: the import is valid, but every identity of the + // designated file is undefined — the reference never resolves. The + // condition (14.5/14.6) is decidable per file; the caller reports it + // with its construct kind's span rules (SPEC 14, 5.7). + return { + outcome: "undefined-target", + modulePath: binding.modulePath, + segments: classified.segments.map((segment) => segment.name), + span: classified.span, + }; + } if (binding.kind === "poisoned") { // Masked (SPEC 14): the import's 14.15 makes the target // undetectable; no separate condition, no edge. diff --git a/src/workspace/baseline.ts b/src/workspace/baseline.ts index 0dcc65c..226dd59 100644 --- a/src/workspace/baseline.ts +++ b/src/workspace/baseline.ts @@ -385,7 +385,24 @@ export async function resolveBaseline( } oidForSource.set(sourcePath, oid); } - const sourceBlobs = await readBlobs(root, [...oidForSource.values()]); + // SPEC 14.19/11.2: invalid-path sources at the ref are analyzed too — + // their findings make the baseline fail resolution like any others — + // addressed by their exact path bytes. + const oidForInvalidSource = new Map<string, string>(); + for (const source of classification.invalidSources) { + const oid = oidByPath.get(byteKey(Buffer.from(source.bytes))); + if (oid === undefined) { + // Impossible: classified sources come from the same listing. + throw new Error( + "xspec internal error: baseline invalid-path source without a blob", + ); + } + oidForInvalidSource.set(byteKey(Buffer.from(source.bytes)), oid); + } + const sourceBlobs = await readBlobs(root, [ + ...oidForSource.values(), + ...oidForInvalidSource.values(), + ]); if (sourceBlobs === null) return unreadable; const analysis = await analyzeWorkspaceContent(configParse.configuration, { @@ -400,6 +417,16 @@ export async function resolveBaseline( } return Promise.resolve(bytes); }, + readInvalidSource: (pathBytes) => { + const oid = oidForInvalidSource.get(byteKey(Buffer.from(pathBytes))); + const bytes = oid === undefined ? undefined : sourceBlobs.get(oid); + if (bytes === undefined) { + throw new Error( + "xspec internal error: baseline invalid-path blob not preloaded", + ); + } + return Promise.resolve(bytes); + }, loadJournal: () => Promise.resolve(journal), }); // SPEC 6.3: baseline content that cannot be parsed and validated as a diff --git a/src/workspace/pipeline.ts b/src/workspace/pipeline.ts index b373dab..6dd8d89 100644 --- a/src/workspace/pipeline.ts +++ b/src/workspace/pipeline.ts @@ -23,8 +23,12 @@ // file contributes its single 14.20 finding and nothing else, and // references into it report as unresolved (14.5–14.7) during graph // resolution; -// - invalid source paths (14.19) make the file no source: it is skipped with -// its finding. +// - a discovered file whose own path is invalid (14.19) is no source of the +// graph — no identity of it is defined (SPEC 11.2) — but it keeps its +// parse-local structure: it is parsed and per-file validated beside its +// 14.19 finding, its references resolved on their own terms (the graph +// reports their 14.5–14.7), and its analysis carried separately +// (`invalidPathSpecs`/`invalidPathCode`) for the surfaces of 11.3–11.5. // // The journal is loaded here because it is a validation subject (14.13) and // a hash input (SPEC 5.4, 5.5): a workspace whose journal is malformed fails @@ -32,11 +36,12 @@ import * as fsp from "node:fs/promises"; import * as path from "node:path"; +import { Buffer } from "node:buffer"; import { compareBytes } from "../core/bytes.js"; import type { CodeAnalysis } from "../core/code-analysis.js"; import { analyzeCodeSource } from "../core/code-analysis.js"; import type { Configuration } from "../core/config.js"; -import type { SourceClassification } from "../core/discovery.js"; +import type { InvalidSource, SourceClassification } from "../core/discovery.js"; import { markdownEmitDestinations } from "../core/discovery.js"; import type { Finding } from "../core/findings.js"; import { @@ -53,9 +58,11 @@ import type { NodeHashes } from "../core/hashes.js"; import { computeWorkspaceHashes } from "../core/hashes.js"; import { Journal } from "../core/journal.js"; import { parseSpecSource } from "../core/mdx.js"; +import type { PathText } from "../core/path-text.js"; import { analyzeSpecImports, analyzeSpecReferences, + SpecSourceDomain, } from "../core/spec-references.js"; import { WorkspaceTextModel } from "../core/text-model.js"; import type { LoadedWorkspace } from "./config.js"; @@ -72,6 +79,19 @@ export interface WorkspaceAnalysis { readonly specs: readonly SpecFileAnalysis[]; /** The parseable code sources' analyses, byte-ordered by path. */ readonly code: readonly CodeAnalysis[]; + /** + * Per-file analyses of parseable discovered sources whose own paths are + * invalid (SPEC 14.19), byte-ordered by path — structure is parse-local + * (SPEC 11.2), so these files are parsed and validated like any other + * while no identity of theirs is defined: they feed no graph nodes, no + * hashes, no journal or derived-file interaction, and no recorded + * inputs (their 14.19 findings gate every write, SPEC 12.1). Each + * `document.file` / `analysis.file` carries the real path; `path` is a + * never-rendered stand-in (core/mdx.ts, core/code-analysis.ts). + */ + readonly invalidPathSpecs: readonly SpecFileAnalysis[]; + /** The code-source counterpart of `invalidPathSpecs`. */ + readonly invalidPathCode: readonly CodeAnalysis[]; readonly graph: WorkspaceGraph; readonly textModel: WorkspaceTextModel; /** SPEC 5.5: the four hashes of every requirement node. */ @@ -81,7 +101,9 @@ export interface WorkspaceAnalysis { * SHA-256 (hex) of each discovered source's exact bytes as analyzed — * the graph data's recorded derivation inputs (SPEC 13.3; * core/graph-data.ts). Unreadable sources have no entry (their 14.20 - * finding fails validation before any store write). + * finding fails validation before any store write), and neither do + * invalid-path sources (SPEC 14.19: the finding gates every write, and + * recorded state never concerns such a file). */ readonly sourceHashes: ReadonlyMap<string, string>; /** @@ -118,6 +140,15 @@ export interface WorkspaceContent { * be read (reported as an unparseable source, SPEC 14.20). */ readonly readSource: (rel: string) => Promise<Uint8Array | null>; + /** + * Read one invalid-path discovered source's exact bytes (SPEC 14.19), + * addressed by its exact path bytes — such a path may have no plain + * string form (SPEC 12.0). Null when the content cannot be read + * (SPEC 14.20). Called only for `classification.invalidSources` + * entries, so content sourced from a workspace that passed `build`'s + * validations (which discovers none) may answer null unconditionally. + */ + readonly readInvalidSource: (bytes: Uint8Array) => Promise<Uint8Array | null>; /** * Load the journal (SPEC 6.1). Called only when analysis proceeds past * configuration errors — those precede all source analysis (SPEC 14). @@ -140,6 +171,7 @@ export async function analyzeWorkspace( return analyzeWorkspaceContent(configuration, { classification, readSource: (rel) => readSourceBytes(root, rel), + readInvalidSource: (bytes) => readInvalidSourceBytes(root, bytes), loadJournal: () => loadJournal(root), }); } @@ -171,6 +203,8 @@ export async function analyzeWorkspaceContent( markdownDestinations: new Set(), specs: [], code: [], + invalidPathSpecs: [], + invalidPathCode: [], graph, textModel, hashes: new Map(), @@ -194,6 +228,14 @@ export async function analyzeWorkspaceContent( const specPaths = new Set( classification.specSources.map((source) => source.path), ); + // SPEC 2.1/7.1: import designation consults the ENTIRE discovered + // spec-source set — an import designating a discovered member whose own + // path is invalid (SPEC 14.19) is valid, the member's identities all + // undefined (SPEC 11.2, 14.5–14.7). + const specDomain = new SpecSourceDomain( + specPaths, + classification.invalidSources.filter((source) => source.kind === "spec"), + ); // SPEC 7.3: destinations exist exactly while emission is enabled — // classification by configuration alone, whether or not emission has run. const markdownDestinations = markdownEmitDestinations( @@ -221,7 +263,10 @@ export async function analyzeWorkspaceContent( continue; } const document = parsed.document; - const imports = analyzeSpecImports(document, specPaths); + const imports = analyzeSpecImports( + document, + specDomain.designatorFor(source.path), + ); const references = analyzeSpecReferences(document, imports); findings.push(...document.findings); findings.push(...imports.findings); @@ -257,7 +302,7 @@ export async function analyzeWorkspaceContent( } sourceHashes.set(source.path, sha256Hex(bytes)); const analyzed = analyzeCodeSource(source.path, bytes, { - specPaths, + designate: specDomain.designatorFor(source.path), markdownDestinations, }); if (analyzed.kind === "unparseable") { @@ -268,12 +313,87 @@ export async function analyzeWorkspaceContent( code.push(analyzed.analysis); } + // --- invalid-path sources (SPEC 14.19, 11.2) -------------------------- + // + // A discovered file whose own path is invalid keeps its parse-local + // structure: it is parsed and per-file validated like any other source + // — its located findings (marked byte-form location files) report + // beside its 14.19 — while no identity of it is defined: it enters no + // graph node, no hash, no recorded input, and no derived-file + // derivation (its 14.19 gates every write, SPEC 12.1). An unparseable + // one reports its 14.20 beside the 14.19, its contents masked (SPEC 14). + const invalidPathSpecs: SpecFileAnalysis[] = []; + const invalidPathCode: CodeAnalysis[] = []; + for (const source of classification.invalidSources) { + const bytes = await content.readInvalidSource(source.bytes); + if (bytes === null) { + findings.push(unreadableSourceFinding(source.path)); + continue; + } + // The analyzers' identity-space path: a deterministic stand-in (the + // lossily decoded path bytes) — never rendered, never resolved + // against; `source.path` is the real path (core/mdx.ts SpecDocument). + const standIn = lossyDecoder.decode(source.bytes); + if (source.kind === "spec") { + try { + const parsed = parseSpecSource(standIn, bytes, source.path); + if (parsed.kind === "unparseable") { + findings.push(parsed.finding); + continue; + } + const document = parsed.document; + const imports = analyzeSpecImports( + document, + specDomain.designatorForBytes(source.bytes), + ); + const references = analyzeSpecReferences(document, imports); + findings.push(...document.findings); + findings.push(...imports.findings); + findings.push(...references.findings); + invalidPathSpecs.push({ document, imports, references }); + } catch (error) { + // SPEC 14.20: overflow-deep nesting, as in the valid-source loop. + if (!(error instanceof RangeError)) throw error; + findings.push( + locatedFinding( + 20, + `unparseable source: not well-formed MDX — the file's nesting ` + + `exceeds what the analyzer can process, so no location inside ` + + `it can be analyzed; simplify or split the file (SPEC 14.20)`, + [{ file: source.path, range: { start: 0, end: 0 } }], + ), + ); + } + } else { + const analyzed = analyzeCodeSource( + standIn, + bytes, + { + designate: specDomain.designatorForBytes(source.bytes), + markdownDestinations, + }, + source.path, + ); + if (analyzed.kind === "unparseable") { + findings.push(analyzed.finding); + continue; + } + findings.push(...analyzed.analysis.findings); + invalidPathCode.push(analyzed.analysis); + } + } + // --- journal (SPEC 6.1, 5.4 → 14.13) ---------------------------------- const journal = await content.loadJournal(); findings.push(...journal.findings); // --- graph, text model, hashes (SPEC 5; conditions 14.5–14.7, 14.9) --- - const graph = buildWorkspaceGraph({ specs, code }); + const graph = buildWorkspaceGraph({ + specs, + code, + invalidPathSpecs, + invalidPathCode, + }); findings.push(...graph.findings); const textModel = new WorkspaceTextModel(graph.embeddingResolver()); // Total even over invalid workspaces (core/hashes.ts); only valid @@ -285,6 +405,8 @@ export async function analyzeWorkspaceContent( markdownDestinations, specs, code, + invalidPathSpecs, + invalidPathCode, graph, textModel, hashes, @@ -321,6 +443,14 @@ export function workspaceInputsOf( }; } +/** + * Deterministic lossy decoding for the identity-space stand-in path of an + * invalid-path source (SPEC 14.19): invalid sequences become U+FFFD per + * the Unicode maximal-subpart rule — never rendered, only a per-analysis + * map key. + */ +const lossyDecoder = new TextDecoder("utf-8"); + /** * Read one discovered source's exact bytes from the filesystem, null when * unreadable — the reader `analyzeWorkspace` hands the shared body. @@ -336,6 +466,25 @@ async function readSourceBytes( } } +/** + * Read one invalid-path discovered source's exact bytes (SPEC 14.19) — + * such a workspace-relative path may have no plain string form, so the + * filesystem is addressed with the exact bytes (`/`-separated, as the + * walk produced them; every platform Node supports accepts `/` here). + */ +async function readInvalidSourceBytes( + root: string, + bytes: Uint8Array, +): Promise<Uint8Array | null> { + try { + return await fsp.readFile( + Buffer.concat([Buffer.from(root), Buffer.from("/"), Buffer.from(bytes)]), + ); + } catch { + return null; + } +} + /** * SPEC 14.20: a discovered source whose content cannot be read. On the * filesystem that means the file vanished (or became unreadable) between @@ -343,7 +492,7 @@ async function readSourceBytes( * last-write-wins territory; it was discovered, and its content cannot be * analyzed. */ -function unreadableSourceFinding(rel: string): Finding { +function unreadableSourceFinding(rel: PathText): Finding { // SPEC 14.20 locates in source; with no readable content, the failure // locates at the file start (range [0, 0)). return locatedFinding( From 37134c1c82ec34362907a87df9b2622294a806f5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 00:01:19 +0000 Subject: [PATCH 153/226] sdg(phase-10): record reference occurrences in core analysis and graph data Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 48 ++++------ src/core/code-analysis.ts | 23 ++++- src/core/graph-data.ts | 111 ++++++++++++++++++++-- src/core/graph.ts | 189 ++++++++++++++++++++++++++++++++++---- 4 files changed, 309 insertions(+), 62 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index aee753d..099adca 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -105,35 +105,25 @@ these files from `invalidPathSpecs`/`invalidPathCode`; B2's occurrence recording for them hooks into the graph's invalid-path resolution pass, source datum explicitly unavailable.) -### B2. Record reference occurrences in core analysis and graph data - -SPEC 5.7, 13.3, 12.7 (occurrence record form), 11.2 (existence/source-datum -rules). No occurrence concept exists in `src/`. In `core`, compute and carry, -and in graph data persist, reference occurrences: - -- One occurrence per textual spelling of a dependency-kind reference whose - target resolves: each `d` array entry separately; each MDX `{text(...)}` - embedding; each TS `text(...)` call; each TS dependency marker. Duplicates - collapsing to one edge remain distinct occurrences. Constructs recording no - edge record none (imports, type-only bindings, shadowed chains, dynamic or - unresolving spellings). -- Record: referencing file; own range — exact per kind: a `d` entry's own - expression; an MDX embedding the entire braced container, opening through - closing brace (`SpecEmbedding.range`, `src/core/mdx.ts`, already holds it); a - TS `text(...)` occurrence the entire call expression, callee through closing - parenthesis (record this span in `src/core/code-analysis.ts` — today only the - argument chain's span exists); a marker the bare chain, terminator excluded - (`CodeReference.range` holds it) — edge kind; source graph node as one datum - (identity plus that node's own range; explicitly unavailable when 11.2 leaves - the containing node's identity undefined — representable now, consumed by - Stage B surfaces); resolved target's identity. -- Total order: referencing file path bytes, then range start, then range end. -- Persist in graph data (`src/core/graph-data.ts` stored shape, - `src/core/graph.ts`) so 13.3's "graph data contains … reference occurrences" - holds and refresh round-trips them byte-deterministically. - -Verify: T5.7-1..4 (`section-5.7.test.ts`) via the Stage B surfaces; T13.3-1/2 -arms once B4 lands. +(B2 landed: reference occurrences are computed in the graph and persisted. +`WorkspaceGraph.occurrences` (`src/core/graph.ts`) holds one +`ReferenceOccurrence` per resolving dependency-kind spelling in occurrence +order (file path bytes via `comparePathTexts`, then range start, then end): +`file` is the referencing file's real `PathText`; `range` is the exact 5.7 +span (a `d` entry's own expression; an MDX embedding's full braced +container; a TS `text(...)` call's whole call expression via the new +`CodeReference.occurrenceRange`, `src/core/code-analysis.ts`; a marker's +bare chain); `kind` is the `DependencyEdgeKind`; `source` is the source +node's IDENTITY or null (= the 11.2 explicitly-unavailable datum: sections +without a usable identity, every node of an invalid-path file — those +occurrences are recorded in the graph's invalid-path resolution pass); +`target` the resolved identity. The source datum's RANGE half joins through +the node itself: a requirement source's is `RequirementNode.section.range` +(root = whole file); a code source's range is B3's deliverable — B4 renders +`{"identity", "range"}` from the graph node, or `{"unavailable": true}` for +null. Persisted as `GraphSnapshot.occurrences` (`StoredOccurrence`, +`src/core/graph-data.ts`, format version 3): valid workspaces only, so +stored `file` is a plain string; round-trips byte-deterministically.) ### B3. Compute code-location source ranges diff --git a/src/core/code-analysis.ts b/src/core/code-analysis.ts index 45a4ce1..7b45fac 100644 --- a/src/core/code-analysis.ts +++ b/src/core/code-analysis.ts @@ -151,6 +151,13 @@ export interface CodeReference { readonly spelling: ReferenceSpelling; /** The reference expression's bytes (finding locations, SPEC 14.7). */ readonly range: ByteRange; + /** + * The occurrence span (SPEC 5.7), exact per kind: for a marker the bare + * reference chain alone, exclusive of any statement terminator (equal to + * `range`); for a `text(...)` call the entire call expression, callee + * through closing parenthesis, argument included. + */ + readonly occurrenceRange: ByteRange; } /** The analysis of one parseable code source. */ @@ -516,12 +523,18 @@ class CodeAnalyzer { return this.path; } - /** Build one recorded reference from a classified static chain. */ + /** + * Build one recorded reference from a classified static chain. + * `occurrenceRange` is the SPEC 5.7 occurrence span — for a marker the + * chain itself (omit it), for a `text(...)` call the entire call + * expression, callee through closing parenthesis. + */ private chainReference( kind: "references" | "embeds", classified: ClassifiedChain, modulePath: string, location: string, + occurrenceRange?: ByteRange, ): CodeReference { const spanRange = (span: { readonly start: number; @@ -530,6 +543,7 @@ class CodeAnalyzer { start: this.offsets.byteOffset(span.start), end: this.offsets.byteOffset(span.end), }); + const range = spanRange(classified.span); return { kind, location, @@ -547,7 +561,8 @@ class CodeAnalyzer { accessRange: spanRange(segment.accessSpan), })), }, - range: spanRange(classified.span), + range, + occurrenceRange: occurrenceRange ?? range, }; } @@ -1396,13 +1411,15 @@ class CodeAnalyzer { return; } // SPEC 4.3: text(node) records an `embeds` edge from the calling - // code location. + // code location. Its occurrence spans the entire call expression, + // callee through closing parenthesis (SPEC 5.7). this.references.push( this.chainReference( "embeds", classified, rootBinding.target.path, this.attributionOf(call), + this.rangeOf(call), ), ); } diff --git a/src/core/graph-data.ts b/src/core/graph-data.ts index c175ff9..c2fe5c4 100644 --- a/src/core/graph-data.ts +++ b/src/core/graph-data.ts @@ -3,10 +3,11 @@ // Pure core (IMPLEMENTATION Architecture: serialization is core — // deterministic, I/O-free; storage I/O is the workspace layer's, // src/workspace/graph-data.ts): xspec maintains graph data under `.xspec/`, -// containing requirement nodes, code locations, edges by kind, source -// ranges (SPEC 1.7), all four hashes (SPEC 5.5), coverage attributes -// (SPEC 2.5), tags (SPEC 2.6), and the paths of the derived files most -// recently generated (SPEC 13.3, 13.4). This module defines that content: +// containing requirement nodes, code locations, edges by kind, reference +// occurrences (SPEC 5.7), source ranges (SPEC 1.7), all four hashes +// (SPEC 5.5), coverage attributes (SPEC 2.5), tags (SPEC 2.6), and the +// paths of the derived files most recently generated (SPEC 13.3, 13.4). +// This module defines that content: // // - the stored model — a plain-data snapshot of the assembled workspace // graph (./graph.ts) plus the recorded derived-file paths; @@ -48,7 +49,12 @@ import type { ByteRange } from "./bytes.js"; import { compareBytes } from "./bytes.js"; import type { JsonValue } from "./canonical-json.js"; import { canonicalJson } from "./canonical-json.js"; -import type { GraphEdge, GraphEdgeKind, WorkspaceGraph } from "./graph.js"; +import type { + DependencyEdgeKind, + GraphEdge, + GraphEdgeKind, + WorkspaceGraph, +} from "./graph.js"; import type { NodeHashes } from "./hashes.js"; import type { WorkspaceTextModel } from "./text-model.js"; @@ -59,8 +65,9 @@ export const GRAPH_DATA_PATH = ".xspec/graph.json"; * The stored format version: a parsed file of any other version is * malformed (parse yields null), so it reads as not matching the current * sources and configuration and is refreshed or rebuilt (SPEC 13.3). + * Version 3 added the reference occurrences (SPEC 5.7, 13.3). */ -const GRAPH_DATA_VERSION = 2; +const GRAPH_DATA_VERSION = 3; /** One recorded derivation input: a discovered source and its fingerprint. */ export interface StoredSourceInput { @@ -133,6 +140,28 @@ export interface StoredCodeLocation { readonly path: string; } +/** + * One stored reference occurrence (SPEC 13.3, 5.7). The snapshot is built + * only over workspaces passing `build`'s validations (core/build.ts), so + * the referencing file's path is always a plain string (SPEC 14.19) and + * the source graph node's identity is always defined (SPEC 11.2) — the + * null arm is carried for shape totality. The source node's own range + * (the reported datum's other half, SPEC 5.7) travels with the stored + * node itself. + */ +export interface StoredOccurrence { + /** Workspace-relative `/`-separated referencing file path (SPEC 1.5). */ + readonly file: string; + /** SPEC 5.7: the occurrence's own span, exact per kind. */ + readonly range: ByteRange; + /** The recorded edge kind (SPEC 5.2): depends, embeds, or references. */ + readonly kind: DependencyEdgeKind; + /** The source graph node's identity — null where undefined (SPEC 11.2). */ + readonly source: string | null; + /** The resolved target's identity (SPEC 1.5). */ + readonly target: string; +} + /** * The graph-content part of the store: a pure function of the current * sources, configuration, and journal (SPEC 13.3) — the part the @@ -145,6 +174,11 @@ export interface GraphSnapshot { readonly codeLocations: readonly StoredCodeLocation[]; /** The collapsed edge set in (source, kind, target) order (SPEC 5.2). */ readonly edges: readonly GraphEdge[]; + /** + * Every reference occurrence (SPEC 5.7, 13.3) in occurrence order: + * referencing file path bytes, then range start, then range end. + */ + readonly occurrences: readonly StoredOccurrence[]; } /** The complete stored graph data (SPEC 13.3). */ @@ -167,7 +201,8 @@ export interface GraphData { * hashes (SPEC 13.3): every requirement node with its source range, * coverage attribute, tags, four hashes, and fully expanded own and * subtree text (SPEC 1.6 — recorded so the store answers the node report - * of SPEC 11/12.4 without re-deriving); every code location; every edge. + * of SPEC 11/12.4 without re-deriving); every code location; every edge; + * every reference occurrence (SPEC 5.7). * Deterministic: everything is emitted in the graph's own fixed order * (SPEC 12.0). `hashes` must be the computation over this same graph * (./hashes.ts covers every requirement node), `textModel` the model over @@ -208,7 +243,25 @@ export function buildGraphSnapshot( source: edge.source, target: edge.target, })); - return { requirements, codeLocations, edges }; + // SPEC 5.7/13.3: the reference occurrences, already in occurrence order. + // Only valid workspaces reach this derivation (core/build.ts), so every + // referencing file's path is a plain string (SPEC 14.19). + const occurrences = graph.occurrences.map((occurrence): StoredOccurrence => { + if (typeof occurrence.file !== "string") { + throw new Error( + `xspec internal error: an invalid-path file's occurrence reached ` + + `a stored snapshot (SPEC 14.19 fails build validation)`, + ); + } + return { + file: occurrence.file, + range: { start: occurrence.range.start, end: occurrence.range.end }, + kind: occurrence.kind, + source: occurrence.source, + target: occurrence.target, + }; + }); + return { requirements, codeLocations, edges, occurrences }; } /** @@ -334,6 +387,13 @@ export function serializeGraphData(data: GraphData): string { source: edge.source, target: edge.target, })), + occurrences: data.snapshot.occurrences.map((occurrence): JsonValue => ({ + file: occurrence.file, + range: { start: occurrence.range.start, end: occurrence.range.end }, + kind: occurrence.kind, + source: occurrence.source, + target: occurrence.target, + })), }; return canonicalJson(value); } @@ -393,17 +453,19 @@ export function parseGraphData(text: string): GraphData | null { const requirements = parseArray(raw["requirements"], parseRequirement); const codeLocations = parseArray(raw["codeLocations"], parseCodeLocation); const edges = parseArray(raw["edges"], parseEdge); + const occurrences = parseArray(raw["occurrences"], parseOccurrence); if ( inputs === null || derivedFiles === null || requirements === null || codeLocations === null || - edges === null + edges === null || + occurrences === null ) { return null; } return { - snapshot: { requirements, codeLocations, edges }, + snapshot: { requirements, codeLocations, edges, occurrences }, inputs, derivedFiles, }; @@ -582,3 +644,32 @@ function parseEdge(value: unknown): GraphEdge | null { } return { kind: kind as GraphEdgeKind, source, target }; } + +/** SPEC 5.7: the dependency edge kinds occurrences record. */ +const OCCURRENCE_KINDS: ReadonlySet<string> = new Set([ + "depends", + "embeds", + "references", +]); + +function parseOccurrence(value: unknown): StoredOccurrence | null { + if (!isRecord(value)) { + return null; + } + const file = value["file"]; + const kind = value["kind"]; + const source = value["source"]; + const target = value["target"]; + const range = parseRange(value["range"]); + if ( + typeof file !== "string" || + typeof kind !== "string" || + !OCCURRENCE_KINDS.has(kind) || + (source !== null && typeof source !== "string") || + typeof target !== "string" || + range === null + ) { + return null; + } + return { file, range, kind: kind as DependencyEdgeKind, source, target }; +} diff --git a/src/core/graph.ts b/src/core/graph.ts index ab1884b..5f4157d 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -16,6 +16,9 @@ // and 14.7. Masking (SPEC 14): a reference into an unparseable file // reports as unresolved here while the file's internal conditions stay // masked behind its own 14.20; +// - reference occurrences (SPEC 5.7) — one record per textual spelling of +// a dependency-kind reference whose target resolves, in occurrence +// order: the positions behind the collapsed edge set; // - cycles (SPEC 5.3 → 14.9) — dependency cycles over the combined // `contains`+`depends`+`embeds` graph on requirement nodes (a // self-`depends`/self-`embeds` is a cycle of length one; a section @@ -38,7 +41,7 @@ import type { Finding, FindingLocation } from "./findings.js"; import { compareFindings, locatedFinding } from "./findings.js"; import type { SpecDocument, SpecEmbedding, SpecSection } from "./mdx.js"; import type { PathText } from "./path-text.js"; -import { pathTextKey, renderPathText } from "./path-text.js"; +import { comparePathTexts, pathTextKey, renderPathText } from "./path-text.js"; import type { ReferenceTarget, SpecImportModel, @@ -96,6 +99,48 @@ export interface GraphEdge { readonly target: string; } +/** SPEC 5.2/5.7: the dependency edge kinds — the kinds occurrences record. */ +export type DependencyEdgeKind = Exclude<GraphEdgeKind, "contains">; + +/** + * SPEC 5.7: one reference occurrence — one textual spelling of a + * dependency-kind reference whose target resolves (SPEC 11.2): each `d` + * array entry separately (2.2), each MDX `{text(...)}` embedding (2.3), + * each TypeScript `text(...)` call (4.3), and each TypeScript dependency + * marker (4.5). Edges are sets; occurrences are the positions behind them + * — duplicate references collapsing to a single edge each remain distinct + * occurrences. A construct that records no edge records no occurrence. + */ +export interface ReferenceOccurrence { + /** + * The referencing file's real path (SPEC 12.0, 12.7 path value form + * capable — the marked byte form for an invalid-path file's occurrence, + * SPEC 14.19; such occurrences arise only on failing workspaces). + */ + readonly file: PathText; + /** + * The occurrence's own span (SPEC 5.7), exact per kind: a `d` reference's + * own expression; an MDX embedding's entire braced container, opening + * brace through closing brace; a TS `text(...)` call's entire call + * expression, callee through closing parenthesis; a marker's bare + * reference chain, exclusive of any statement terminator. + */ + readonly range: ByteRange; + readonly kind: DependencyEdgeKind; + /** + * The source graph node's identity — null exactly where SPEC 11.2 leaves + * the containing node's identity undefined (a section without a usable + * identity; every node of an invalid-path file, SPEC 14.19): the source + * datum is then reported explicitly unavailable (SPEC 5.7). The datum's + * other half — the source node's own range (SPEC 1.7) — travels with the + * identified node itself (a requirement node's section range; a code + * location's range), joined at presentation (SPEC 11.3, 12.7). + */ + readonly source: string | null; + /** The resolved target's identity (SPEC 1.5) — always a requirement node. */ + readonly target: string; +} + /** One parsed spec source with its per-file analyses (T6–T8 outputs). */ export interface SpecFileAnalysis { readonly document: SpecDocument; @@ -145,6 +190,7 @@ interface GraphParts { readonly requirementNodes: readonly RequirementNode[]; readonly codeLocations: readonly CodeLocationNode[]; readonly edges: readonly GraphEdge[]; + readonly occurrences: readonly ReferenceOccurrence[]; readonly findings: readonly Finding[]; readonly requirementIndex: ReadonlyMap<string, RequirementNode>; readonly codeIndex: ReadonlyMap<string, CodeLocationNode>; @@ -156,16 +202,19 @@ interface GraphParts { * The assembled workspace graph (SPEC 5). Node lists are ordered by file * path (byte order, SPEC 12.0) and within a file by document order, the * root (or the whole-file code location) first; `edges` is the collapsed - * edge set (SPEC 5.2) ordered by (source, kind, target); `findings` holds - * the graph's own conditions — unresolved references (14.5–14.7) and - * cycles (14.9) — deterministically ordered. Everything else (structural, - * prop, import, argument, and code-usage findings) belongs to the - * per-file analyses this graph was built from. + * edge set (SPEC 5.2) ordered by (source, kind, target); `occurrences` + * holds every reference occurrence (SPEC 5.7) in occurrence order — + * referencing file path bytes, then range start, then range end; + * `findings` holds the graph's own conditions — unresolved references + * (14.5–14.7) and cycles (14.9) — deterministically ordered. Everything + * else (structural, prop, import, argument, and code-usage findings) + * belongs to the per-file analyses this graph was built from. */ export class WorkspaceGraph { readonly requirementNodes: readonly RequirementNode[]; readonly codeLocations: readonly CodeLocationNode[]; readonly edges: readonly GraphEdge[]; + readonly occurrences: readonly ReferenceOccurrence[]; readonly findings: readonly Finding[]; private readonly requirementIndex: ReadonlyMap<string, RequirementNode>; @@ -182,6 +231,7 @@ export class WorkspaceGraph { this.requirementNodes = parts.requirementNodes; this.codeLocations = parts.codeLocations; this.edges = parts.edges; + this.occurrences = parts.occurrences; this.findings = parts.findings; this.requirementIndex = parts.requirementIndex; this.codeIndex = parts.codeIndex; @@ -401,6 +451,22 @@ export function buildWorkspaceGraph( const findings: Finding[] = []; const resolution = new Resolver(parsedByPath, requirementIndex, idIndex); + // SPEC 5.7: one occurrence per textual spelling of a dependency-kind + // reference whose target resolves — recorded beside edge recording, so a + // construct that records no edge records no occurrence, while a resolving + // spelling whose SOURCE node has no defined identity (SPEC 11.2) still + // records one, its source datum explicitly unavailable (null). + const occurrences: ReferenceOccurrence[] = []; + const addOccurrence = ( + file: PathText, + range: ByteRange, + kind: DependencyEdgeKind, + source: string | null, + target: string, + ): void => { + occurrences.push({ file, range, kind, source, target }); + }; + // The reference spellings behind each requirement-side dependency edge // (SPEC 5.7 spans), keyed source → target: a cycle locates its full path // in source through every spelling recording a participating edge @@ -441,6 +507,17 @@ export function buildWorkspaceGraph( continue; } const source = sectionIndex.get(dependency.section); + // SPEC 5.7: a `d` reference occurrence spans that one reference's own + // expression — recorded whenever the target resolves, the source + // datum unavailable where the declaring section has no defined + // identity (SPEC 11.2). + addOccurrence( + spec.document.file, + dependency.reference.range, + "depends", + source?.identity ?? null, + resolved.node.identity, + ); if (source !== undefined) { addEdge("depends", source.identity, resolved.node.identity); // SPEC 5.7: a `d` reference's spelling spans its own expression. @@ -490,6 +567,17 @@ export function buildWorkspaceGraph( } embeddingIndex.set(embedded.embedding, resolved.node); const source = sectionIndex.get(embedded.embedding.section); + // SPEC 5.7: an MDX embedding occurrence spans the entire braced + // container, opening brace through closing brace — the innermost + // containing section (the root included) is its source, unavailable + // where that section has no defined identity (SPEC 11.2). + addOccurrence( + spec.document.file, + embedded.embedding.range, + "embeds", + source?.identity ?? null, + resolved.node.identity, + ); if (source !== undefined) { addEdge("embeds", source.identity, resolved.node.identity); // SPEC 5.7: an MDX embedding's spelling spans the entire braced @@ -530,6 +618,18 @@ export function buildWorkspaceGraph( continue; } addEdge(reference.kind, reference.location, resolved.node.identity); + // SPEC 5.7: a TS `text(...)` occurrence spans the entire call + // expression, callee through closing parenthesis; a marker occurrence + // spans the bare reference chain alone. The source is the attributed + // code location (SPEC 4.6), whose identity is always defined for a + // valid-path file (SPEC 11.2). + addOccurrence( + analysis.file, + reference.occurrenceRange, + reference.kind, + reference.location, + resolved.node.identity, + ); } } @@ -541,14 +641,22 @@ export function buildWorkspaceGraph( // its extracted references resolve against the defined identities, a // local reference (naming an ID in the invalid-path file itself) never // resolving, and each unresolved reference reports its 14.5/14.6/14.7 - // located in the file (marked byte form capable). References that DO - // resolve are finding-free; their occurrence recording (source datum - // explicitly unavailable, SPEC 5.7) is the occurrence machinery's. - const invalidPathFailure = ( + // located in the file (marked byte form capable). A reference that DOES + // resolve is finding-free and records its occurrence (SPEC 5.7), the + // source datum explicitly unavailable — no identity of the referencing + // file is defined (SPEC 14.19, 11.2). + const invalidPathOutcome = ( target: ReferenceTarget, - ): { readonly described: string; readonly reason: string } | null => { + ): + | { readonly ok: true; readonly node: RequirementNode } + | { + readonly ok: false; + readonly described: string; + readonly reason: string; + } => { if (target.kind === "local") { return { + ok: false, described: `${JSON.stringify(target.idPath)} in this file`, reason: `the reference names an ID in this file, and no identity of ` + @@ -560,8 +668,9 @@ export function buildWorkspaceGraph( target.modulePath, target.segments, ); - if (resolved.ok) return null; + if (resolved.ok) return resolved; return { + ok: false, described: describeExternal(target.modulePath, target.segments), reason: resolved.reason, }; @@ -569,13 +678,22 @@ export function buildWorkspaceGraph( for (const spec of invalidPathSpecs) { const file = spec.document.file; for (const dependency of spec.references.dependencies) { - const failure = invalidPathFailure(dependency.reference.target); - if (failure === null) continue; + const outcome = invalidPathOutcome(dependency.reference.target); + if (outcome.ok) { + addOccurrence( + file, + dependency.reference.range, + "depends", + null, + outcome.node.identity, + ); + continue; + } findings.push( locatedFinding( 5, - `unknown dependency: the d reference to ${failure.described} ` + - `does not resolve — ${failure.reason}; declare the target ` + + `unknown dependency: the d reference to ${outcome.described} ` + + `does not resolve — ${outcome.reason}; declare the target ` + `section or correct the reference (SPEC 2.2, 14.5)`, [{ file, range: dependency.reference.range }], ), @@ -583,15 +701,25 @@ export function buildWorkspaceGraph( } for (const embedded of spec.references.embeddings) { if (embedded.reference === null) continue; - const failure = invalidPathFailure(embedded.reference.target); - if (failure === null) continue; + const outcome = invalidPathOutcome(embedded.reference.target); + if (outcome.ok) { + // SPEC 5.7: the occurrence spans the entire braced container. + addOccurrence( + file, + embedded.embedding.range, + "embeds", + null, + outcome.node.identity, + ); + continue; + } // SPEC 14: an embedding-form finding's range is the full braced // container — the span its occurrence would occupy (5.7). findings.push( locatedFinding( 6, `unknown text target: the text(...) reference to ` + - `${failure.described} does not resolve — ${failure.reason}; ` + + `${outcome.described} does not resolve — ${outcome.reason}; ` + `declare the target section or correct the reference ` + `(SPEC 2.3, 14.6)`, [{ file, range: embedded.embedding.range }], @@ -605,7 +733,16 @@ export function buildWorkspaceGraph( reference.modulePath, reference.segments, ); - if (resolved.ok) continue; + if (resolved.ok) { + addOccurrence( + analysis.file, + reference.occurrenceRange, + reference.kind, + null, + resolved.node.identity, + ); + continue; + } const construct = reference.kind === "references" ? "marker" : "text(...) argument"; findings.push( @@ -633,6 +770,17 @@ export function buildWorkspaceGraph( compareBytes(a.target, b.target), ); + // SPEC 5.7: occurrence order is total and deterministic — referencing + // file path bytes (one byte order over both path forms, SPEC 12.0), then + // range start, then range end. Distinct occurrences occupy distinct + // spans, so no further tiebreak exists. + occurrences.sort( + (a, b) => + comparePathTexts(a.file, b.file) || + a.range.start - b.range.start || + a.range.end - b.range.end, + ); + // --- cycles (SPEC 5.3, 2.1 → 14.9) --------------------------------------- findings.push( ...dependencyCycleFindings( @@ -648,6 +796,7 @@ export function buildWorkspaceGraph( requirementNodes, codeLocations, edges, + occurrences, findings, requirementIndex, codeIndex, From 64eba7bb4837413d074d5db63d78030a4a3565b3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 00:26:17 +0000 Subject: [PATCH 154/226] sdg(phase-10): compute code-location source ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX_PLAN B3 (SPEC 1.7, 4.6): every code location now carries its source range. CodeUnit.range (core/code-analysis.ts) is the construct binding the unit's name: a variable declaration's own node (name through initializer, never the multi-declaration statement); dotted-namespace units share the chain's outermost declaration — the one construct binding them all; a named default export takes the construct's own range with the `export default ` prefix excluded (later modifiers like `async` kept, per the construct), an anonymous one's `default` unit the whole export declaration; `@N` units their own occurrence's construct. CodeLocationNode.range (core/graph.ts) carries it into the graph — a whole-file location spans 0..the file's byte length — and StoredCodeLocation.range persists it (core/graph-data.ts, stored format version 4; an old store reads as malformed, hence a mismatch, SPEC 13.3). Nothing presents the range yet: occurrence records (B4) and review payloads (C6) are the two presentation points (SPEC 1.7), so query edges/reachable stay bare identities. Verified: typecheck, build, format clean; every T1.7-2 fixture shape (plus async-default, braced-nested-namespace, and parenthesized named function expression exports) checked byte-exact against the built analyzer and graph at the core level — T1.7-2 itself goes green with B4/C6. Full suite 63 failed / 574 passed, the failing set member-for- member identical to the pre-change baseline (CI run 124): no regression, every failure in a pending B4-C7 task's scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 33 ++++++------ src/core/code-analysis.ts | 106 ++++++++++++++++++++++++++++++++++++++ src/core/graph-data.ts | 22 ++++++-- src/core/graph.ts | 16 +++++- 4 files changed, 155 insertions(+), 22 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 099adca..fca1c06 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -122,25 +122,24 @@ the node itself: a requirement source's is `RequirementNode.section.range` (root = whole file); a code source's range is B3's deliverable — B4 renders `{"identity", "range"}` from the graph node, or `{"unavailable": true}` for null. Persisted as `GraphSnapshot.occurrences` (`StoredOccurrence`, -`src/core/graph-data.ts`, format version 3): valid workspaces only, so +`src/core/graph-data.ts`, format version 4 since B3): valid workspaces only, so stored `file` is a plain string; round-trips byte-deterministically.) -### B3. Compute code-location source ranges - -SPEC 1.7 (code-location ranges), 4.6. `CodeUnit` (`src/core/code-analysis.ts`), -`CodeLocationNode` (`src/core/graph.ts`), and `StoredCodeLocation` -(`src/core/graph-data.ts`) carry no range. Compute and store one per code -location: whole-file = the entire file; named unit = the construct binding its -name — a function/class-valued variable declaration's unit spans its own name -through its initializer (not the multi-declaration statement); the nested units -of a dotted namespace name all share the single namespace declaration's range; a -named default export takes the exported construct's range, an anonymous one's -`default` unit the whole export declaration; `path#unit@N` takes its own -occurrence's construct. Presentation stays confined to exactly two outputs — -occurrence records (B2/B4) and review payloads (C6); everywhere else a code -location remains a bare identity (query edges/reachable unchanged). - -Verify: T1.7-2 (`section-1.7.test.ts`) once B4/C6 expose the ranges. +(B3 landed: every code location carries its SPEC 1.7 source range. +`CodeUnit.range` (`src/core/code-analysis.ts`) is the construct binding the +unit's name — a variable declaration's own node (name through initializer, +never the multi-declaration statement), dotted-namespace units sharing the +chain's outermost declaration, a named default export the construct's own +range with the `export default ` prefix excluded (`async`/`abstract` kept), +an anonymous one's `default` unit the whole export declaration, `@N` units +their own occurrence's construct. `CodeLocationNode.range` +(`src/core/graph.ts`) carries it into the graph (whole-file location = +`0..utf8Length(text)`), persisted as `StoredCodeLocation.range` +(`src/core/graph-data.ts`, format version 4 — an old store reads as +malformed → mismatch). Verified against T1.7-2's fixture offsets at the +core level; nothing presents the range yet — B4's occurrence records and +C6's review payloads are the two presentation points, and T1.7-2 goes +green with them.) ### B4. `xspec occurrences` and the shared 11.2 availability layer diff --git a/src/core/code-analysis.ts b/src/core/code-analysis.ts index 7b45fac..7d4fa6b 100644 --- a/src/core/code-analysis.ts +++ b/src/core/code-analysis.ts @@ -81,6 +81,17 @@ export interface CodeUnit { * same chain occurs more than once in the file (SPEC 4.6). */ readonly identity: string; + /** + * SPEC 1.7: the byte range of the construct binding the unit's name — a + * variable declaration's unit spans its own name through its + * initializer (not the enclosing multi-declaration statement), the + * nested units of a dotted namespace name all share the single + * namespace declaration's range, a named default export takes the + * exported construct's own range while an anonymous one's `default` + * unit takes the whole export declaration, and a `path#unit@N` takes + * its own occurrence's construct. + */ + readonly range: ByteRange; } /** One identifier a spec module import binds (SPEC 4). */ @@ -982,12 +993,80 @@ class CodeAnalyzer { identity: `${this.path}#${record.chain}` + (count > 1 ? `@${String(count)}` : ""), + range: this.unitRange(record.node), }; this.units.push(unit); this.unitByNode.set(record.node, unit); } } + /** + * SPEC 1.7: the byte range of the construct binding a unit's name. The + * construct is the recorded declaration node itself — a variable + * declaration node already spans its own name through its initializer, + * never the enclosing multi-declaration statement — with three + * carve-outs: the nested declarations a dotted namespace name nests in + * the AST all take the outermost declaration of the dotted chain (the + * one construct binding them all); a default export whose exported + * construct is named takes that construct's own range — for the merged + * declaration form (`export default function f() {}`) the declaration + * with its `export default ` modifier prefix excluded, for the + * `export default <expression>` form the named function or class + * expression's own span — while the `default` unit an anonymous + * exported construct derives takes the whole export declaration; and a + * `path#unit@N` simply carries its own occurrence's construct, which is + * the node recorded for it. + */ + private unitRange(node: ts.Node): ByteRange { + if (ts.isModuleDeclaration(node)) { + // A dotted name (`namespace A.B`) nests declarations: an inner one + // is its parent declaration's body. Climb to the chain's outermost + // declaration — the single construct binding every derived unit. + let outer: ts.ModuleDeclaration = node; + while ( + ts.isModuleDeclaration(outer.parent) && + outer.parent.body === outer + ) { + outer = outer.parent; + } + return this.rangeOf(outer); + } + if (ts.isExportAssignment(node)) { + const expression = stripParentheses(node.expression); + if ( + (ts.isFunctionExpression(expression) || + ts.isClassExpression(expression)) && + expression.name !== undefined + ) { + // A named exported construct: its own range (SPEC 1.7). + return this.rangeOf(expression); + } + // Anonymous: the whole export declaration, terminator included. + return this.rangeOf(node); + } + if ( + (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && + node.name !== undefined + ) { + const defaultModifier = (ts.getModifiers(node) ?? []).find( + (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword, + ); + if (defaultModifier !== undefined) { + // The merged named-default-export form: the construct's own range + // excludes the `export default ` prefix, beginning at the first + // construct token after the `default` modifier (SPEC 1.7) — any + // further modifier (`async`, `abstract`) is the construct's own. + return { + start: this.offsets.byteOffset( + firstTokenStartAfter(node, defaultModifier.end, this.sourceFile), + ), + end: this.offsets.byteOffset(node.getEnd()), + }; + } + } + return this.rangeOf(node); + } + // -- value-level use analysis (SPEC 4.3, 4.5 → 14.8, 14.11, 14.18) -------- private walk(node: ts.Node): void { @@ -1452,6 +1531,33 @@ function stripParentheses(expression: ts.Expression): ts.Expression { return current; } +/** + * The UTF-16 start of `node`'s first token lying entirely after + * `boundary` — used to exclude a leading `export default ` modifier + * prefix from a named construct's own range (SPEC 1.7). Children come in + * source order; a syntax list (the modifier list) is searched within, so + * a modifier following `default` (e.g. `async`) is found where the next + * sibling token would overshoot it. + */ +function firstTokenStartAfter( + node: ts.Node, + boundary: number, + sourceFile: ts.SourceFile, +): number { + for (const child of node.getChildren(sourceFile)) { + if (child.getEnd() <= boundary) continue; + if (child.kind === ts.SyntaxKind.SyntaxList) { + for (const member of child.getChildren(sourceFile)) { + if (member.getEnd() <= boundary) continue; + return member.getStart(sourceFile); + } + continue; // defensive: a list's end is its last member's end + } + return child.getStart(sourceFile); + } + return node.getStart(sourceFile); // defensive: boundary inside the node +} + /** * SPEC 4.6: the name a construct statically binds to executable code, or * null when the construct is not a named code unit. The construct list is diff --git a/src/core/graph-data.ts b/src/core/graph-data.ts index c2fe5c4..344776f 100644 --- a/src/core/graph-data.ts +++ b/src/core/graph-data.ts @@ -65,9 +65,10 @@ export const GRAPH_DATA_PATH = ".xspec/graph.json"; * The stored format version: a parsed file of any other version is * malformed (parse yields null), so it reads as not matching the current * sources and configuration and is refreshed or rebuilt (SPEC 13.3). - * Version 3 added the reference occurrences (SPEC 5.7, 13.3). + * Version 3 added the reference occurrences (SPEC 5.7, 13.3); version 4 + * added the code-location source ranges (SPEC 1.7). */ -const GRAPH_DATA_VERSION = 3; +const GRAPH_DATA_VERSION = 4; /** One recorded derivation input: a discovered source and its fingerprint. */ export interface StoredSourceInput { @@ -138,6 +139,12 @@ export interface StoredCodeLocation { readonly identity: string; /** Workspace-relative `/`-separated code file path (SPEC 1.5). */ readonly path: string; + /** + * SPEC 1.7: the location's source range — the entire file for a + * whole-file location, the construct binding the unit's name for a + * named unit. + */ + readonly range: ByteRange; } /** @@ -237,6 +244,7 @@ export function buildGraphSnapshot( const codeLocations = graph.codeLocations.map((node): StoredCodeLocation => ({ identity: node.identity, path: node.path, + range: { start: node.range.start, end: node.range.end }, })); const edges = graph.edges.map((edge): GraphEdge => ({ kind: edge.kind, @@ -381,6 +389,7 @@ export function serializeGraphData(data: GraphData): string { codeLocations: data.snapshot.codeLocations.map((location): JsonValue => ({ identity: location.identity, path: location.path, + range: { start: location.range.start, end: location.range.end }, })), edges: data.snapshot.edges.map((edge): JsonValue => ({ kind: edge.kind, @@ -621,10 +630,15 @@ function parseCodeLocation(value: unknown): StoredCodeLocation | null { } const identity = value["identity"]; const path = value["path"]; - if (typeof identity !== "string" || typeof path !== "string") { + const range = parseRange(value["range"]); + if ( + typeof identity !== "string" || + typeof path !== "string" || + range === null + ) { return null; } - return { identity, path }; + return { identity, path, range }; } function parseEdge(value: unknown): GraphEdge | null { diff --git a/src/core/graph.ts b/src/core/graph.ts index 5f4157d..63072bf 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -34,7 +34,7 @@ // edges. Only valid workspaces ever surface graph content (SPEC 12.1, // 13.3). -import { compareBytes, sortByBytes } from "./bytes.js"; +import { compareBytes, sortByBytes, utf8Length } from "./bytes.js"; import type { ByteRange } from "./bytes.js"; import type { CodeAnalysis } from "./code-analysis.js"; import type { Finding, FindingLocation } from "./findings.js"; @@ -73,6 +73,14 @@ export interface CodeLocationNode { readonly identity: string; /** Workspace-relative `/`-separated code file path (SPEC 1.5). */ readonly path: string; + /** + * SPEC 1.7: the location's source range — the entire file for a + * whole-file location, the construct binding the unit's name for a + * named unit (CodeUnit.range). Presented in exactly two outputs — + * occurrence records (5.7, 11.3) and review payloads (10.7); everywhere + * else a code location remains a bare identity (SPEC 1.7). + */ + readonly range: ByteRange; } export type GraphNode = RequirementNode | CodeLocationNode; @@ -409,6 +417,10 @@ export function buildWorkspaceGraph( kind: "code", identity: analysis.path, path: analysis.path, + // SPEC 1.7: a whole-file location's range spans the entire file — + // the analyzed text is the file's exact bytes decoded (SPEC 1.6), + // so its UTF-8 length is the file's byte length. + range: { start: 0, end: utf8Length(analysis.text) }, }; codeLocations.push(file); codeIndex.set(file.identity, file); @@ -418,6 +430,8 @@ export function buildWorkspaceGraph( kind: "code", identity: unit.identity, path: analysis.path, + // SPEC 1.7: the construct binding the unit's name. + range: unit.range, }; codeLocations.push(node); codeIndex.set(node.identity, node); From 5a448c6fb41689c3c8955a2f41b61699ec212254 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 00:58:33 +0000 Subject: [PATCH 155/226] sdg(phase-10): add xspec occurrences and the shared 11.2 availability layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register `occurrences [--file <glob>] [--to <node>]` (SPEC 11.3, JSON-only) emitting the 12.7 {"findings","occurrences"} document, with argument checks preceding answering: an outside-root --file pattern and a malformed --to spelling each exit 2 with the error document; --to acceptance is syntactic, unknown or unresolving identities selecting nothing; the filters combine conjunctively. The shared SPEC 11.2 machinery lands in core/availability.ts (consulted domain over the entire discovered set, invalid-path members by exact bytes; accompanying-findings selection; node-spelling well-formedness; occurrence selection joining each source datum's range through its graph node; the any-finding-or-unavailable exit rule), with the pre-answer step in workspace/availability.ts: configuration errors keep exit-2 precedence, a failing workspace answers from current sources consulting and writing nothing, and a passing one participates in the 13.3 read-time refresh. Identity definedness (SPEC 11.2) is now definedIdentitySections in core/mdx.ts — spelled + well-formed + structural with inherited chain conditions, uniqueness own-only — and graph node construction builds nodes for exactly those sections: the duplicate-ID winner-picking is removed, so ambiguous references report 14.5-14.7 and record no occurrence, no winner resolved through. Verified: T11.3-1..4 and T5.7-1..4 green; T11.2-*, T13.3-1/2, P-11 occurrences arms pass, each failing only at its first `view`/`at` invocation (tasks B5/B6). Full suite 54 failed / 583 passed, every failure mapping to an open FIX_PLAN task. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 56 ++++---- src/cli/args.ts | 22 ++- src/cli/commands/occurrences.ts | 114 +++++++++++++++ src/cli/main.ts | 7 + src/cli/prepare.ts | 41 ++++++ src/cli/report.ts | 35 +++++ src/core/availability.ts | 248 ++++++++++++++++++++++++++++++++ src/core/graph.ts | 18 ++- src/core/mdx.ts | 75 ++++++++++ src/workspace/availability.ts | 128 +++++++++++++++++ 10 files changed, 700 insertions(+), 44 deletions(-) create mode 100644 src/cli/commands/occurrences.ts create mode 100644 src/core/availability.ts create mode 100644 src/workspace/availability.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index fca1c06..2ecd06a 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -141,38 +141,30 @@ core level; nothing presents the range yet — B4's occurrence records and C6's review payloads are the two presentation points, and T1.7-2 goes green with them.) -### B4. `xspec occurrences` and the shared 11.2 availability layer - -SPEC 11.3, 11.2, 12.7. Prereqs A1–A4, B2. Register the command (JSON-only) and -build the per-file availability machinery in `core` (not in the command file — -`view`/`at` reuse it): - -- Document `{"findings", "occurrences"}`: records in occurrence order, each in - the 12.7 record form `{"file", "range", "kind", "source", "target"}`, `source` - `{"identity", "range"}` or `{"unavailable": true}` per 11.2. -- `--file <glob>`: set restriction over discovered sources (spec and code) under - the glob rules of 7; a pattern resolving outside the workspace root is an - invalid flag value (exit 2); a glob admitting nothing yields - `{"findings": [], "occurrences": []}` exit 0 — no unknown-file error exists on - this filter. Without it, the domain is the entire discovered set. -- `--to <node>`: acceptance is syntactic only — well-formed iff at most one `#`, - non-empty path part, and any post-`#` part is one or more non-empty - `.`-joined segments each satisfying 1.4; malformed = usage error exit 2; - unknown or unresolving selects nothing (the 12.0 exit-class exception). - Filters combine conjunctively. -- 11.2 contract (shared machinery): the consulted domain's findings accompany - the answer — a finding belongs to a domain file when one of its locations lies - in it or it is the concerned path; a cross-file cycle accompanies whole when - any participant is in the domain. Any finding or explicitly-unavailable datum - in the answer → exit 1 with the full document still emitted; complete and - finding-free → exit 0. Argument checks precede answering (exit 2 whatever the - workspace carries). On a workspace passing `build`'s validations these - surfaces join the read-time refresh of 13.3; on a failing one they answer from - current sources and write nothing — journal (14.13) and write-path (14.22) - gate findings, being no domain file's findings, never accompany the answer. - -Verify: T11.3-1..4 (`section-11.3.test.ts`), T11.2-1/3/5/6 arms -(`section-11.2.test.ts`), T13.3-1/2, P-11. +(B4 landed: `xspec occurrences` and the shared 11.2 layer. +`src/core/availability.ts` — `discoveredDomain(classification, glob?)` builds +the `ConsultedDomain` (byte-keyed membership over the entire discovered set, +invalid-path members matched by their exact bytes), `accompanyingFindings` +selects a domain's findings (location file or concerned path in domain — +jointly-violated conditions accompany whole), `nodeSpellingProblem` is 11.3's +syntactic `--to`/node-spelling well-formedness, `selectOccurrences(graph, +domain, to?)` yields `ResolvedOccurrence`s (the source datum's range joined +through the graph node — requirement `section.range`, root = whole file; code +`range`), `availabilityExit` the any-finding-or-unavailable → 1 rule. +Identity definedness (11.2) is `definedIdentitySections(document)` in +`src/core/mdx.ts` — spells + well-formed + structural, chain-inherited, +uniqueness own-only — and graph node construction now builds nodes for +exactly those sections (winner-picking removed: on failing workspaces, +references to duplicate/malformed/structurally-invalid bearers report +14.5–14.7 and record nothing); B5's `view` per-node identity datum and B6's +`at` reuse it. Pre-answer step `prepareWorkspaceForAvailability` +(`src/workspace/availability.ts`): config errors exit 2; a failing workspace +(analysis findings, or 14.22 symlink findings over build's full write set) → +answer from the current analysis, no store or journal consequence, no write; +passing → the 13.3 refresh participation. CLI plumbing: +`prepareAnalysisForAvailability` (`src/cli/prepare.ts`); +`occurrenceRecordJson`/`unavailableJson` (`src/cli/report.ts`) — `view`/`at` +render occurrence records and unavailability markers through these.) ### B5. `xspec view` diff --git a/src/cli/args.ts b/src/cli/args.ts index 35a67c4..89b567d 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -10,9 +10,9 @@ // // - The first argv element names a command from the known table (12.5): // `build`, `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, -// `rename`, `move`, `version`. `review` and `query` take a subcommand as -// the next element. Unknown commands and subcommands are usage errors -// (12.0). +// `occurrences`, `rename`, `move`, `version`. `review` and `query` take a +// subcommand as the next element. Unknown commands and subcommands are +// usage errors (12.0). // - Tokens beginning `--` are flags; a value flag consumes the following // element, verbatim, as its value. The specification writes only the // space-separated form, so a token like `--config=x` is an unknown flag. @@ -114,8 +114,8 @@ const TEST_HOLD_FLAG: FlagSpec = { /** * The known command table (SPEC 12.5), in specification order. Argument * forms: `build` 12.1, `check` 12.2, `ids` 12.3, `show` 12.4, `coverage` 8.2, - * `impact` 9, `review` 10.7, `query` 11, `rename` 6.4, `move` 6.5, - * `version` 12.6. + * `impact` 9, `review` 10.7, `query` 11.1, `occurrences` 11.3, `rename` 6.4, + * `move` 6.5, `version` 12.6. */ const COMMANDS: readonly CommandSpec[] = [ // SPEC 12.1. @@ -269,6 +269,18 @@ const COMMANDS: readonly CommandSpec[] = [ }, ], }, + // SPEC 11.3: `occurrences [--file <glob>] [--to <node>]` — JSON-only + // (SPEC 11: a single JSON document is its only output form, with or + // without `--json`). + { + path: "occurrences", + positionals: [], + jsonOnly: true, + flags: [ + { name: "--file", takesValue: true, valueName: "<glob>" }, + { name: "--to", takesValue: true, valueName: "<node>" }, + ], + }, // SPEC 6.4: `rename <file> <old-id> <new-id>`. { path: "rename", diff --git a/src/cli/commands/occurrences.ts b/src/cli/commands/occurrences.ts new file mode 100644 index 0000000..0b743f3 --- /dev/null +++ b/src/cli/commands/occurrences.ts @@ -0,0 +1,114 @@ +// `xspec occurrences [--file <glob>] [--to <node>]` (SPEC 11.3). +// +// Enumerates reference occurrences (SPEC 5.7) in occurrence order, one +// record per occurrence carrying every datum of 5.7 — the source graph node +// per SPEC 11.2 where its source node's identity is undefined. JSON-only +// (SPEC 11): a single JSON document — the 12.7 `{"findings", +// "occurrences"}` form — is its only output form, with or without `--json`. +// +// `--file` admits the discovered source files — spec and code alike — that +// the glob matches (the rules of SPEC 7): a set restriction, not an +// existence assertion — the consulted domain (SPEC 11.2) is the discovered +// files it admits, a glob admitting none admits the empty set (an empty, +// finding-free answer, exit 0), and no unknown-file usage error exists on +// this filter. A pattern resolving outside the workspace root is an invalid +// flag value, exit 2 (SPEC 11.3, 11.1, 12.0). Without `--file` the domain +// is the entire discovered set. +// +// `--to` selects the occurrences whose resolved target it names: acceptance +// is syntactic (SPEC 11.3) — only a malformed spelling is a usage error, +// and an unknown or unresolving identity selects nothing (the SPEC 12.0 +// exit-class exception). The two filters combine conjunctively. +// +// The argument checks precede answering (SPEC 11.2, 12.0): each exits 2 +// whatever findings the workspace carries, before any source is analyzed. +// The answer's findings are the consulted domain's (SPEC 11.2), its exit 1 +// exactly when any finding or explicitly-unavailable datum is carried, the +// full document emitted either way; refresh participation and the +// no-write/no-consult discipline of a failing workspace are the shared +// pre-answer step's (workspace/availability.ts via cli/prepare.ts). + +import { + accompanyingFindings, + availabilityExit, + discoveredDomain, + nodeSpellingProblem, + selectOccurrences, +} from "../../core/availability.js"; +import { canonicalJson } from "../../core/canonical-json.js"; +import type { JsonValue } from "../../core/canonical-json.js"; +import type { ExitCode } from "../../core/findings.js"; +import { orderFindings } from "../../core/findings.js"; +import type { CompiledGlob } from "../../core/glob.js"; +import { compileGlob } from "../../core/glob.js"; +import type { Invocation } from "../args.js"; +import { flagValue } from "../args.js"; +import type { CommandContext } from "../io.js"; +import { prepareAnalysisForAvailability } from "../prepare.js"; +import { findingToJson, occurrenceRecordJson } from "../report.js"; +import { usageError } from "./common.js"; + +/** The `occurrences` command handler (SPEC 11.3). */ +export async function occurrencesCommand( + invocation: Invocation, + context: CommandContext, +): Promise<ExitCode> { + // --- argument checks (SPEC 11.2: they precede answering; 12.0) ---------- + let fileGlob: CompiledGlob | undefined; + const filePattern = flagValue(invocation, "--file"); + if (filePattern !== undefined) { + const compiled = compileGlob(filePattern, "plain"); + if (!compiled.ok) { + // Plain mode has one compile error: a pattern resolving outside the + // workspace root — an invalid flag value, as in SPEC 11.1 (SPEC 7). + return usageError( + invocation, + context, + `invalid value '${filePattern}' for '--file' — the pattern ` + + `resolves outside the workspace root (SPEC 11.3, 11.1, 7, 12.0)`, + ); + } + fileGlob = compiled.glob; + } + + const to = flagValue(invocation, "--to"); + if (to !== undefined) { + // SPEC 11.3: acceptance is syntactic — only a malformed requirement- + // node identity spelling is a usage error. + const problem = nodeSpellingProblem(to); + if (problem !== null) { + return usageError( + invocation, + context, + `invalid value '${to}' for '--to' — not a well-formed ` + + `requirement-node identity: ${problem} (SPEC 11.3, 1.4, 1.5, 12.0)`, + ); + } + } + + // --- the SPEC 11.2 pre-answer step -------------------------------------- + const prepared = await prepareAnalysisForAvailability(invocation, context); + if (!prepared.ok) { + return prepared.exit; + } + const { analysis } = prepared; + + // --- the answer (SPEC 11.3, 11.2) --------------------------------------- + const domain = discoveredDomain(analysis.classification, fileGlob); + const findings = orderFindings( + accompanyingFindings(analysis.findings, domain), + ); + const records = selectOccurrences(analysis.graph, domain, to); + + const document: JsonValue = { + findings: findings.map(findingToJson), + occurrences: records.map(occurrenceRecordJson), + }; + context.stdout.write(canonicalJson(document)); + // SPEC 11.2: any finding or explicitly-unavailable datum → exit 1 with + // the full document emitted; complete and finding-free → exit 0. + return availabilityExit( + findings, + records.some((record) => record.source === null), + ); +} diff --git a/src/cli/main.ts b/src/cli/main.ts index a08d23b..a1febe3 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -152,6 +152,13 @@ const HANDLERS: ReadonlyMap<string, () => Promise<CommandHandler>> = new Map( async () => (await import("./commands/review.js")).reviewExportCommand, ]; + case "occurrences": + // SPEC 11.3. + return [ + path, + async () => + (await import("./commands/occurrences.js")).occurrencesCommand, + ]; case "rename": // SPEC 6.4. return [ diff --git a/src/cli/prepare.ts b/src/cli/prepare.ts index 58e7e00..b64ce07 100644 --- a/src/cli/prepare.ts +++ b/src/cli/prepare.ts @@ -17,6 +17,7 @@ import type { ExitCode } from "../core/findings.js"; import type { GraphData } from "../core/graph-data.js"; +import { prepareWorkspaceForAvailability } from "../workspace/availability.js"; import type { WorkspaceAnalysis } from "../workspace/pipeline.js"; import { prepareWorkspaceForRead } from "../workspace/refresh.js"; import type { Invocation } from "./args.js"; @@ -71,3 +72,43 @@ export async function prepareGraphForRead( }; } } + +/** The analysis an availability surface answers from, or the emitted exit. */ +export type AvailabilityAnalysis = + | { + readonly ok: true; + /** The analyzed current workspace — the SPEC 11.2 answer's source. */ + readonly analysis: WorkspaceAnalysis; + } + | { + /** The failure is fully reported already; return `exit` as is. */ + readonly ok: false; + readonly exit: ExitCode; + }; + +/** + * The SPEC 11.2 pre-answer step of `occurrences`, `view`, and `at` + * (workspace/availability.ts), with its one failure rendered here: + * configuration errors keep their exit-2 precedence (SPEC 14.14, 12.0) — + * diagnostics on standard error and, these surfaces being JSON-only + * (SPEC 11), the 12.7 error document as the entire standard output. A + * failing workspace is not a failure of this step: the surface answers + * from the analysis, its findings selected by consulted domain + * (core/availability.ts). + */ +export async function prepareAnalysisForAvailability( + invocation: Invocation, + context: CommandContext, +): Promise<AvailabilityAnalysis> { + const prepared = await prepareWorkspaceForAvailability(context.workspace); + if (prepared.kind === "configuration") { + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + context.workspace.configAnchor, + prepared.errors, + ); + return { ok: false, exit: 2 }; + } + return { ok: true, analysis: prepared.analysis }; +} diff --git a/src/cli/report.ts b/src/cli/report.ts index 95df573..901b7a2 100644 --- a/src/cli/report.ts +++ b/src/cli/report.ts @@ -20,6 +20,7 @@ // static text, workspace-relative paths, and byte offsets only — no // absolute paths, no wall clock, no environment-dependent content. +import type { ResolvedOccurrence } from "../core/availability.js"; import { canonicalJson } from "../core/canonical-json.js"; import type { JsonObject, JsonValue } from "../core/canonical-json.js"; import type { Finding, FindingLocation } from "../core/findings.js"; @@ -157,6 +158,40 @@ export function emitAppliedMappingReport( stdout.write(lines.join("")); } +/** The SPEC 12.7 unavailability marker — the one explicit-absence form. */ +export function unavailableJson(): JsonObject { + return { unavailable: true }; +} + +/** + * One reference occurrence record as JSON data — exactly the five-member + * record form of SPEC 12.7: `{"file", "range", "kind", "source", "target"}` + * — the referencing file through the shared path-value renderer (marked + * byte form for a non-UTF-8 path, SPEC 12.0), the occurrence's own range, + * its edge kind, the source graph node as `{"identity", "range"}` or the + * unavailability marker (one datum per SPEC 11.2 — never `null`), and the + * resolved target's identity. Shared by every emitter of occurrence + * records (SPEC 11.3, 11.4, 11.5). + */ +export function occurrenceRecordJson(record: ResolvedOccurrence): JsonObject { + return { + file: pathTextJson(record.file), + range: { start: record.range.start, end: record.range.end }, + kind: record.kind, + source: + record.source === null + ? unavailableJson() + : { + identity: record.source.identity, + range: { + start: record.source.range.start, + end: record.source.range.end, + }, + }, + target: record.target, + }; +} + /** * A plain usage error as the finding form of SPEC 12.7: `code` and `path` * null — SPEC 14 assigns usage errors no stable code and no concerned diff --git a/src/core/availability.ts b/src/core/availability.ts new file mode 100644 index 0000000..37ec7bf --- /dev/null +++ b/src/core/availability.ts @@ -0,0 +1,248 @@ +// The shared SPEC 11.2 availability machinery — the per-file layer behind +// the query surfaces `occurrences` (11.3), `view` (11.4), and `at` (11.5). +// +// Pure core (IMPLEMENTATION Architecture): these surfaces answer per file, +// from parsing alone, never gated on workspace-wide validity (SPEC 11.2). +// Every answer has a consulted domain of files, and the findings of every +// domain file — and those alone — accompany the answer: a finding is a +// domain file's exactly when one of its locations lies in that file or that +// file is its concerned path (SPEC 14.19), which makes a condition several +// files jointly violate (a cross-file cycle, 14.9 — one finding locating +// every participating construct, SPEC 14) accompany whole whenever any +// participating file lies in the domain. A gate condition that is no domain +// file's finding — the journal's (14.13), a write path's (14.22) — +// accompanies no answer of these surfaces (SPEC 11.2). +// +// An invocation whose answer carries any finding or any explicitly- +// unavailable datum exits 1 with the full answer still emitted; a complete, +// finding-free answer exits 0 (SPEC 11.2, 12.0). The workspace-layer +// pre-answer step (src/workspace/availability.ts) supplies the analysis +// these functions select from; the CLI renders the 12.7 document forms. + +import type { ByteRange } from "./bytes.js"; +import type { SourceClassification } from "./discovery.js"; +import type { Finding } from "./findings.js"; +import type { CompiledGlob } from "./glob.js"; +import type { DependencyEdgeKind, WorkspaceGraph } from "./graph.js"; +import type { PathText } from "./path-text.js"; +import { pathTextKey } from "./path-text.js"; +import { + containsControl, + containsWhitespace, + FORBIDDEN_SEGMENT_NAMES, +} from "./text.js"; + +/** + * The consulted domain of one availability answer (SPEC 11.2): a set of + * discovered files, membership by exact path bytes (SPEC 12.0 — one byte + * space over both path presentation forms, so an invalid-path file's marked + * byte form and a plain string never collide or diverge). + */ +export class ConsultedDomain { + private readonly keys: ReadonlySet<string>; + + constructor(files: Iterable<PathText>) { + const keys = new Set<string>(); + for (const file of files) { + keys.add(pathTextKey(file)); + } + this.keys = keys; + } + + /** Whether `path` names a domain file (exact byte membership). */ + has(path: PathText): boolean { + return this.keys.has(pathTextKey(path)); + } +} + +/** + * The discovered files a `--file` restriction admits (SPEC 11.3): the + * discovered source files — spec and code alike, invalid-path (14.19) + * members included: they are discovered files (SPEC 11.2) — that the glob + * matches, under the glob rules of 7 (byte-wise against the + * workspace-relative path). Without a glob, the entire discovered set. A + * glob admitting nothing admits the empty set — a set restriction, not an + * existence assertion (SPEC 11.3); discovery is controlled exclusively by + * configuration (SPEC 7), so an on-disk file no group discovers is never + * admitted, whatever patterns match it. + */ +export function discoveredDomain( + classification: SourceClassification, + glob?: CompiledGlob, +): ConsultedDomain { + const files: PathText[] = []; + for (const source of classification.specSources) { + if (glob === undefined || glob.matches(source.path)) { + files.push(source.path); + } + } + for (const source of classification.codeSources) { + if (glob === undefined || glob.matches(source.path)) { + files.push(source.path); + } + } + for (const source of classification.invalidSources) { + // SPEC 7: matching is byte-wise against the workspace-relative path — + // an invalid path's exact bytes, which may have no plain string form. + if (glob === undefined || glob.matches(source.bytes)) { + files.push(source.path); + } + } + return new ConsultedDomain(files); +} + +/** + * The findings accompanying an answer over `domain` (SPEC 11.2): every + * finding one of whose locations lies in a domain file or whose concerned + * path is a domain file. A jointly-violated condition carries a location + * for every participating construct (SPEC 14), so it accompanies whole + * whenever any participant is in the domain; a condition with neither an + * in-domain location nor an in-domain concerned path — the journal's 14.13, + * a write path's 14.22, a policy violation's 14.12 — accompanies no answer. + * Input order is preserved (the emitters re-order per SPEC 12.7). + */ +export function accompanyingFindings( + findings: readonly Finding[], + domain: ConsultedDomain, +): Finding[] { + return findings.filter( + (finding) => + finding.locations.some((location) => domain.has(location.file)) || + (finding.path !== null && domain.has(finding.path)), + ); +} + +/** + * Why `spelling` is not a syntactically well-formed requirement-node + * identity — `path#id`, or a bare `path` for a root (SPEC 1.5) — or null + * when it is (SPEC 11.3): well-formed exactly when it contains at most one + * `#`, its path part (the whole spelling, or the part before the `#`) is + * non-empty, and, when a `#` is present, the part after it is one or more + * non-empty segments joined by `.`, each satisfying the segment rules of + * 1.4. Acceptance is syntactic: whether the named identity resolves is no + * part of this check (SPEC 11.3, 12.0). + */ +export function nodeSpellingProblem(spelling: string): string | null { + const firstHash = spelling.indexOf("#"); + if (firstHash !== -1 && spelling.indexOf("#", firstHash + 1) !== -1) { + return 'it contains more than one "#" (SPEC 12.0: at most one is well-formed)'; + } + const pathPart = firstHash === -1 ? spelling : spelling.slice(0, firstHash); + if (pathPart.length === 0) { + return "its path part is empty"; + } + if (firstHash === -1) { + return null; + } + const idPart = spelling.slice(firstHash + 1); + for (const segment of idPart.split(".")) { + if (segment.length === 0) { + return idPart.length === 0 + ? 'its id part after "#" is empty (one or more segments required)' + : "its id part has an empty segment"; + } + if (FORBIDDEN_SEGMENT_NAMES.has(segment)) { + return ( + `its id segment ${JSON.stringify(segment)} is one of the forbidden ` + + `names ("$", "__proto__", "prototype", "constructor", "then") ` + + `(SPEC 1.4)` + ); + } + if (containsWhitespace(segment)) { + return `its id segment ${JSON.stringify(segment)} contains whitespace (SPEC 1.4)`; + } + if (containsControl(segment)) { + return `its id segment ${JSON.stringify(segment)} contains a control character (SPEC 1.4)`; + } + // SPEC 1.4's no-"." rule is structural under the split; a "#" inside a + // segment is impossible under the at-most-one-"#" rule above. + } + return null; +} + +/** + * A reference occurrence as answered (SPEC 5.7, 11.3): every datum of 5.7 + * with the source graph node resolved to its one-datum form — the node's + * identity together with that node's own source range (SPEC 1.7), or null + * exactly where 11.2 leaves the source node's identity undefined (a section + * without a usable identity; every node of an invalid-path file), the datum + * then reported explicitly unavailable (SPEC 12.7). + */ +export interface ResolvedOccurrence { + readonly file: PathText; + readonly range: ByteRange; + readonly kind: DependencyEdgeKind; + readonly source: { + readonly identity: string; + readonly range: ByteRange; + } | null; + readonly target: string; +} + +/** + * The occurrence records of an answer (SPEC 11.3): the graph's occurrences + * — already in occurrence order (SPEC 5.7) — whose referencing file lies in + * the domain and, with `to` given, whose resolved target it names (the two + * filters combine conjunctively). `to` selection is by exact identity + * (byte-wise, SPEC 12.0): an unknown or unresolving identity is no record's + * target and selects nothing (SPEC 11.3). Each record's source datum joins + * the source node's own range through the graph node itself — a requirement + * node's section construct range (the entire file for a root) or a code + * location's range (SPEC 1.7, 5.7). + */ +export function selectOccurrences( + graph: WorkspaceGraph, + domain: ConsultedDomain, + to?: string, +): ResolvedOccurrence[] { + const records: ResolvedOccurrence[] = []; + for (const occurrence of graph.occurrences) { + if (!domain.has(occurrence.file)) continue; + if (to !== undefined && occurrence.target !== to) continue; + records.push({ + file: occurrence.file, + range: occurrence.range, + kind: occurrence.kind, + source: resolveOccurrenceSource(graph, occurrence.source), + target: occurrence.target, + }); + } + return records; +} + +/** + * The source datum's range half (SPEC 5.7): identity and range travel + * together as one datum, the range read from the identified graph node — + * `RequirementNode.section.range` (the entire file for a root, SPEC 1.7) or + * `CodeLocationNode.range`. Null stays null (explicitly unavailable). + */ +function resolveOccurrenceSource( + graph: WorkspaceGraph, + source: string | null, +): { readonly identity: string; readonly range: ByteRange } | null { + if (source === null) return null; + const node = graph.node(source); + if (node === undefined) { + // Unreachable: every occurrence's source identity is a node of the same + // graph (core/graph.ts records occurrences beside edge recording). + throw new Error( + `xspec internal error: occurrence source ${source} names no graph node`, + ); + } + return { + identity: source, + range: node.kind === "requirement" ? node.section.range : node.range, + }; +} + +/** + * The SPEC 11.2 exit of an availability answer: 1 when the answer carries + * any finding or any explicitly-unavailable datum — emitted in full either + * way — and 0 for a complete, finding-free answer (SPEC 12.0). + */ +export function availabilityExit( + findings: readonly Finding[], + carriesUnavailable: boolean, +): 0 | 1 { + return findings.length > 0 || carriesUnavailable ? 1 : 0; +} diff --git a/src/core/graph.ts b/src/core/graph.ts index 63072bf..e512fb8 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -40,6 +40,7 @@ import type { CodeAnalysis } from "./code-analysis.js"; import type { Finding, FindingLocation } from "./findings.js"; import { compareFindings, locatedFinding } from "./findings.js"; import type { SpecDocument, SpecEmbedding, SpecSection } from "./mdx.js"; +import { definedIdentitySections } from "./mdx.js"; import type { PathText } from "./path-text.js"; import { comparePathTexts, pathTextKey, renderPathText } from "./path-text.js"; import type { @@ -380,18 +381,21 @@ export function buildWorkspaceGraph( requirementNodes.push(root); requirementIndex.set(root.identity, root); sectionIndex.set(document.root, root); + // SPEC 11.2/1.5: only defined node identities are formed, emitted, or + // resolved against — a section spelling no identity, a malformed or + // structurally invalid spelling (or one anywhere in its chain), and + // every bearer of a duplicated spelling (no winner picked) contribute + // no identified node; their findings (14.1–14.4, 14.17) account for + // them, and references to them report as unresolved (14.5–14.7). + const definedSections = definedIdentitySections(document); for (const section of document.sections) { - if (section.id === null) { - // No usable identity — the section's 14.1/14.17 accounts for it. + if (section.id === null || !definedSections.has(section)) { continue; } // SPEC 1.5: `path#id`; the `#` is unambiguous because discovered - // paths never contain `#` (14.19). + // paths never contain `#` (14.19), and definedness makes the + // identity unique within the file (SPEC 11.2). const identity = `${document.path}#${section.id}`; - if (requirementIndex.has(identity)) { - // A duplicate ID (14.3): the first declaration keeps the identity. - continue; - } const node: RequirementNode = { kind: "requirement", identity, diff --git a/src/core/mdx.ts b/src/core/mdx.ts index c2462d2..fb618dc 100644 --- a/src/core/mdx.ts +++ b/src/core/mdx.ts @@ -1084,6 +1084,81 @@ function valueViolation(value: string, kind: "segment" | "tag"): string | null { return null; } +/** + * SPEC 11.2: the sections of a parsed document whose node identities are + * defined, over a valid file path (an invalid-path file defines no identity + * whatever this returns — the caller's concern, SPEC 14.19). A section's + * node identity is defined exactly when it and each enclosing section spell + * an identity, each spelled identity in the chain is well-formed (SPEC 1.4) + * and satisfies the structural rules (SPEC 1.3), and no other section of + * the file spells the same identity as it does. The chain conditions are + * inherited — a descendant of a section that spells no identity, or whose + * spelled identity is malformed or structurally invalid, has no defined + * identity — but uniqueness is not: it constrains the section's own spelled + * identity alone, so duplicate spellings leave every bearer undefined (no + * winner picked) while a uniquely spelled descendant of duplicate-`id` + * ancestors keeps its defined identity. Parse-local (SPEC 11.2): shared by + * graph node construction (core/graph.ts) — only defined identities are + * formed, emitted, or resolved against (SPEC 1.5) — and the availability + * surfaces (SPEC 11.3–11.5). + */ +export function definedIdentitySections( + document: SpecDocument, +): ReadonlySet<SpecSection> { + // Uniqueness compares spelled identities only (SPEC 11.2): a section + // spelling no identity (`id` absent, repeated, or in invalid value form — + // SpecSection.id null) contests no other section's. + const spelled = new Map<string, number>(); + for (const section of document.sections) { + if (section.id !== null) { + spelled.set(section.id, (spelled.get(section.id) ?? 0) + 1); + } + } + + // The chain conditions (own and inherited; uniqueness excluded): spells + // an identity, well-formed per SPEC 1.4, structurally valid per SPEC 1.3 + // against the parent's spelled identity — a top-level section against the + // empty prefix (exactly one segment). + const wellFormed = (id: string): boolean => + id + .split(".") + .every((segment) => valueViolation(segment, "segment") === null); + const chain = new Map<SpecSection, boolean>(); + const chainOk = (section: SpecSection): boolean => { + if (section.parent === null) return true; // the root spells no identity + const memo = chain.get(section); + if (memo !== undefined) return memo; + let ok = false; + if (section.id !== null && wellFormed(section.id)) { + const segments = section.id.split("."); + const parent = section.parent; + if (parent.parent === null) { + // SPEC 1.3: a top-level section's ID is exactly one segment. + ok = segments.length === 1; + } else if (parent.id !== null) { + // SPEC 1.3: the parent's spelled ID plus exactly one segment. A + // parent spelling no identity fails the chain regardless. + const parentSegments = parent.id.split("."); + ok = + segments.length === parentSegments.length + 1 && + parentSegments.every((segment, index) => segments[index] === segment); + } + ok = ok && chainOk(parent); + } + chain.set(section, ok); + return ok; + }; + + const defined = new Set<SpecSection>(); + for (const section of document.sections) { + if (section.id === null) continue; + if (spelled.get(section.id) !== 1) continue; + if (!chainOk(section)) continue; + defined.add(section); + } + return defined; +} + /** * SPEC 2.6: split a `tags` value on runs of SPEC 1.4 whitespace, ignoring * leading and trailing whitespace, and collapse duplicates keeping diff --git a/src/workspace/availability.ts b/src/workspace/availability.ts new file mode 100644 index 0000000..a8593e5 --- /dev/null +++ b/src/workspace/availability.ts @@ -0,0 +1,128 @@ +// The SPEC 11.2 pre-answer step — the workspace side of the availability +// surfaces `occurrences` (11.3), `view` (11.4), and `at` (11.5). +// +// SPEC 11.2 (never stale; writing nothing on a failing workspace): these +// surfaces never answer from stale graph data. On a workspace that passes +// the validations of `xspec build` (SPEC 12.1) they participate in +// read-time refresh exactly as the reads of 13.3 do — the stored graph data +// is refreshed, writing exactly what `build` would write except that no +// TypeScript or Markdown is generated or removed and the recorded +// derived-file paths are left unchanged. On one that fails them — source +// validation errors, journal errors (14.13), and refused writes (14.22) +// alike (SPEC 13.3): the findings a `build` would now report — they answer +// from the current sources and modify nothing: no graph data, no derived +// files, no journal consulted, no record consulted. Either way the answer +// itself comes from the fresh analysis, so the caller's answer never +// depends on the store; refresh participation is the 13.3 side effect +// alone. +// +// Unlike the gated reads' step (./refresh.ts), a failing workspace is not a +// report here: its gate findings reach the answer only through the SPEC +// 11.2 consulted-domain selection (core/availability.ts) — a journal or +// write-path condition is no domain file's finding and accompanies no +// answer. Configuration errors keep their exit-2 precedence (SPEC 14.14). +// +// IMPLEMENTATION (Architecture): this workspace-layer module owns the I/O — +// the analysis pipeline (./pipeline.ts), the store load and the one +// refresh write (./graph-data.ts) — over the pure derivation of +// core/build.ts, exactly as ./refresh.ts composes them, so refresh and +// build agree byte for byte (SPEC 12.0). + +import { computeBuildOutputs } from "../core/build.js"; +import type { Finding } from "../core/findings.js"; +import { + graphDataMatchesCurrent, + refreshedGraphData, +} from "../core/graph-data.js"; +import type { LoadedWorkspace } from "./config.js"; +import { loadGraphData, writeGraphData } from "./graph-data.js"; +import type { WorkspaceAnalysis } from "./pipeline.js"; +import { analyzeWorkspace, workspaceInputsOf } from "./pipeline.js"; +import { symlinkWritePathFindings } from "./writes.js"; + +/** The outcome of the SPEC 11.2 pre-answer step. */ +export type AvailabilityPreparation = + | { + /** + * Answer from `analysis` per SPEC 11.2 — on a passing workspace the + * stored graph data now matches the current sources and configuration + * (refreshed if it did not, SPEC 13.3); on a failing one nothing was + * consulted or modified. The caller selects the consulted domain's + * findings itself (core/availability.ts) — a failing workspace is not + * a report on these surfaces. + */ + readonly kind: "answer"; + readonly analysis: WorkspaceAnalysis; + } + | { + /** + * SPEC 14.14/12.0: discovery-level configuration errors — usage + * class, exit 2, nothing modified; configuration errors keep their + * precedence over every answer (SPEC 11.2). + */ + readonly kind: "configuration"; + readonly errors: readonly Finding[]; + }; + +/** + * The SPEC 11.2 pre-answer step: analyze the current workspace; on + * configuration errors fail with exit-2 precedence; on a workspace failing + * `build`'s validations answer from the analysis consulting nothing and + * writing nothing; on a passing one participate in read-time refresh + * exactly as the reads of 13.3 do, then answer from the same analysis. + */ +export async function prepareWorkspaceForAvailability( + workspace: LoadedWorkspace, +): Promise<AvailabilityPreparation> { + const analysis = await analyzeWorkspace(workspace); + if (analysis.configurationErrors.length > 0) { + return { kind: "configuration", errors: analysis.configurationErrors }; + } + if (analysis.findings.length > 0) { + // SPEC 11.2/13.3: the current sources fail build validation — answer + // from them; no store read, no journal consequence, no write. + return { kind: "answer", analysis }; + } + + // What `xspec build` would write for the current sources and + // configuration (SPEC 13.3): the same pure derivation `build` runs + // (SPEC 12.1). Its graph data and write set are independent of the + // stored record (`stored` feeds orphan removal alone, which no refresh + // performs), so the store stays unconsulted until the workspace has + // passed the complete gate below. + const build = computeBuildOutputs( + workspace.configuration, + analysis.specs, + analysis.graph, + analysis.textModel, + analysis.hashes, + null, + workspaceInputsOf(workspace, analysis), + ); + + // SPEC 13.3: refused writes (14.22) fail `build`'s validations alike — + // judged over build's complete write set, exactly the findings a `build` + // would now report. On that failing side these surfaces write nothing + // and consult no record (SPEC 11.2); the condition itself is no domain + // file's finding and accompanies no answer. + const writeFindings = await symlinkWritePathFindings( + workspace.root, + build.writePaths, + ); + if (writeFindings.length > 0) { + return { kind: "answer", analysis }; + } + + // Passing workspace: read-time refresh participation (SPEC 13.3), as in + // ./refresh.ts — matching data is served as is; mismatched or missing + // data is rewritten as `build` would write it, the recorded derived-file + // paths left unchanged. + const stored = await loadGraphData(workspace.root); + if (!graphDataMatchesCurrent(stored.bytes, stored.data, build.graphData)) { + await writeGraphData( + workspace.root, + refreshedGraphData(stored.data, build.graphData), + ); + } + return { kind: "answer", analysis }; +} From 84ae6444810d4c68e7fdb7bfe44aaf520a7bee20 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 01:25:05 +0000 Subject: [PATCH 156/226] sdg(phase-10): register xspec view with the 11.4 document form The whole-document structural view (SPEC 11.4) lands on the shared 11.2 availability layer: view is JSON-only with variadic <file> operands that assert membership in the discovered spec-source domain (unknown and wrong-kind operands exit 2 before any answer or refresh side effect; combining operands with --file is rejected at parse time), while --file restricts the domain under the glob rules of 7. Per parseable requested file the answer serves the positional section tree with construct ranges, tag-range decompositions, raw attribute spellings (SpecSection.attributes - repeated, unknown, and spread forms included), the three-state interpreted datums of 11.2 (identity via definedIdentitySections; tags/coverage via the new tagsDefined/coverageDefined), every import declaration with the specifier-form-and-discovery target datum (SpecImport.designatedFile), the file's occurrence records, and comment ranges. With --text each node carries own and subtree text, defined all-or-nothing over transitive expansion (TextAvailability) with the consulted domain extended by expansionConsultedFiles; the graph's embedding index now covers invalid-path files' embeddings so defined text values stay exact there. The workspace pre-answer step is split (analyzeWorkspaceForAvailability / finishAvailabilityRefresh) so discovery-consulting argument checks precede the 13.3 refresh. Verified: T11.4-1..6, T11.2-2, T11.2-4 pass; T11.2-1/3/5, T13.3-1/2 fail only on the unimplemented at (B6); P-12 stays red until B6 lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 66 +++--- src/cli/args.ts | 57 ++++- src/cli/commands/view.ts | 391 ++++++++++++++++++++++++++++++++++ src/cli/main.ts | 6 + src/cli/prepare.ts | 32 ++- src/core/availability.ts | 119 +++++++++++ src/core/graph.ts | 16 +- src/core/mdx.ts | 113 +++++++++- src/core/spec-references.ts | 16 ++ src/workspace/availability.ts | 51 ++++- 10 files changed, 797 insertions(+), 70 deletions(-) create mode 100644 src/cli/commands/view.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 2ecd06a..ea1fd82 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -166,47 +166,31 @@ passing → the 13.3 refresh participation. CLI plumbing: `occurrenceRecordJson`/`unavailableJson` (`src/cli/report.ts`) — `view`/`at` render occurrence records and unavailability markers through these.) -### B5. `xspec view` - -SPEC 11.4, 11.2, 12.7. Prereqs B2, B4 (availability layer). Register the command -(JSON-only). Document `{"findings", "views"}`, one -`{"file", "root", "imports", "occurrences", "comments"}` per parseable requested -file ordered by path bytes: - -- Operands vs. flag: `<file>` operands assert membership — a file outside the - discovered set is unknown (exit 2), a discovered code source is a wrong-kind - operand (exit 2); a `#`-containing operand is a whole path, never a - `path#id` split. `--file <glob>` is a set restriction (empty admitted set → - empty finding-free answer, exit 0). Combining operands with `--file` is a - usage error. Neither → every discovered spec source. An unparseable requested - file contributes no view entry (its parse finding accompanies, exit 1); an - invalid-path (14.19) file keeps its view, every identity unavailable, - condition-19 finding accompanying. -- Node form `{"identity", "range", "opening", "closing", "attributes", "tags", - "coverage", "children"}` plus `"ownText"`/`"subtreeText"` exactly when - `--text`. Positional tree by construct nesting alone (a section inside a - non-section construct parents to the innermost enclosing section, else root). - `opening`/`closing`: tag ranges — self-closing has opening only, root neither - (null). `attributes`: one `{"name", "range", "text"}` per spelled attribute in - tag order — repeated/unknown/spread included, spread `name` null, `text` the - attribute's own characters. `identity`, `tags`, `coverage`, text members: - plain value, null where 11.4 defines structural absence (a root's - tags/coverage — absent, never unavailable), or `{"unavailable": true}` per - 11.2 (spelled-identity rules; interpreted tags/coverage undefined on - repeated/malformed props; text all-or-nothing over transitive expansion, - unavailable on any unresolved spelling or embedding cycle on the path). -- With `--text` the consulted domain adds every file the requested expansions - transitively consult (resolved targets reachable through occurrence-recording - embeddings, cycle participants included); a spelling recording no occurrence - is an expansion boundary; a masked file is consulted only when itself - requested. -- `imports`: every declaration, valid or invalid, `{"range", "name", "target"}` - in document order — `name` the default-binding identifier or null (absent, - never unavailable), `target` the resolved file or `{"unavailable": true}`. - `occurrences`: the file's records in document order. `comments`: every MDX - comment's range. - -Verify: T11.4-1..6 (`section-11.4.test.ts`), T11.2-2/4, P-12. +(B5 landed: `xspec view` (`src/cli/commands/view.ts`), registered JSON-only +with variadic `<file>` positionals — `variadicPositionals` and +`positionalConflicts` in `src/cli/args.ts` make combining operands with +`--file` a parse-level usage error. The 11.2 pre-answer step is split so +discovery-consulting argument checks precede answering AND the refresh: +`analyzeWorkspaceForAvailability`/`finishAvailabilityRefresh` +(`src/workspace/availability.ts`; `prepareWorkspaceForAvailability` still +composes both for `occurrences`), CLI face `analyzeAnalysisForAvailability` +(`src/cli/prepare.ts`) — B6's `at` must reuse this: operand membership by +`pathTextKey` over `classification` spec/code/invalid sources (unknown / +wrong-kind → exit 2), then `finishAvailabilityRefresh`, then answer. New +parse-local data: `SpecSection.attributes` (raw `{name, range, text}` +entries, spread name null) and `tagsDefined`/`coverageDefined` (11.2 +three-state interpreted datums) in `src/core/mdx.ts`; +`SpecImport.designatedFile` (`src/core/spec-references.ts`) is the 11.4 +import-target datum (specifier form + discovery alone, binding validity +notwithstanding). `src/core/availability.ts` adds `expansionConsultedFiles` +(the `--text` domain walk over occurrence-recording embeddings) and +`TextAvailability` (per-node own/subtree-text definedness — unresolved +spelling or embedding cycle poisons the whole value); the graph's +embedding index now also covers invalid-path files' embeddings, so the +text model expands them where resolution holds. A file's own occurrence +records = `selectOccurrences(graph, new ConsultedDomain([file]))` — B6's +`occurrence` member reuses this. P-12 stays red until B6's `at` lands, as +its own verify line records.) ### B6. `xspec at` diff --git a/src/cli/args.ts b/src/cli/args.ts index 89b567d..dbfeb48 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -10,9 +10,9 @@ // // - The first argv element names a command from the known table (12.5): // `build`, `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, -// `occurrences`, `rename`, `move`, `version`. `review` and `query` take a -// subcommand as the next element. Unknown commands and subcommands are -// usage errors (12.0). +// `occurrences`, `view`, `rename`, `move`, `version`. `review` and `query` +// take a subcommand as the next element. Unknown commands and subcommands +// are usage errors (12.0). // - Tokens beginning `--` are flags; a value flag consumes the following // element, verbatim, as its value. The specification writes only the // space-separated form, so a token like `--config=x` is an unknown flag. @@ -63,6 +63,17 @@ interface CommandSpec { readonly positionals: readonly string[]; /** How many trailing positionals are optional (default none). */ readonly optionalPositionals?: number; + /** + * The command accepts any number of positionals beyond `positionals` + * (SPEC 11.4: `view [<file> …]`); the upper arity bound is not checked. + */ + readonly variadicPositionals?: boolean; + /** + * Flags that may not be combined with positional operands — SPEC 11.4: + * combining `<file>` operands with `--file` is a usage error, a defect + * the invocation's syntax alone determines (SPEC 12.0). + */ + readonly positionalConflicts?: readonly string[]; /** Command-specific flags; the SPEC 12.0 globals are added for every command. */ readonly flags: readonly FlagSpec[]; /** @@ -114,8 +125,8 @@ const TEST_HOLD_FLAG: FlagSpec = { /** * The known command table (SPEC 12.5), in specification order. Argument * forms: `build` 12.1, `check` 12.2, `ids` 12.3, `show` 12.4, `coverage` 8.2, - * `impact` 9, `review` 10.7, `query` 11.1, `occurrences` 11.3, `rename` 6.4, - * `move` 6.5, `version` 12.6. + * `impact` 9, `review` 10.7, `query` 11.1, `occurrences` 11.3, `view` 11.4, + * `rename` 6.4, `move` 6.5, `version` 12.6. */ const COMMANDS: readonly CommandSpec[] = [ // SPEC 12.1. @@ -281,6 +292,21 @@ const COMMANDS: readonly CommandSpec[] = [ { name: "--to", takesValue: true, valueName: "<node>" }, ], }, + // SPEC 11.4: `view [<file> …] [--file <glob>] [--text]` — JSON-only + // (SPEC 11). Operands assert membership while `--file` restricts the + // domain; combining them is a usage error. + { + path: "view", + positionals: ["<file>"], + optionalPositionals: 1, + variadicPositionals: true, + positionalConflicts: ["--file"], + jsonOnly: true, + flags: [ + { name: "--file", takesValue: true, valueName: "<glob>" }, + { name: "--text", takesValue: false }, + ], + }, // SPEC 6.4: `rename <file> <old-id> <new-id>`. { path: "rename", @@ -623,7 +649,8 @@ export function parseArgv(argv: readonly string[]): ParseResult { } } // SPEC 12.0: missing required arguments are usage errors; an argument the - // command's form does not define is one too. + // command's form does not define is one too (a variadic command defines + // no upper bound, SPEC 11.4). const minimum = spec.positionals.length - (spec.optionalPositionals ?? 0); if (positionals.length < minimum) { return usageError( @@ -632,13 +659,29 @@ export function parseArgv(argv: readonly string[]): ParseResult { inEffect(), ); } - if (positionals.length > spec.positionals.length) { + if ( + spec.variadicPositionals !== true && + positionals.length > spec.positionals.length + ) { return usageError( `${spec.path}: unexpected argument ` + `'${positionals[spec.positionals.length]!}'`, inEffect(), ); } + // SPEC 11.4/12.0: combining positional operands with a domain-restricting + // flag is a usage error the invocation's syntax alone determines. + for (const conflicting of spec.positionalConflicts ?? []) { + if (positionals.length > 0 && seen.has(conflicting)) { + return usageError( + `${spec.path}: ${spec.positionals[0] ?? "positional"} operands ` + + `cannot be combined with '${conflicting}' — operands assert ` + + `membership while the flag restricts the domain; give one or ` + + `the other`, + inEffect(), + ); + } + } return { ok: true, diff --git a/src/cli/commands/view.ts b/src/cli/commands/view.ts new file mode 100644 index 0000000..ca50c15 --- /dev/null +++ b/src/cli/commands/view.ts @@ -0,0 +1,391 @@ +// `xspec view [<file> …] [--file <glob>] [--text]` (SPEC 11.4). +// +// Returns, per requested file, everything needed to overlay structure on +// the raw MDX bytes: the root and the full positional section tree with +// construct ranges, tag-range decompositions, raw attribute spellings, and +// the per-node interpreted datums of SPEC 11.2 (identity, tags, coverage — +// each plain, structurally absent, or explicitly unavailable), every +// import declaration, the file's reference occurrences, and every MDX +// comment's range — with `--text`, each node's own and subtree text +// (SPEC 1.6), defined or explicitly unavailable per SPEC 11.2. JSON-only +// (SPEC 11): a single JSON document — the 12.7 `{"findings", "views"}` +// form — is its only output form, with or without `--json`. +// +// The view's domain is the discovered spec sources (SPEC 11.4). `<file>` +// operands assert membership — a file outside the discovered set is an +// unknown file and a discovered code source a wrong-kind operand, each a +// usage error (exit 2, SPEC 12.0); a `#`-containing operand is a whole +// path, never a `path#id` split (SPEC 12.0). `--file` is instead a set +// restriction under the glob rules of SPEC 7 — a glob admitting no +// discovered spec source admits the empty set (an empty, finding-free +// answer, exit 0) — and combining operands with `--file` is a usage error +// (rejected at parse time). With neither, the request covers every +// discovered spec source. The argument checks precede answering +// (SPEC 11.2, 12.0): membership is judged against discovery, before the +// SPEC 13.3 refresh participation, so a failing invocation writes nothing. +// +// The consulted domain (SPEC 11.2) is the requested files plus, with +// `--text`, every file the requested expansions transitively consult +// (core/availability.ts `expansionConsultedFiles`); the domain's findings +// accompany the answer, and any finding or explicitly-unavailable datum +// exits 1 with the full document still emitted. An unparseable requested +// file contributes no view entry — its parse-failure finding reports it — +// while an invalid-path (SPEC 14.19) requested file keeps its view, every +// node identity explicitly unavailable. + +import { + accompanyingFindings, + availabilityExit, + ConsultedDomain, + expansionConsultedFiles, + selectOccurrences, + TextAvailability, +} from "../../core/availability.js"; +import { canonicalJson } from "../../core/canonical-json.js"; +import type { JsonObject, JsonValue } from "../../core/canonical-json.js"; +import type { ExitCode } from "../../core/findings.js"; +import { orderFindings } from "../../core/findings.js"; +import type { CompiledGlob } from "../../core/glob.js"; +import { compileGlob } from "../../core/glob.js"; +import type { SpecFileAnalysis, WorkspaceGraph } from "../../core/graph.js"; +import type { SpecDocument, SpecSection } from "../../core/mdx.js"; +import { definedIdentitySections } from "../../core/mdx.js"; +import type { PathText } from "../../core/path-text.js"; +import { + comparePathTexts, + pathTextJson, + pathTextKey, +} from "../../core/path-text.js"; +import type { WorkspaceTextModel } from "../../core/text-model.js"; +import { finishAvailabilityRefresh } from "../../workspace/availability.js"; +import type { Invocation } from "../args.js"; +import { flagPresent, flagValue } from "../args.js"; +import type { CommandContext } from "../io.js"; +import { analyzeAnalysisForAvailability } from "../prepare.js"; +import { + findingToJson, + occurrenceRecordJson, + unavailableJson, +} from "../report.js"; +import { rangeJson, usageError } from "./common.js"; + +/** One requested file's parsed analysis and its path validity (SPEC 14.19). */ +interface RequestedSpec { + readonly spec: SpecFileAnalysis; + /** + * Whether the file's own path is valid — false for a 14.19 member, whose + * every node identity is explicitly unavailable (SPEC 11.2) while its + * parse-local structure stays on view. + */ + readonly pathValid: boolean; +} + +/** The `view` command handler (SPEC 11.4). */ +export async function viewCommand( + invocation: Invocation, + context: CommandContext, +): Promise<ExitCode> { + // --- syntactic argument checks (SPEC 11.2: they precede answering) ------ + const withText = flagPresent(invocation, "--text"); + let fileGlob: CompiledGlob | undefined; + const filePattern = flagValue(invocation, "--file"); + if (filePattern !== undefined) { + const compiled = compileGlob(filePattern, "plain"); + if (!compiled.ok) { + // Plain mode has one compile error: a pattern resolving outside the + // workspace root — an invalid flag value, as in SPEC 11.1 (SPEC 7). + return usageError( + invocation, + context, + `invalid value '${filePattern}' for '--file' — the pattern ` + + `resolves outside the workspace root (SPEC 11.4, 11.1, 7, 12.0)`, + ); + } + fileGlob = compiled.glob; + } + + // --- the analysis half of the SPEC 11.2 pre-answer step (a pure read) --- + const prepared = await analyzeAnalysisForAvailability(invocation, context); + if (!prepared.ok) { + return prepared.exit; + } + const { analysis } = prepared; + const { classification } = analysis; + + // --- operand membership checks (SPEC 11.4, 12.0): judged against the + // discovered set — discovery is controlled exclusively by configuration + // (SPEC 7), so an on-disk file no group discovers is unknown — before + // any answer or refresh side effect (SPEC 11.2). + const discoveredKinds = new Map<string, "spec" | "code">(); + for (const source of classification.specSources) { + discoveredKinds.set(pathTextKey(source.path), "spec"); + } + for (const source of classification.codeSources) { + discoveredKinds.set(pathTextKey(source.path), "code"); + } + for (const source of classification.invalidSources) { + // SPEC 11.2/14.19: invalid-path members are discovered files of their + // kind — a spec-kind member keeps its view; a code-kind member is a + // wrong-kind operand like any other discovered code source. + discoveredKinds.set(pathTextKey(source.path), source.kind); + } + + const requested: PathText[] = []; + const requestedKeys = new Set<string>(); + const addRequested = (file: PathText): void => { + const key = pathTextKey(file); + if (!requestedKeys.has(key)) { + requestedKeys.add(key); + requested.push(file); + } + }; + if (invocation.positionals.length > 0) { + for (const operand of invocation.positionals) { + // SPEC 12.0: a bare <file> operand is a whole path — `#` has no + // delimiter role in it — so the operand names the discovered file of + // exactly that spelling. + const kind = discoveredKinds.get(pathTextKey(operand)); + if (kind === undefined) { + return usageError( + invocation, + context, + `unknown file '${operand}' — a <file> operand names a ` + + `discovered spec source, and no configured group discovers ` + + `this path (SPEC 11.4, 7, 12.0)`, + ); + } + if (kind === "code") { + return usageError( + invocation, + context, + `wrong-kind file '${operand}' — the operand names a discovered ` + + `code source, which has no structural view; name a discovered ` + + `spec source (SPEC 11.4, 12.0)`, + ); + } + addRequested(operand); + } + } else { + // SPEC 11.4: `--file` admits the discovered spec sources it matches — + // matching is byte-wise against the workspace-relative path (SPEC 7); + // with neither operands nor `--file`, every discovered spec source. + for (const source of classification.specSources) { + if (fileGlob === undefined || fileGlob.matches(source.path)) { + addRequested(source.path); + } + } + for (const source of classification.invalidSources) { + if (source.kind !== "spec") continue; + if (fileGlob === undefined || fileGlob.matches(source.bytes)) { + addRequested(source.path); + } + } + } + // SPEC 11.4: the requested files form a set; per-file views are ordered + // by byte order of workspace-relative path. + requested.sort(comparePathTexts); + + // --- the refresh half (SPEC 13.3, 11.2): the invocation is valid, so + // the surface participates in read-time refresh on a passing workspace + // and touches nothing on a failing one. + await finishAvailabilityRefresh(context.workspace, analysis); + + // --- the answer (SPEC 11.4, 11.2) --------------------------------------- + const parsedByKey = new Map<string, RequestedSpec>(); + for (const spec of analysis.specs) { + parsedByKey.set(pathTextKey(spec.document.file), { spec, pathValid: true }); + } + for (const spec of analysis.invalidPathSpecs) { + parsedByKey.set(pathTextKey(spec.document.file), { + spec, + pathValid: false, + }); + } + // An unparseable requested file has no parsed analysis: it contributes + // no view entry, its parse-failure finding reporting it (SPEC 11.2). + const requestedSpecs: RequestedSpec[] = []; + for (const file of requested) { + const entry = parsedByKey.get(pathTextKey(file)); + if (entry !== undefined) { + requestedSpecs.push(entry); + } + } + + // The consulted domain: the requested files plus, with `--text`, every + // file the requested expansions transitively consult (SPEC 11.4). + const domainFiles: PathText[] = [...requested]; + if (withText) { + domainFiles.push( + ...expansionConsultedFiles( + analysis.graph, + requestedSpecs.map((entry) => entry.spec.document), + ), + ); + } + const domain = new ConsultedDomain(domainFiles); + const findings = orderFindings( + accompanyingFindings(analysis.findings, domain), + ); + + const renderer = new ViewRenderer( + analysis.graph, + analysis.textModel, + withText, + ); + const views = requestedSpecs.map((entry) => renderer.fileView(entry)); + + const document: JsonValue = { + findings: findings.map(findingToJson), + views, + }; + context.stdout.write(canonicalJson(document)); + // SPEC 11.2: any finding or explicitly-unavailable datum → exit 1 with + // the full document emitted; complete and finding-free → exit 0. + return availabilityExit(findings, renderer.carriesUnavailable); +} + +/** + * Renders per-file views in the 12.7 document form, tracking whether any + * emitted datum is the explicit unavailability marker (the SPEC 11.2 exit + * input). Structure is parse-local; the interpreted datums follow + * SPEC 11.2's three states — plain value, stated `null` where 11.4 defines + * structural absence, or `{"unavailable": true}` — and with `--text` the + * own/subtree text values are defined exactly per the expansion rules + * (core/availability.ts `TextAvailability`). + */ +class ViewRenderer { + carriesUnavailable = false; + private readonly textAvailability: TextAvailability; + + constructor( + private readonly graph: WorkspaceGraph, + private readonly textModel: WorkspaceTextModel, + private readonly withText: boolean, + ) { + this.textAvailability = new TextAvailability(graph); + } + + /** The 12.7 unavailability marker, counted toward the exit (SPEC 11.2). */ + private unavailable(): JsonObject { + this.carriesUnavailable = true; + return unavailableJson(); + } + + /** One `{"file", "root", "imports", "occurrences", "comments"}` entry. */ + fileView(entry: RequestedSpec): JsonObject { + const { spec, pathValid } = entry; + const document = spec.document; + // SPEC 11.2: a section's node identity is defined per the spelling and + // chain rules — and in a file whose own path is invalid (SPEC 14.19) + // no node has a defined identity, whatever the content spells. + const defined = pathValid ? definedIdentitySections(document) : null; + // SPEC 11.4: the file's own occurrence records, in document order — + // the graph's occurrence order restricted to one file (SPEC 5.7). + const records = selectOccurrences( + this.graph, + new ConsultedDomain([document.file]), + ); + if (records.some((record) => record.source === null)) { + // The source datum is reported explicitly unavailable (SPEC 11.2). + this.carriesUnavailable = true; + } + return { + file: pathTextJson(document.file), + root: this.nodeJson(document, document.root, defined), + imports: spec.imports.imports.map((declaration) => ({ + range: rangeJson(declaration.statement.range), + // SPEC 11.4: the default binding's identifier — structurally + // absent (null, never unavailable) where the declaration binds no + // default. + name: declaration.bindingName, + // SPEC 11.4/11.2: the resolved target where specifier form and + // discovery define one, explicitly unavailable otherwise. + target: + declaration.designatedFile === null + ? this.unavailable() + : pathTextJson(declaration.designatedFile), + })), + occurrences: records.map(occurrenceRecordJson), + comments: document.comments.map((comment) => rangeJson(comment.range)), + }; + } + + /** + * One node of the positional section tree (SPEC 11.4, 12.7): the + * `{"identity", "range", "opening", "closing", "attributes", "tags", + * "coverage", "children"}` form plus `"ownText"`/`"subtreeText"` exactly + * when `--text` is given. + */ + private nodeJson( + document: SpecDocument, + section: SpecSection, + defined: ReadonlySet<SpecSection> | null, + ): JsonObject { + const isRoot = section.parent === null; + // SPEC 11.2: the identity datum — the root's is defined exactly when + // the file's path is valid; a section's when the spelling, chain, and + // uniqueness rules define it. + const identity = + defined === null + ? this.unavailable() + : isRoot + ? document.path + : defined.has(section) + ? `${document.path}#${section.id ?? ""}` + : this.unavailable(); + // SPEC 11.2/12.7: with `--text`, all-or-nothing over transitive + // expansion — where defined the value is exact, one unresolved + // spelling or embedding cycle on the path makes the whole value + // unavailable; without the flag the members are absent (the stated + // conditional presence — `undefined` members are omitted by the + // canonical serializer). + const ownText = !this.withText + ? undefined + : this.textAvailability.ownTextDefined(document, section) + ? this.textModel.ownText(document, section) + : this.unavailable(); + const subtreeText = !this.withText + ? undefined + : this.textAvailability.subtreeTextDefined(document, section) + ? this.textModel.subtreeText(document, section) + : this.unavailable(); + return { + identity, + range: rangeJson(section.range), + // SPEC 11.4: the construct range's decomposition — a self-closing + // section has an opening-tag range only (the whole tag), the root + // neither. + opening: isRoot ? null : rangeJson(section.openingTagRange), + closing: + isRoot || section.selfClosing + ? null + : rangeJson(section.closingTagRange), + // SPEC 11.4: raw attribute spellings as parsed, one entry per + // spelled attribute in tag order — inclusion is by form. + attributes: section.attributes.map((attribute) => ({ + name: attribute.name, + range: rangeJson(attribute.range), + text: attribute.text, + })), + // SPEC 11.4/11.2: a root's tags and coverage attribute are + // structurally absent — the stated null, never unavailable; a + // section's are plain where its parsed attributes define them + // unambiguously, explicitly unavailable otherwise. + tags: isRoot + ? null + : section.tagsDefined + ? [...section.tags] + : this.unavailable(), + coverage: isRoot + ? null + : section.coverageDefined + ? section.coverage + : this.unavailable(), + children: section.children.map((child) => + this.nodeJson(document, child, defined), + ), + ownText, + subtreeText, + }; + } +} diff --git a/src/cli/main.ts b/src/cli/main.ts index a1febe3..559316d 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -159,6 +159,12 @@ const HANDLERS: ReadonlyMap<string, () => Promise<CommandHandler>> = new Map( async () => (await import("./commands/occurrences.js")).occurrencesCommand, ]; + case "view": + // SPEC 11.4. + return [ + path, + async () => (await import("./commands/view.js")).viewCommand, + ]; case "rename": // SPEC 6.4. return [ diff --git a/src/cli/prepare.ts b/src/cli/prepare.ts index b64ce07..28a57d6 100644 --- a/src/cli/prepare.ts +++ b/src/cli/prepare.ts @@ -17,7 +17,10 @@ import type { ExitCode } from "../core/findings.js"; import type { GraphData } from "../core/graph-data.js"; -import { prepareWorkspaceForAvailability } from "../workspace/availability.js"; +import { + analyzeWorkspaceForAvailability, + prepareWorkspaceForAvailability, +} from "../workspace/availability.js"; import type { WorkspaceAnalysis } from "../workspace/pipeline.js"; import { prepareWorkspaceForRead } from "../workspace/refresh.js"; import type { Invocation } from "./args.js"; @@ -112,3 +115,30 @@ export async function prepareAnalysisForAvailability( } return { ok: true, analysis: prepared.analysis }; } + +/** + * The analysis half of the SPEC 11.2 pre-answer step alone — a pure read, + * configuration errors rendered exactly as `prepareAnalysisForAvailability` + * renders them (SPEC 14.14, 12.0). For surfaces whose argument checks + * consult discovery (`view`'s operand membership, SPEC 11.4): the caller + * runs those checks against the returned analysis, then — the invocation + * valid — performs the SPEC 13.3 refresh participation + * (workspace/availability.ts `finishAvailabilityRefresh`) before + * answering, so a failing invocation writes nothing. + */ +export async function analyzeAnalysisForAvailability( + invocation: Invocation, + context: CommandContext, +): Promise<AvailabilityAnalysis> { + const prepared = await analyzeWorkspaceForAvailability(context.workspace); + if (prepared.kind === "configuration") { + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + context.workspace.configAnchor, + prepared.errors, + ); + return { ok: false, exit: 2 }; + } + return { ok: true, analysis: prepared.analysis }; +} diff --git a/src/core/availability.ts b/src/core/availability.ts index 37ec7bf..334b1c6 100644 --- a/src/core/availability.ts +++ b/src/core/availability.ts @@ -24,6 +24,7 @@ import type { SourceClassification } from "./discovery.js"; import type { Finding } from "./findings.js"; import type { CompiledGlob } from "./glob.js"; import type { DependencyEdgeKind, WorkspaceGraph } from "./graph.js"; +import type { SpecDocument, SpecSection } from "./mdx.js"; import type { PathText } from "./path-text.js"; import { pathTextKey } from "./path-text.js"; import { @@ -246,3 +247,121 @@ export function availabilityExit( ): 0 | 1 { return findings.length > 0 || carriesUnavailable ? 1 : 0; } + +// --------------------------------------------------------------------------- +// Expanded text (SPEC 11.2) — definedness and the expansion-consulted files +// --------------------------------------------------------------------------- + +/** Whether `range` lies within `outer` (byte containment, SPEC 1.7). */ +function rangeWithin(outer: ByteRange, range: ByteRange): boolean { + return range.start >= outer.start && range.end <= outer.end; +} + +/** + * The files a request's expansions transitively consult beyond the + * requested files themselves (SPEC 11.4, with `--text`): exactly the files + * of the resolved targets reachable from the requested files' embeddings + * through resolved — occurrence-recording (SPEC 5.7) — embeddings, an + * embedding cycle's participants included, whether or not any expansion + * completes. A spelling that records no occurrence is an expansion's + * boundary: it consults no further file; a masked file (SPEC 14.20) is + * never consulted — no spelling resolves into it (SPEC 11.2). From an + * embedded target the expansion re-enters exactly the embeddings anywhere + * in that target's subtree (SPEC 11.2), so the walk recurses over the + * embeddings lying within the target section's construct range. + */ +export function expansionConsultedFiles( + graph: WorkspaceGraph, + requested: readonly SpecDocument[], +): PathText[] { + const files: PathText[] = []; + const fileKeys = new Set<string>(); + const visited = new Set<SpecSection>(); + + const visitTarget = (document: SpecDocument, section: SpecSection): void => { + if (visited.has(section)) return; + visited.add(section); + for (const embedding of document.embeddings) { + if (!rangeWithin(section.range, embedding.range)) continue; + const target = graph.embeddingTarget(embedding); + if (target === null) continue; // no occurrence — the boundary + const key = pathTextKey(target.document.file); + if (!fileKeys.has(key)) { + fileKeys.add(key); + files.push(target.document.file); + } + visitTarget(target.document, target.section); + } + }; + + for (const document of requested) { + // With `--text` every node's text is computed, the root's subtree + // covering the whole file (SPEC 1.2), so every embedding of a + // requested file starts an expansion. + visitTarget(document, document.root); + } + return files; +} + +/** + * Per-node definedness of the SPEC 11.2 expanded-text values: a node's own + * (respectively subtree) text is defined exactly when every embedding the + * expansion transitively reaches — each `{text(...)}` spelling in the + * node's own contribution (respectively anywhere in its subtree), and + * recursively each one anywhere in every embedded target's subtree — + * records an occurrence (resolved through the graph's embedding index) and + * the recursion re-enters no node already being expanded (an embedding + * cycle). One unresolved spelling or one cycle on the expansion path makes + * the whole value unavailable — partial expansion never occurs. Where + * defined, the text model's values are exact (SPEC 11.2). + */ +export class TextAvailability { + /** Per-section verdict; "visiting" marks a subtree expansion in progress. */ + private readonly state = new Map<SpecSection, "visiting" | boolean>(); + + constructor(private readonly graph: WorkspaceGraph) {} + + /** Whether the node's own text (SPEC 1.6) is defined (SPEC 11.2). */ + ownTextDefined(document: SpecDocument, section: SpecSection): boolean { + for (const embedding of document.embeddings) { + // The node's own contribution: the embeddings whose innermost + // section is the node itself (children's are excised, SPEC 1.6). + if (embedding.section !== section) continue; + const target = this.graph.embeddingTarget(embedding); + if (target === null) return false; // records no occurrence + if (!this.subtreeTextDefined(target.document, target.section)) { + return false; + } + } + return true; + } + + /** Whether the node's subtree text (SPEC 1.6) is defined (SPEC 11.2). */ + subtreeTextDefined(document: SpecDocument, section: SpecSection): boolean { + const memo = this.state.get(section); + if (memo === "visiting") { + // The recursion re-entered a node already being expanded: an + // embedding cycle — the value is undefined for every node on or + // reaching the cycle (the false return propagates up the chain). + return false; + } + if (typeof memo === "boolean") return memo; + this.state.set(section, "visiting"); + let defined = true; + for (const embedding of document.embeddings) { + // Anywhere in the subtree: the embeddings within the construct range + // (the whole file for the root, SPEC 1.2). + if (!rangeWithin(section.range, embedding.range)) continue; + const target = this.graph.embeddingTarget(embedding); + if ( + target === null || + !this.subtreeTextDefined(target.document, target.section) + ) { + defined = false; + break; + } + } + this.state.set(section, defined); + return defined; + } +} diff --git a/src/core/graph.ts b/src/core/graph.ts index e512fb8..1144fd5 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -718,10 +718,21 @@ export function buildWorkspaceGraph( ); } for (const embedded of spec.references.embeddings) { - if (embedded.reference === null) continue; + if (embedded.reference === null) { + // No reference extracted: its 14.8 (or a masking 14.15) accounts + // for it (SPEC 14); the text model expands it to nothing. + embeddingIndex.set(embedded.embedding, null); + continue; + } const outcome = invalidPathOutcome(embedded.reference.target); if (outcome.ok) { - // SPEC 5.7: the occurrence spans the entire braced container. + // SPEC 5.7: the occurrence spans the entire braced container. The + // resolved target also enters the embedding index: the text model + // expands an invalid-path file's resolving embeddings exactly like + // any other (SPEC 11.2 — a defined text value is exact on + // imperfect files too), while the file still contributes no nodes + // and no edges. + embeddingIndex.set(embedded.embedding, outcome.node); addOccurrence( file, embedded.embedding.range, @@ -731,6 +742,7 @@ export function buildWorkspaceGraph( ); continue; } + embeddingIndex.set(embedded.embedding, null); // SPEC 14: an embedding-form finding's range is the full braced // container — the span its occurrence would occupy (5.7). findings.push( diff --git a/src/core/mdx.ts b/src/core/mdx.ts index fb618dc..c36f4d1 100644 --- a/src/core/mdx.ts +++ b/src/core/mdx.ts @@ -96,6 +96,22 @@ export interface SpecDependencyAttribute { readonly attributeRange: ByteRange; } +/** + * One raw attribute spelling as parsed (SPEC 11.4): every attribute the + * tag spells appears — repeated, unknown, and spread attributes included, + * their invalidity a located finding, never an omission. `name` is the + * attribute's name as spelled, structurally absent (null) for a spread + * attribute; `range` the attribute's own characters (SPEC 1.7) — for a + * named attribute its name through the last character of its value, or the + * bare name where it spells no value; for a spread attribute its entire + * braced construct — and `text` those exact source characters. + */ +export interface SpecRawAttribute { + readonly name: string | null; + readonly range: ByteRange; + readonly text: string; +} + /** * One requirement section (SPEC 1.1) or the file's implicit root (SPEC 1.2, * distinguished by `parent === null`). Sections form the containment tree; @@ -150,6 +166,29 @@ export interface SpecSection { readonly idAttribute: SpecAttributeValue | null; /** The `d` attribute's recorded expression span, when validly braced. */ readonly dependency: SpecDependencyAttribute | null; + /** + * The raw attribute spellings as parsed, one entry per attribute the tag + * spells, in tag order (SPEC 11.4). Empty for the root, which has no tag. + */ + readonly attributes: readonly SpecRawAttribute[]; + /** + * SPEC 11.2: whether the interpreted `tags` value is defined — an absent + * prop defines the default (no tags), while a repeated, malformed + * (braced or valueless), or invalid-valued (SPEC 1.4 → 14.4) `tags` prop + * leaves the interpreted value undefined, its raw spelling still listed + * in `attributes`. `tags` holds the interpreted value only where this is + * true. The root's `tags` is structurally absent, not undefined + * (SPEC 11.4): true there. + */ + readonly tagsDefined: boolean; + /** + * SPEC 11.2: whether the interpreted coverage value is defined — the + * `tags` rule's coverage counterpart (absent → the default "required"; + * repeated, braced, valueless, or a value other than "required"/"none" → + * undefined). `coverage` holds the interpreted value only where this is + * true; structurally absent (null) for the root, which is not undefined. + */ + readonly coverageDefined: boolean; } /** One `{text(...)}` embedding occurrence (SPEC 2.3). */ @@ -1205,6 +1244,9 @@ interface MutableSection { tags: readonly string[]; idAttribute: SpecAttributeValue | null; dependency: SpecDependencyAttribute | null; + attributes: SpecRawAttribute[]; + tagsDefined: boolean; + coverageDefined: boolean; /** Whether an `id` prop occurred at all (14.1 is only for absence). */ idPresent: boolean; } @@ -1251,6 +1293,9 @@ class DocumentBuilder { tags: [], idAttribute: null, dependency: null, + attributes: [], + tagsDefined: true, + coverageDefined: true, idPresent: false, }; } @@ -1569,6 +1614,9 @@ class DocumentBuilder { tags: [], idAttribute: null, dependency: null, + attributes: [], + tagsDefined: true, + coverageDefined: true, idPresent: false, }; this.processAttributes(node, section); @@ -1584,7 +1632,17 @@ class DocumentBuilder { for (const attribute of node.attributes ?? []) { const attrSpan = this.spanOf(attribute); const attrRange = this.byteRange(attrSpan.start, attrSpan.end); - if (attribute.type !== "mdxJsxAttribute") { + const named = attribute.type === "mdxJsxAttribute"; + // SPEC 11.4: every attribute the tag spells is recorded as a raw + // entry, in tag order — repeated, unknown, and spread attributes + // included; a spread attribute's name is structurally absent, its + // text its entire braced construct. + section.attributes.push({ + name: named ? (attribute.name ?? "") : null, + range: attrRange, + text: this.text.slice(attrSpan.start, attrSpan.end), + }); + if (!named) { // SPEC 2.7 → 14.17: every prop is a named attribute; a spread // attribute is invalid. this.addFinding( @@ -1610,15 +1668,38 @@ class DocumentBuilder { if (name === "id") { idUnusable = true; // ambiguous declaration — no usable ID } + // SPEC 11.2: a repeated `tags`/`coverage` prop leaves the + // interpreted value undefined — no occurrence is picked. + if (name === "tags") { + section.tagsDefined = false; + } + if (name === "coverage") { + section.coverageDefined = false; + } continue; } seen.add(name); if (name === "d") { this.processDependencyProp(attribute, attrSpan, section); } else if (name === "id" || name === "coverage" || name === "tags") { - this.processStringProp(name, attribute, attrSpan, section, () => { - idUnusable = true; - }); + const interpreted = this.processStringProp( + name, + attribute, + attrSpan, + section, + () => { + idUnusable = true; + }, + ); + // SPEC 11.2: a malformed (braced/valueless) or invalid-valued + // `tags`/`coverage` prop leaves the interpreted value undefined, + // its raw spelling still listed. + if (!interpreted && name === "tags") { + section.tagsDefined = false; + } + if (!interpreted && name === "coverage") { + section.coverageDefined = false; + } } else { // SPEC 2.7 → 14.17: the props defined on <S>/<Spec> are id, d, // coverage, and tags. @@ -1674,7 +1755,11 @@ class DocumentBuilder { /** * SPEC 2.7: the value of `id`, `coverage`, and `tags` MUST be a static * string literal in quoted attribute form. Validates the value and - * records it on the section (SPEC 1.3, 2.5, 2.6 → 14.4, 14.17). + * records it on the section (SPEC 1.3, 2.5, 2.6 → 14.4, 14.17). Returns + * whether the prop's interpreted value is defined (SPEC 11.2): false for + * a malformed (braced/valueless) or invalid-valued `tags`/`coverage` + * occurrence — a spelled `id`'s definedness is the identity machinery's + * (`definedIdentitySections`), not this predicate's. */ private processStringProp( name: "id" | "coverage" | "tags", @@ -1682,7 +1767,7 @@ class DocumentBuilder { attrSpan: { start: number; end: number }, section: MutableSection, onIdUnusable: () => void, - ): void { + ): boolean { const attrRange = this.byteRange(attrSpan.start, attrSpan.end); if (name === "id") { section.idPresent = true; @@ -1703,7 +1788,7 @@ class DocumentBuilder { if (name === "id") { onIdUnusable(); } - return; + return false; } const open = this.valueOpenIndex(attribute, attrSpan); const quoteCharacter = open === null ? null : this.text[open]; @@ -1733,10 +1818,10 @@ class DocumentBuilder { `only defined values are "required" (the default) and "none" ` + `(SPEC 2.5, 2.7, 14.17)`, ); - return; + return false; } section.coverage = value; - return; + return true; } if (name === "tags") { @@ -1744,8 +1829,10 @@ class DocumentBuilder { // yielding no tags is equivalent to omitting the prop. const tags = splitTags(value); section.tags = tags; + let tagsValid = true; if (rawHasNul) { // SPEC 1.4 → 14.4: U+0000 is a control character. + tagsValid = false; this.addFinding( 4, attrRange, @@ -1757,6 +1844,9 @@ class DocumentBuilder { for (const tag of tags) { const violation = valueViolation(tag, "tag"); if (violation !== null) { + // SPEC 11.2: an invalid-valued prop leaves the interpreted + // value undefined. + tagsValid = false; this.addFinding( 4, attrRange, @@ -1766,7 +1856,7 @@ class DocumentBuilder { ); } } - return; + return tagsValid; } // name === "id" (SPEC 1.3): record the declared ID and validate its @@ -1801,6 +1891,9 @@ class DocumentBuilder { ); } } + // The spelled identity stays spelled whatever its segments (SPEC 11.2); + // its definedness is judged by `definedIdentitySections`. + return true; } /** diff --git a/src/core/spec-references.ts b/src/core/spec-references.ts index 9a63ddb..3d12c44 100644 --- a/src/core/spec-references.ts +++ b/src/core/spec-references.ts @@ -79,6 +79,16 @@ export interface SpecImport { * of 11.4 read it. Null exactly for an invalid import. */ readonly targetFile: PathText | null; + /** + * The declaration's resolved target file where specifier form and + * discovery define one (SPEC 11.4) — binding validity notwithstanding: + * the file an in-form (`./`/`../`, `.xspec`) specifier designates when + * that member is discovered, whatever other defects the declaration + * carries. Null where form or discovery defines none — the view reports + * the datum explicitly unavailable (SPEC 11.2). Equal to `targetFile` + * for a valid import. + */ + readonly designatedFile: PathText | null; /** Whether the import itself is valid (duplicate bindings are pairwise). */ readonly valid: boolean; } @@ -469,6 +479,7 @@ export function analyzeSpecImports( let targetPath: string | null = null; let targetFile: PathText | null = null; let undefinedTarget: PathText | null = null; + let designatedFile: PathText | null = null; if (relative && specifier.endsWith(XSPEC_SUFFIX)) { const designation = designate(specifier); if (designation.kind === "outside-root") { @@ -484,12 +495,14 @@ export function analyzeSpecImports( } else if (designation.kind === "defined-member") { targetPath = designation.path; targetFile = designation.path; + designatedFile = designation.path; } else { // SPEC 14.19/11.2: a discovered member whose path is invalid is // designated validly — no 14.15 — while its identities are all // undefined, so references rooted at this binding never resolve. targetFile = designation.file; undefinedTarget = designation.file; + designatedFile = designation.file; } } @@ -560,6 +573,9 @@ export function analyzeSpecImports( }), targetPath, targetFile, + // SPEC 11.4: the datum turns on specifier form and discovery + // alone — kept through the invalid-import reset above. + designatedFile, valid, }); } diff --git a/src/workspace/availability.ts b/src/workspace/availability.ts index a8593e5..a36bd5a 100644 --- a/src/workspace/availability.ts +++ b/src/workspace/availability.ts @@ -65,23 +65,40 @@ export type AvailabilityPreparation = }; /** - * The SPEC 11.2 pre-answer step: analyze the current workspace; on - * configuration errors fail with exit-2 precedence; on a workspace failing - * `build`'s validations answer from the analysis consulting nothing and - * writing nothing; on a passing one participate in read-time refresh - * exactly as the reads of 13.3 do, then answer from the same analysis. + * The analysis half of the SPEC 11.2 pre-answer step: analyze the current + * workspace — a pure read, nothing consulted beyond the sources and + * nothing modified — failing only with configuration-error precedence + * (SPEC 14.14). Callers whose argument checks consult discovery (`view`'s + * operand membership, SPEC 11.4) run them between this and + * `finishAvailabilityRefresh`: the checks precede answering (SPEC 11.2, + * 12.0), and a failing invocation writes nothing. */ -export async function prepareWorkspaceForAvailability( +export async function analyzeWorkspaceForAvailability( workspace: LoadedWorkspace, ): Promise<AvailabilityPreparation> { const analysis = await analyzeWorkspace(workspace); if (analysis.configurationErrors.length > 0) { return { kind: "configuration", errors: analysis.configurationErrors }; } + return { kind: "answer", analysis }; +} + +/** + * The refresh half of the SPEC 11.2 pre-answer step: on a workspace whose + * current sources fail `build`'s validations — source findings, journal + * errors, and refused writes alike (SPEC 13.3) — do nothing (no store + * read, no journal consequence, no write); on a passing one participate in + * read-time refresh exactly as the reads of 13.3 do. The answer itself + * always comes from `analysis`, never from the store. + */ +export async function finishAvailabilityRefresh( + workspace: LoadedWorkspace, + analysis: WorkspaceAnalysis, +): Promise<void> { if (analysis.findings.length > 0) { // SPEC 11.2/13.3: the current sources fail build validation — answer // from them; no store read, no journal consequence, no write. - return { kind: "answer", analysis }; + return; } // What `xspec build` would write for the current sources and @@ -110,7 +127,7 @@ export async function prepareWorkspaceForAvailability( build.writePaths, ); if (writeFindings.length > 0) { - return { kind: "answer", analysis }; + return; } // Passing workspace: read-time refresh participation (SPEC 13.3), as in @@ -124,5 +141,21 @@ export async function prepareWorkspaceForAvailability( refreshedGraphData(stored.data, build.graphData), ); } - return { kind: "answer", analysis }; +} + +/** + * The SPEC 11.2 pre-answer step: analyze the current workspace; on + * configuration errors fail with exit-2 precedence; on a workspace failing + * `build`'s validations answer from the analysis consulting nothing and + * writing nothing; on a passing one participate in read-time refresh + * exactly as the reads of 13.3 do, then answer from the same analysis. + */ +export async function prepareWorkspaceForAvailability( + workspace: LoadedWorkspace, +): Promise<AvailabilityPreparation> { + const prepared = await analyzeWorkspaceForAvailability(workspace); + if (prepared.kind === "answer") { + await finishAvailabilityRefresh(workspace, prepared.analysis); + } + return prepared; } From 92a8f7e73e1735504c9f5a294c7f3bc67b669e4f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 02:12:23 +0000 Subject: [PATCH 157/226] sdg(phase-10): resolve byte positions with xspec at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC 11.5: register the JSON-only at command — offset spelling and bound checks and view-equivalent operand membership precede the refresh, the innermost containing section resolves with its 11.2 identity datum, the containing occurrence reports its full 5.7 record, and an unparseable named file answers the explicit unavailability marker beside its 14.20. On a verified store (workspace/fast-read.ts) at answers store-backed and byte-identical to the full path without loading the TypeScript compiler, which holds P-12's exhaustive sweep inside its budget. Verified: T11.5-1..3 pass; P-12 passes (545s/600s); fast/full byte parity swept over 203 offsets; section-11.2 and section-13.3 at arms pass (their remaining failures are B7/C3/C4 territory). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 2 +- specs/tmp/FIX_PLAN.md | 33 +++-- src/cli/args.ts | 19 ++- src/cli/commands/at-common.ts | 55 +++++++ src/cli/commands/at-fast.ts | 182 +++++++++++++++++++++++ src/cli/commands/at.ts | 262 ++++++++++++++++++++++++++++++++++ src/cli/main.ts | 24 ++++ src/workspace/availability.ts | 26 ++++ 8 files changed, 581 insertions(+), 22 deletions(-) create mode 100644 src/cli/commands/at-common.ts create mode 100644 src/cli/commands/at-fast.ts create mode 100644 src/cli/commands/at.ts diff --git a/AGENTS.md b/AGENTS.md index 73f5cf3..d35a512 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Requires Node.js >= 22 and npm. Install dependencies: `npm ci`. - One npm package (`xspec`) holding two distinct programs: the product under `src/` and the test harness under `test/`. The harness never imports product code; it drives the built `xspec` executable as a subprocess. - Build the product: `npm run build` — compiles `src/` (TypeScript ESM, `src/tsconfig.json`) to `dist/`; the `xspec` bin is `dist/cli/bin.js`. Run it: `node dist/cli/bin.js`. -- The built product enables Node's on-disk V8 compile cache (`node:module` `enableCompileCache`; default directory under the OS temp dir, e.g. `/tmp/node-compile-cache`). The first invocations after `npm run build` repopulate it, so one-off CLI timings are slower than steady state; the cache affects timing only, never output. +- The built product enables Node's on-disk V8 compile cache (`node:module` `enableCompileCache`; default directory under the OS temp dir, e.g. `/tmp/node-compile-cache`). The first invocations after `npm run build` repopulate it, so one-off CLI timings are slower than steady state; the cache affects timing only, never output. Steady-state per-invocation cost is ~0.5s for any command that parses the configuration (the TypeScript compiler module alone takes ~0.3s to load even cache-warm) and ~0.12s for the store-backed fast paths (`query` and `at` on a workspace whose `.xspec/graph.json` verifies against the current bytes) — the number that matters when a test sweeping many CLI invocations nears its timeout. - Typecheck both programs: `npm run typecheck` (`src/tsconfig.json`, then `test/tsconfig.json`; the harness is not typechecked by Vitest at run time). `test/fixtures/` is excluded from the harness typecheck: fixture projects are data compiled or executed at test run time and may contain deliberate type errors (e.g. the S-4 fixture). - Consumer fixture programs are compiled through the harness's TypeScript tooling driver (`test/helpers/tooling.ts`), which resolves `@types/node` from this repository's own `node_modules` — `npm ci` (dev dependencies included) must have run for consumer compilation to work. - Full test suite (TEST-SPEC sections 1–17, certification included; the Linux CI leg): `npm test`. Build the product first — tests invoke the built executable. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index ea1fd82..f0bc762 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -192,23 +192,22 @@ records = `selectOccurrences(graph, new ConsultedDomain([file]))` — B6's `occurrence` member reuses this. P-12 stays red until B6's `at` lands, as its own verify line records.) -### B6. `xspec at` - -SPEC 11.5, 12.7. Prereqs B2, B4. Register (JSON-only). Document -`{"findings", "resolution"}`; `resolution` `{"section", "occurrence"}` or -`{"unavailable": true}` (unparseable file — parse finding accompanies, exit 1): - -- `<file>` asserts membership exactly as a `view` operand (unknown / wrong-kind - → exit 2). `<offset>` must be one or more ASCII decimal digits, read decimal, - leading zeros permitted — sign, whitespace, or any other character is a usage - error; an offset greater than the file's byte length is a usage error; equal - resolves to the root. -- `section`: `{"identity", "range"}` of the innermost section construct whose - range contains the offset (root when none — resolution is total over the - file), identity per 11.2. `occurrence`: the containing occurrence's full - record, `null` when the offset lies in none. - -Verify: T11.5-1..3 (`section-11.5.test.ts`), P-12. +(B6 landed: `xspec at` (`src/cli/commands/at.ts`), registered JSON-only with +`<file>` `<offset>` positionals. Membership mirrors `view`'s operand check +(spec/code/invalid sources by `pathTextKey`); the offset-spelling check is +syntactic and precedes the analysis; the offset bound is judged against the +parsed root's range end or, for an unparseable named file, the byte length +read via `readSourceByteLength` (`src/workspace/availability.ts`); the +unparseable file answers `{"unavailable": true}` beside its 14.20, and +identity/occurrence rendering reuses `definedIdentitySections` / +`selectOccurrences` / `occurrenceRecordJson`. Performance: the full path +costs ~0.5s per invocation (the pinned TS-compiler config parse), which +would blow P-12's 600s exhaustive sweep, so `at` also answers from a +verified store — `tryFastAt` in `src/cli/commands/at-fast.ts`, wired in +`main` beside `tryFastQuery` over `workspace/fast-read.ts`, ~0.12s, +byte-identical to the full path (usage diagnostics shared through +`src/cli/commands/at-common.ts`). If another exhaustive sweep nears its +timeout, this store-backed fast-path pattern is the lever.) ### B7. `xspec inventory` diff --git a/src/cli/args.ts b/src/cli/args.ts index dbfeb48..c86ffa5 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -10,9 +10,9 @@ // // - The first argv element names a command from the known table (12.5): // `build`, `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, -// `occurrences`, `view`, `rename`, `move`, `version`. `review` and `query` -// take a subcommand as the next element. Unknown commands and subcommands -// are usage errors (12.0). +// `occurrences`, `view`, `at`, `rename`, `move`, `version`. `review` and +// `query` take a subcommand as the next element. Unknown commands and +// subcommands are usage errors (12.0). // - Tokens beginning `--` are flags; a value flag consumes the following // element, verbatim, as its value. The specification writes only the // space-separated form, so a token like `--config=x` is an unknown flag. @@ -126,7 +126,7 @@ const TEST_HOLD_FLAG: FlagSpec = { * The known command table (SPEC 12.5), in specification order. Argument * forms: `build` 12.1, `check` 12.2, `ids` 12.3, `show` 12.4, `coverage` 8.2, * `impact` 9, `review` 10.7, `query` 11.1, `occurrences` 11.3, `view` 11.4, - * `rename` 6.4, `move` 6.5, `version` 12.6. + * `at` 11.5, `rename` 6.4, `move` 6.5, `version` 12.6. */ const COMMANDS: readonly CommandSpec[] = [ // SPEC 12.1. @@ -307,6 +307,17 @@ const COMMANDS: readonly CommandSpec[] = [ { name: "--text", takesValue: false }, ], }, + // SPEC 11.5: `at <file> <offset>` — JSON-only (SPEC 11). `<file>` asserts + // domain membership exactly as a `view` operand does and `<offset>` must + // be one or more ASCII decimal digits within the file's byte length — + // checks the handler runs against discovery and the file's bytes, before + // answering (SPEC 11.2, 12.0). + { + path: "at", + positionals: ["<file>", "<offset>"], + flags: [], + jsonOnly: true, + }, // SPEC 6.4: `rename <file> <old-id> <new-id>`. { path: "rename", diff --git a/src/cli/commands/at-common.ts b/src/cli/commands/at-common.ts new file mode 100644 index 0000000..d816830 --- /dev/null +++ b/src/cli/commands/at-common.ts @@ -0,0 +1,55 @@ +// `xspec at` — the argument checks shared by the full path (./at.ts) and +// the store-backed fast path (./at-fast.ts). +// +// SPEC 12.0: output is byte-deterministic for identical input, whichever +// internal path answers — so the two paths share one spelling predicate and +// one diagnostic composition for every usage error of SPEC 11.5. This +// module stays light on purpose: cli/main.ts reaches it through the fast +// path before the TypeScript compiler is loaded. + +/** SPEC 11.5: one or more ASCII decimal digits — nothing else. */ +const OFFSET_SPELLING = /^[0-9]+$/; + +/** Whether `spelling` is a well-formed `<offset>` value (SPEC 11.5). */ +export function offsetSpellingOk(spelling: string): boolean { + return OFFSET_SPELLING.test(spelling); +} + +/** The malformed-`<offset>` diagnostic (SPEC 11.5, 12.0). */ +export function invalidOffsetMessage(spelling: string): string { + return ( + `invalid <offset> value '${spelling}' — one or more ASCII decimal ` + + `digits required (leading zeros permitted; a sign, whitespace, or any ` + + `other character is not a non-negative integer's spelling) ` + + `(SPEC 11.5, 12.0)` + ); +} + +/** The unknown-`<file>` diagnostic (SPEC 11.5, 11.4, 7, 12.0). */ +export function unknownFileMessage(file: string): string { + return ( + `unknown file '${file}' — the <file> operand names a discovered spec ` + + `source, and no configured group discovers this path ` + + `(SPEC 11.5, 11.4, 7, 12.0)` + ); +} + +/** The wrong-kind-`<file>` diagnostic (SPEC 11.5, 11.4, 12.0). */ +export function wrongKindFileMessage(file: string): string { + return ( + `wrong-kind file '${file}' — the operand names a discovered code ` + + `source, and \`at\` resolves positions in spec sources; name a ` + + `discovered spec source (SPEC 11.5, 11.4, 12.0)` + ); +} + +/** The out-of-range-`<offset>` diagnostic (SPEC 11.5, 12.0). */ +export function offsetOutOfRangeMessage( + spelling: string, + byteLength: number, +): string { + return ( + `offset ${spelling} is out of range — only the offsets 0 through the ` + + `file's byte length (${String(byteLength)}) resolve (SPEC 11.5, 12.0)` + ); +} diff --git a/src/cli/commands/at-fast.ts b/src/cli/commands/at-fast.ts new file mode 100644 index 0000000..e000e68 --- /dev/null +++ b/src/cli/commands/at-fast.ts @@ -0,0 +1,182 @@ +// `xspec at` — the store-backed fast path (SPEC 13.3; the full path is +// ./at.ts). +// +// cli/main.ts calls this before loading the full pipeline: when the stored +// graph data verifies against the current workspace bytes +// (workspace/fast-read.ts — every recorded derivation input matches), the +// workspace is exactly the passing one the snapshot was derived from +// (SPEC 12.0 determinism), so the store already "matches the current +// sources and configuration" (SPEC 13.3 — the refresh these surfaces +// participate in would write nothing) and the answer is finding-free with +// every datum defined (a passing workspace carries no findings, SPEC 11.2). +// The snapshot holds everything `at` reports: every requirement node with +// its construct range and identity (a root node per spec source, every +// section a node — zero findings leave no identity undefined), every code +// location, and every reference occurrence (SPEC 5.7), so the resolution is +// read off the stored data byte-for-byte as the full path would derive it. +// A null return means "no verified store" — the caller falls back to the +// full path, whose behavior is exactly the SPEC 11.2/13.3 pre-answer step. +// The fast path performs no writes: a verified store needs no refresh. +// +// The argument checks keep their SPEC 11.2/12.0 semantics and their exact +// diagnostics (./at-common.ts — SPEC 12.0: byte-identical output whichever +// path answers): the syntactic offset check precedes everything; membership +// is judged against the verified snapshot — on a verified store the +// discovered set equals the recorded set with no invalid paths +// (workspace/fast-read.ts), every discovered spec source has its root node +// and every discovered code source its whole-file location (core/graph.ts), +// so the operand's classification is the stored identities' — and the +// offset bound against the root's whole-file range (SPEC 1.7). + +import { canonicalJson } from "../../core/canonical-json.js"; +import type { JsonValue } from "../../core/canonical-json.js"; +import type { ExitCode } from "../../core/findings.js"; +import type { + GraphSnapshot, + StoredRequirementNode, +} from "../../core/graph-data.js"; +import { verifyStoreForRead } from "../../workspace/fast-read.js"; +import type { LocatedWorkspace } from "../../workspace/locate.js"; +import type { Invocation } from "../args.js"; +import type { CliWriter } from "../io.js"; +import { occurrenceRecordJson } from "../report.js"; +import { + invalidOffsetMessage, + offsetOutOfRangeMessage, + offsetSpellingOk, + unknownFileMessage, + wrongKindFileMessage, +} from "./at-common.js"; +import { rangeJson, usageError } from "./common.js"; + +/** The stored source range of `identity`, or undefined when unknown. */ +function rangeOfIdentity( + snapshot: GraphSnapshot, + identity: string, +): { readonly start: number; readonly end: number } | undefined { + for (const node of snapshot.requirements) { + if (node.identity === identity) return node.range; + } + for (const location of snapshot.codeLocations) { + if (location.identity === identity) return location.range; + } + return undefined; +} + +/** + * Answer `at` from the verified store, or return null when no store + * verifies (the caller falls back to the full path). SPEC 11: a single + * JSON document is `at`'s only output form; the argument checks of + * SPEC 11.5 precede the answer exactly as on the full path. + */ +export async function tryFastAt( + invocation: Invocation, + located: LocatedWorkspace, + stdout: CliWriter, + stderr: CliWriter, +): Promise<ExitCode | null> { + const io = { stdout, stderr }; + const file = invocation.positionals[0]!; + const offsetSpelling = invocation.positionals[1]!; + + // SPEC 11.5/12.0: a malformed <offset> is judged from the invocation + // alone — before any store, configuration, or workspace consult. + if (!offsetSpellingOk(offsetSpelling)) { + return usageError(invocation, io, invalidOffsetMessage(offsetSpelling)); + } + const offset = Number.parseInt(offsetSpelling, 10); + + const verified = await verifyStoreForRead(located); + if (verified === null) { + return null; + } + const snapshot = verified.data.snapshot; + + // Operand membership (SPEC 11.5, exactly as a `view` operand, 11.4): + // judged against the verified snapshot (module header). + let root: StoredRequirementNode | undefined; + for (const node of snapshot.requirements) { + if (node.id === null && node.path === file) { + root = node; + break; + } + } + if (root === undefined) { + for (const location of snapshot.codeLocations) { + if (location.identity === file) { + return usageError(invocation, io, wrongKindFileMessage(file)); + } + } + return usageError(invocation, io, unknownFileMessage(file)); + } + + // The offset bound (SPEC 11.5): the root's construct range is the entire + // file (SPEC 1.7), so its end is the file's byte length; greater is a + // usage error, equal resolves to the root. + const byteLength = root.range.end; + if (offset > byteLength) { + return usageError( + invocation, + io, + offsetOutOfRangeMessage(offsetSpelling, byteLength), + ); + } + + // The innermost section construct whose range contains the offset + // (SPEC 1.7: start-inclusive, end-exclusive): sections nest properly, so + // among the containing constructs the innermost is the one opening last; + // the root remains where none contains the offset (the EOF caret + // included). + let section: StoredRequirementNode = root; + for (const node of snapshot.requirements) { + if (node.path !== file || node.id === null) continue; + if (node.range.start <= offset && offset < node.range.end) { + if (section === root || node.range.start > section.range.start) { + section = node; + } + } + } + + // The containing occurrence (SPEC 11.5, 5.7): the named file's records in + // occurrence order, the first whose range contains the offset — null when + // the offset lies within none. The source datum joins its node's stored + // range (a requirement's section construct, a code location's range). + let occurrence: JsonValue = null; + for (const record of snapshot.occurrences) { + if (record.file !== file) continue; + if (record.range.start <= offset && offset < record.range.end) { + if (record.source === null) { + // Unreachable on a verified store (a passing workspace leaves no + // identity undefined, SPEC 11.2) — let the full path decide. + return null; + } + const sourceRange = rangeOfIdentity(snapshot, record.source); + if (sourceRange === undefined) { + // Unreachable: every occurrence's source is a stored node. Let the + // full path decide rather than fabricate. + return null; + } + occurrence = occurrenceRecordJson({ + file: record.file, + range: record.range, + kind: record.kind, + source: { identity: record.source, range: sourceRange }, + target: record.target, + }); + break; + } + } + + // The answer (SPEC 11.5, 12.7): a verified store's domain findings are + // empty and every datum is defined, so the answer is complete and + // finding-free — exit 0 (SPEC 11.2). + const document: JsonValue = { + findings: [], + resolution: { + section: { identity: section.identity, range: rangeJson(section.range) }, + occurrence, + }, + }; + stdout.write(canonicalJson(document)); + return 0; +} diff --git a/src/cli/commands/at.ts b/src/cli/commands/at.ts new file mode 100644 index 0000000..c8e548e --- /dev/null +++ b/src/cli/commands/at.ts @@ -0,0 +1,262 @@ +// `xspec at <file> <offset>` (SPEC 11.5). +// +// Resolves a byte position in a discovered spec source: the innermost +// section construct whose range (SPEC 1.7) contains the offset — the root +// when no narrower section does — reported with its construct range and, +// per SPEC 11.2, its node identity; and, when the offset lies within a +// reference occurrence's range, that occurrence's full record (SPEC 5.7). +// JSON-only (SPEC 11): a single JSON document — the 12.7 +// `{"findings", "resolution"}` form — is its only output form, with or +// without `--json`. +// +// The argument checks precede answering and the refresh (SPEC 11.2, 12.0), +// each a usage error at exit 2 whatever findings the workspace or the named +// file carry: +// +// - `<offset>` must be one or more ASCII decimal digits, read in decimal — +// leading zeros permitted; a sign, whitespace, or any other character is +// not a non-negative integer's spelling (SPEC 11.5). A purely syntactic +// check, judged before any configuration or source is consulted. +// - `<file>` asserts domain membership exactly as a `view` operand does +// (SPEC 11.4): a file outside the discovered set is unknown and a +// discovered code source is a wrong-kind operand; a `#`-containing +// operand is a whole path, never a `path#id` split (SPEC 12.0), so an +// invalid-path spec member with a UTF-8 spelling is addressable (a +// non-UTF-8-pathed one is nameable by no argument value — the glob-reached +// view is the one route to its positions, SPEC 11.5). +// - An offset greater than the file's byte length is a usage error; equal +// (the EOF caret) resolves to the root (SPEC 11.5). The byte length is a +// property of the file's bytes, not of its parse, so the bound is judged +// on unparseable files too — read from the parse where one exists, from +// the filesystem otherwise. +// +// Resolution is by range containment and total over the file (SPEC 11.5): +// every within-file offset resolves through the same positional tree the +// view serves (SPEC 11.4), so `at` adds convenience, not information. The +// consulted domain (SPEC 11.2) is the named file: its findings accompany +// the answer, any finding or explicitly-unavailable datum exits 1 with the +// full document still emitted, and on an unparseable file the resolution is +// exactly the unavailability marker, the parse-failure finding beside it. + +import { + accompanyingFindings, + availabilityExit, + ConsultedDomain, + selectOccurrences, +} from "../../core/availability.js"; +import { canonicalJson } from "../../core/canonical-json.js"; +import type { JsonObject, JsonValue } from "../../core/canonical-json.js"; +import type { ExitCode } from "../../core/findings.js"; +import { orderFindings } from "../../core/findings.js"; +import type { SpecFileAnalysis } from "../../core/graph.js"; +import type { SpecSection } from "../../core/mdx.js"; +import { definedIdentitySections } from "../../core/mdx.js"; +import { pathTextKey } from "../../core/path-text.js"; +import { + finishAvailabilityRefresh, + readSourceByteLength, +} from "../../workspace/availability.js"; +import type { Invocation } from "../args.js"; +import type { CommandContext } from "../io.js"; +import { analyzeAnalysisForAvailability } from "../prepare.js"; +import { + findingToJson, + occurrenceRecordJson, + unavailableJson, +} from "../report.js"; +import { + invalidOffsetMessage, + offsetOutOfRangeMessage, + offsetSpellingOk, + unknownFileMessage, + wrongKindFileMessage, +} from "./at-common.js"; +import { rangeJson, usageError } from "./common.js"; + +/** The `at` command handler (SPEC 11.5). */ +export async function atCommand( + invocation: Invocation, + context: CommandContext, +): Promise<ExitCode> { + const file = invocation.positionals[0]!; + const offsetSpelling = invocation.positionals[1]!; + + // --- the syntactic offset check (SPEC 11.5, 12.0: a malformed value, + // judged from the invocation alone, before anything is consulted) ------- + if (!offsetSpellingOk(offsetSpelling)) { + return usageError( + invocation, + context, + invalidOffsetMessage(offsetSpelling), + ); + } + const offset = Number.parseInt(offsetSpelling, 10); + + // --- the analysis half of the SPEC 11.2 pre-answer step (a pure read) --- + const prepared = await analyzeAnalysisForAvailability(invocation, context); + if (!prepared.ok) { + return prepared.exit; + } + const { analysis } = prepared; + const { classification } = analysis; + + // --- operand membership (SPEC 11.5: exactly as a `view` operand, 11.4): + // judged against the discovered set — discovery is controlled exclusively + // by configuration (SPEC 7), so an on-disk file no group discovers is + // unknown — before any answer or refresh side effect (SPEC 11.2). + const discoveredKinds = new Map<string, "spec" | "code">(); + for (const source of classification.specSources) { + discoveredKinds.set(pathTextKey(source.path), "spec"); + } + for (const source of classification.codeSources) { + discoveredKinds.set(pathTextKey(source.path), "code"); + } + for (const source of classification.invalidSources) { + // SPEC 11.2/14.19: invalid-path members are discovered files of their + // kind — a spec-kind member is addressable where its path has a UTF-8 + // spelling; a code-kind member is a wrong-kind operand like any other + // discovered code source. + discoveredKinds.set(pathTextKey(source.path), source.kind); + } + const kind = discoveredKinds.get(pathTextKey(file)); + if (kind === undefined) { + return usageError(invocation, context, unknownFileMessage(file)); + } + if (kind === "code") { + return usageError(invocation, context, wrongKindFileMessage(file)); + } + + // The named file's parse, where one exists: an unparseable file (masked, + // SPEC 14.20) has none — its resolution is explicitly unavailable below. + const key = pathTextKey(file); + let requested: + | { readonly spec: SpecFileAnalysis; readonly pathValid: boolean } + | undefined; + for (const spec of analysis.specs) { + if (pathTextKey(spec.document.file) === key) { + requested = { spec, pathValid: true }; + break; + } + } + if (requested === undefined) { + for (const spec of analysis.invalidPathSpecs) { + if (pathTextKey(spec.document.file) === key) { + // SPEC 11.2/14.19: parse-local structure stays on view while no + // node of the file has a defined identity. + requested = { spec, pathValid: false }; + break; + } + } + } + + // --- the offset bound (SPEC 11.5): greater than the file's byte length + // is a usage error; equal resolves to the root. The length is the parsed + // root's construct end (the entire file, SPEC 1.7) or, for a file the + // analysis holds no parse for, the file's bytes read directly — with + // unreadable content there is no byte length to judge against, and the + // resolution below is explicitly unavailable regardless. + const byteLength = + requested !== undefined + ? requested.spec.document.root.range.end + : await readSourceByteLength(context.workspace, file); + if (byteLength !== null && offset > byteLength) { + return usageError( + invocation, + context, + offsetOutOfRangeMessage(offsetSpelling, byteLength), + ); + } + + // --- the refresh half (SPEC 13.3, 11.2): the invocation is valid, so + // the surface participates in read-time refresh on a passing workspace + // and touches nothing on a failing one. + await finishAvailabilityRefresh(context.workspace, analysis); + + // --- the answer (SPEC 11.5, 11.2): the consulted domain is the named + // file — its findings alone accompany. + const domain = new ConsultedDomain([file]); + const findings = orderFindings( + accompanyingFindings(analysis.findings, domain), + ); + + let carriesUnavailable = false; + let resolution: JsonValue; + if (requested === undefined) { + // SPEC 11.5/11.2: on an unparseable file the resolution is reported + // explicitly unavailable — never a fabricated root resolution — the + // parse-failure finding accompanying it. + carriesUnavailable = true; + resolution = unavailableJson(); + } else { + const { spec, pathValid } = requested; + const document = spec.document; + + // The innermost section construct whose range contains the offset + // (SPEC 1.7: start-inclusive, end-exclusive), descending the same + // positional tree the view serves (SPEC 11.4) — the root remains where + // no section contains the offset, which also realizes the EOF-caret + // rule: the byte-length offset lies in no end-exclusive range. + let node: SpecSection = document.root; + let descended = true; + while (descended) { + descended = false; + for (const child of node.children) { + if (child.range.start <= offset && offset < child.range.end) { + node = child; + descended = true; + break; + } + } + } + + // SPEC 11.2: the node identity datum — defined per the spelling, chain, + // and uniqueness rules on a valid path (the root's exactly when the + // path is valid), explicitly unavailable otherwise. + const defined = pathValid ? definedIdentitySections(document) : null; + const isRoot = node.parent === null; + let identity: JsonValue; + if (defined === null) { + carriesUnavailable = true; + identity = unavailableJson(); + } else if (isRoot) { + identity = document.path; + } else if (defined.has(node)) { + identity = `${document.path}#${node.id ?? ""}`; + } else { + carriesUnavailable = true; + identity = unavailableJson(); + } + + // SPEC 11.5/5.7: the containing occurrence's full record — the named + // file's records in occurrence order, the first (only: occurrence + // spans are disjoint) whose range contains the offset — or null when + // the offset lies within none. + const records = selectOccurrences(analysis.graph, domain); + const containing = records.find( + (record) => record.range.start <= offset && offset < record.range.end, + ); + let occurrence: JsonValue; + if (containing === undefined) { + occurrence = null; + } else { + if (containing.source === null) { + // The record's source datum is the unavailability marker + // (SPEC 11.2) — an explicitly-unavailable datum in the answer. + carriesUnavailable = true; + } + occurrence = occurrenceRecordJson(containing); + } + + const section: JsonObject = { identity, range: rangeJson(node.range) }; + resolution = { section, occurrence }; + } + + const document: JsonValue = { + findings: findings.map(findingToJson), + resolution, + }; + context.stdout.write(canonicalJson(document)); + // SPEC 11.2: any finding or explicitly-unavailable datum → exit 1 with + // the full document emitted; complete and finding-free → exit 0. + return availabilityExit(findings, carriesUnavailable); +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 559316d..4b0a4b9 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -22,6 +22,7 @@ import type { ExitCode } from "../core/findings.js"; import { locateWorkspace } from "../workspace/locate.js"; import type { Invocation } from "./args.js"; import { COMMAND_PATHS, jsonOutputInEffect, parseArgv } from "./args.js"; +import { tryFastAt } from "./commands/at-fast.js"; import { tryFastQuery } from "./commands/query-fast.js"; import type { CliWriter, CommandContext } from "./io.js"; import { @@ -165,6 +166,12 @@ const HANDLERS: ReadonlyMap<string, () => Promise<CommandHandler>> = new Map( path, async () => (await import("./commands/view.js")).viewCommand, ]; + case "at": + // SPEC 11.5. + return [ + path, + async () => (await import("./commands/at.js")).atCommand, + ]; case "rename": // SPEC 6.4. return [ @@ -271,6 +278,23 @@ export async function main( } } + // SPEC 13.3/11.2: `at` likewise answers from a verified store — the + // store already matches the current sources and configuration, so its + // refresh participation would write nothing and the answer equals the + // full path's byte for byte (SPEC 12.0). Anything unverified falls + // through to the full path below. + if (result.invocation.command === "at") { + const fast = await tryFastAt( + result.invocation, + location.located, + stdout, + stderr, + ); + if (fast !== null) { + return fast; + } + } + // The full path: parse the configuration (a parse failure is the same // exit-2 configuration error as before), then dispatch. const { parseLocatedWorkspace } = await import("../workspace/config.js"); diff --git a/src/workspace/availability.ts b/src/workspace/availability.ts index a36bd5a..eb44f3e 100644 --- a/src/workspace/availability.ts +++ b/src/workspace/availability.ts @@ -28,6 +28,9 @@ // core/build.ts, exactly as ./refresh.ts composes them, so refresh and // build agree byte for byte (SPEC 12.0). +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; + import { computeBuildOutputs } from "../core/build.js"; import type { Finding } from "../core/findings.js"; import { @@ -143,6 +146,29 @@ export async function finishAvailabilityRefresh( } } +/** + * The byte length of one discovered source, read from the filesystem — for + * a named file the analysis holds no parse for (an unparseable source, + * SPEC 14.20): `at`'s out-of-range offset check (SPEC 11.5) is judged + * against the file's bytes, a property of the bytes and not of the parse, + * so the check runs on unparseable files too. Null when the content cannot + * be read (the unreadable 14.20 case): no byte length exists to judge + * against, and the resolution is explicitly unavailable regardless. + */ +export async function readSourceByteLength( + workspace: LoadedWorkspace, + rel: string, +): Promise<number | null> { + try { + const bytes = await fsp.readFile( + path.join(workspace.root, ...rel.split("/")), + ); + return bytes.length; + } catch { + return null; + } +} + /** * The SPEC 11.2 pre-answer step: analyze the current workspace; on * configuration errors fail with exit-2 precedence; on a workspace failing From eeace330865a3a957ef62e01797e2a1153cd62ff Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 02:30:54 +0000 Subject: [PATCH 158/226] sdg(phase-10): report the workspace inventory Register xspec inventory (SPEC 11.6) JSON-only and answer the ten-member 12.7 document without parsing sources, refreshing, or writing: anchoring via the shared 11.6 spelling, the resolved configuration view with every default and inferred kind explicit, every discovered source (invalid-path files included) with its memberships, the derived map by the NAME.mdx byte shape alone (specSourceDerivedPaths), the record-supplied datum through the new shared three-way record read (readDerivedFileRecord: absent = empty, unreadable = condition 23 with concerned path GRAPH_DATA_AREA, exit 1, everything else in full), journal occupancy by presence alone, and session files by name alone in byte order of file name. Discovery-level 14.14 keeps its exit-2 precedence; 14.19/14.13/14.21 are never the inventory's. T11.6-1..4 pass; T12.2-2/3, T13.3-2/3, T14-4 arms touching inventory stay red on the C4/C5/C2 defects the plan already names. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 57 +++----- src/cli/args.ts | 12 +- src/cli/commands/inventory.ts | 266 ++++++++++++++++++++++++++++++++++ src/cli/main.ts | 7 + src/core/discovery.ts | 63 ++++++++ src/core/graph-data.ts | 9 ++ src/workspace/graph-data.ts | 78 ++++++++++ src/workspace/journal.ts | 12 ++ src/workspace/reviews.ts | 32 ++++ 9 files changed, 492 insertions(+), 44 deletions(-) create mode 100644 src/cli/commands/inventory.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index f0bc762..5938e34 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -209,46 +209,23 @@ byte-identical to the full path (usage diagnostics shared through `src/cli/commands/at-common.ts`). If another exhaustive sweep nears its timeout, this store-backed fast-path pattern is the lever.) -### B7. `xspec inventory` - -SPEC 11.6, 12.7, 14.23. Prereqs A1–A4 (anchoring helper from A4). Register -(JSON-only). Parses no sources, never refreshes or writes, answers whatever the -sources' validity; configuration errors keep precedence. Document -`{"findings", "root", "config", "configuration", "sources", "derived", -"recorded", "graphData", "journal", "sessions"}`: - -- Anchoring: `root`/`config` relative to the invocation cwd in the canonical - spelling (ascend `..` segments, then descend, `/`-joined, no `.` segments or - trailing separator; cwd itself `.`); only when the platform admits no relative - path (different Windows drives) the platform's absolute drive-qualified form. -- `configuration` resolved view with every default and inferred kind explicit: - `specs`/`code` one `{"name", "globs"}` per group; `markdown` - `{"emit", "outDir"}` (absent key → `{"emit": false, "outDir": null}`); - `coverage` one `{"name", "target", "targetTags", "targets", "boundary", - "boundaryKind", "mode", "edgeKinds"}` per profile (`targetTags` null when - absent); `policy` one `{"name", "type", "from", "to", "kinds"}` per rule, each - selector `{"group", "kind"}`, `{"files"}`, or `{"tags"}`. -- `sources`: one `{"path", "groups"}` per discovered file, `groups` - `{"name", "kind"}` entries. `derived`: one `{"source", "module", "markdown"}` - per discovered spec source — `module`/`markdown` null for a spec-group file - without `.mdx`; `markdown` null also while emission is disabled. -- `recorded`: the recorded derived-file paths in byte order — empty before any - generation (a missing store is an empty record here), but recorded state that - exists and cannot be read as a record is condition 23: `recorded` is - `{"unavailable": true}`, a finding with code `unreadable-record` and concerned - path `.xspec` accompanies, exit 1, everything else emitted in full. Implement - the record read with a three-way outcome (absent / readable / unreadable) as a - shared helper — the preview delta (B10) and Stage C reuse it. This is the only - finding an inventory ever carries. -- `graphData`: `.xspec` (workspace-relative, no trailing separator). `journal`: - `{"path", "occupied"}` — occupancy is presence of anything at the path, no - content read. `sessions`: every directory entry under the review-session - directory whose name is a well-formed session file name, by name alone, - whatever occupies it, in byte order of file name. Other lists: paths byte - order; groups/profiles/rules configuration order. - -Verify: T11.6-2..4 (`section-11.6.test.ts`; T11.6-1's drive-mismatch arm is -Windows-only, `test/windows/e6-drive-mismatch.test.ts`). +(B7 landed: `xspec inventory` (`src/cli/commands/inventory.ts`), registered +JSON-only between `at` and `rename`. The shared three-way record read is +`readDerivedFileRecord(root)` in `src/workspace/graph-data.ts` — +`DerivedFileRecord` is `{state: "absent"} | {state: "readable", paths} | +{state: "unreadable"}` (absent = empty record; a non-plain occupant or +unparseable bytes = unreadable, condition 23) — B10's delta and C4's `check` +staleness arm reuse it. `GRAPH_DATA_AREA` (`.xspec`, no trailing separator) +is exported from `src/core/graph-data.ts`: the concerned path of every 14.23 +finding, and the path C5's 14.10 unit forms must switch to. Other reusable +pieces: `specSourceDerivedPaths(sourceBytes, configuration)` in +`src/core/discovery.ts` (per-source module/Markdown paths by the `NAME.mdx` +byte shape alone, total over invalid paths), `journalOccupied(root)` in +`src/workspace/journal.ts` (presence alone, lstat), `listSessionFilePaths` +in `src/workspace/reviews.ts` (well-formed session file names by name alone, +byte order of file name), and the discovery-level 14.14 exit-2 routing +inside the handler. T11.6-1..4 green on Linux; T12.2-2/3, T13.3-2/3, T14-4 +inventory-adjacent arms stay red on C4/C5/C2 defects as their notes say.) ### B8. `rename`/`move` refusal contract: every reason, stable codes, 12.7 form diff --git a/src/cli/args.ts b/src/cli/args.ts index c86ffa5..fd1ea73 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -10,9 +10,9 @@ // // - The first argv element names a command from the known table (12.5): // `build`, `check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, -// `occurrences`, `view`, `at`, `rename`, `move`, `version`. `review` and -// `query` take a subcommand as the next element. Unknown commands and -// subcommands are usage errors (12.0). +// `occurrences`, `view`, `at`, `inventory`, `rename`, `move`, `version`. +// `review` and `query` take a subcommand as the next element. Unknown +// commands and subcommands are usage errors (12.0). // - Tokens beginning `--` are flags; a value flag consumes the following // element, verbatim, as its value. The specification writes only the // space-separated form, so a token like `--config=x` is an unknown flag. @@ -126,7 +126,7 @@ const TEST_HOLD_FLAG: FlagSpec = { * The known command table (SPEC 12.5), in specification order. Argument * forms: `build` 12.1, `check` 12.2, `ids` 12.3, `show` 12.4, `coverage` 8.2, * `impact` 9, `review` 10.7, `query` 11.1, `occurrences` 11.3, `view` 11.4, - * `at` 11.5, `rename` 6.4, `move` 6.5, `version` 12.6. + * `at` 11.5, `inventory` 11.6, `rename` 6.4, `move` 6.5, `version` 12.6. */ const COMMANDS: readonly CommandSpec[] = [ // SPEC 12.1. @@ -318,6 +318,10 @@ const COMMANDS: readonly CommandSpec[] = [ flags: [], jsonOnly: true, }, + // SPEC 11.6: `inventory` — JSON-only (SPEC 11: a single JSON document is + // its only output form, with or without `--json`). No flags beyond the + // globals: the inventory is a pure report of the workspace's shape. + { path: "inventory", positionals: [], flags: [], jsonOnly: true }, // SPEC 6.4: `rename <file> <old-id> <new-id>`. { path: "rename", diff --git a/src/cli/commands/inventory.ts b/src/cli/commands/inventory.ts new file mode 100644 index 0000000..5641e7a --- /dev/null +++ b/src/cli/commands/inventory.ts @@ -0,0 +1,266 @@ +// `xspec inventory` (SPEC 11.6). +// +// Reports the machine-readable shape of the workspace — anchoring, resolved +// configuration, discovered sources, the derived-file map, the recorded +// derived paths, the graph-data area, and the durable files — as a single +// JSON document in the 12.7 inventory form. JSON-only (SPEC 11): the +// document is its only output form, with or without `--json`. +// +// The inventory parses no sources, so it answers whatever the sources' +// validity: it runs discovery (the walk and classification — glob-driven, +// never parse-driven) but no per-file analysis, reads no journal or session +// content, and never refreshes or writes anything (SPEC 11.6, 13.3). +// Configuration errors keep their precedence (SPEC 14.14): a missing or +// invalid configuration exits 2 upstream of this handler, and a +// discovery-level configuration error (a file matched by both a spec and a +// code group, SPEC 7.2) exits 2 here, before any answer. The findings a +// listed file or path may bear — an invalid source path (14.19), a journal +// error (14.13), a corrupt session (14.21) — are reported where their +// conditions assign them, never here: the one finding an inventory answer +// ever carries is condition 23 (SPEC 14.23), met in the record-supplied +// datum, with the answer's every other member emitted in full at exit 1. + +import type { JsonObject, JsonValue } from "../../core/canonical-json.js"; +import { canonicalJson } from "../../core/canonical-json.js"; +import type { Configuration, PolicySelector } from "../../core/config.js"; +import { specSourceDerivedPaths } from "../../core/discovery.js"; +import type { SourceClassification } from "../../core/discovery.js"; +import type { ExitCode, Finding } from "../../core/findings.js"; +import { + codeExitClass, + orderFindings, + pathFinding, +} from "../../core/findings.js"; +import { GRAPH_DATA_AREA } from "../../core/graph-data.js"; +import { JOURNAL_PATH } from "../../core/journal.js"; +import type { PathText } from "../../core/path-text.js"; +import { comparePathTexts, pathTextJson } from "../../core/path-text.js"; +import { anchoredPathSpelling } from "../../workspace/anchor.js"; +import { discoverSources } from "../../workspace/discovery.js"; +import { readDerivedFileRecord } from "../../workspace/graph-data.js"; +import { journalOccupied } from "../../workspace/journal.js"; +import { listSessionFilePaths } from "../../workspace/reviews.js"; +import type { Invocation } from "../args.js"; +import { jsonOutputInEffect } from "../args.js"; +import type { CommandContext } from "../io.js"; +import { + emitConfigurationErrors, + findingToJson, + unavailableJson, +} from "../report.js"; + +/** + * One discovered file as the inventory lists it (SPEC 11.6): its path as + * data, its exact bytes (the ordering and derived-path space), the kind of + * its memberships, and the matching group names in configuration order. + */ +interface ListedSource { + readonly path: PathText; + readonly bytes: Uint8Array; + readonly kind: "spec" | "code"; + readonly groups: readonly string[]; +} + +/** + * Every discovered source file — valid spec and code sources and the files + * 14.19 rejects alike: discovery is glob-driven, never parse-driven (SPEC + * 7, 11.6) — in byte order of workspace-relative path (SPEC 11.6). + */ +function listDiscoveredSources( + classification: SourceClassification, +): ListedSource[] { + const utf8Encoder = new TextEncoder(); + const listed: ListedSource[] = [ + ...classification.specSources.map((source): ListedSource => ({ + path: source.path, + bytes: utf8Encoder.encode(source.path), + kind: "spec", + groups: source.groups, + })), + ...classification.codeSources.map((source): ListedSource => ({ + path: source.path, + bytes: utf8Encoder.encode(source.path), + kind: "code", + groups: source.groups, + })), + ...classification.invalidSources.map((source): ListedSource => ({ + path: source.path, + bytes: source.bytes, + kind: source.kind, + groups: source.groups, + })), + ]; + listed.sort((a, b) => comparePathTexts(a.path, b.path)); + return listed; +} + +/** One group definition of the resolved view: `{"name", "globs"}` (12.7). */ +function groupDefJson(group: { + readonly name: string; + readonly patterns: readonly string[]; +}): JsonObject { + return { name: group.name, globs: [...group.patterns] }; +} + +/** + * A resolved policy selector (SPEC 7.5, 12.7): `{"group", "kind"}` with the + * kind explicit though inferred, `{"files"}`, or `{"tags"}`. + */ +function policySelectorJson(selector: PolicySelector): JsonObject { + switch (selector.selector) { + case "group": + return { group: selector.group, kind: selector.groupKind }; + case "files": + return { files: selector.pattern }; + case "tags": + return { tags: [...selector.tags] }; + } +} + +/** + * The resolved configuration view (SPEC 11.6, 12.7): every default and + * inferred kind explicit — an absent `markdown` key resolves to + * `{"emit": false, "outDir": null}` (7.3), `targetTags` null where absent — + * groups, profiles, and rules in configuration order, each carried with its + * complete definition; group references stay the configured group names, + * resolving against the group lists this same view reports. + */ +function configurationViewJson(configuration: Configuration): JsonObject { + return { + specs: configuration.specGroups.map(groupDefJson), + code: configuration.codeGroups.map(groupDefJson), + markdown: { + emit: configuration.markdown?.emit ?? false, + outDir: configuration.markdown?.outDir ?? null, + }, + coverage: configuration.coverage.map((profile): JsonObject => ({ + name: profile.name, + target: profile.target, + targetTags: + profile.targetTags === undefined ? null : [...profile.targetTags], + targets: profile.targets, + boundary: profile.boundary, + boundaryKind: profile.boundaryKind, + mode: profile.mode, + edgeKinds: [...profile.edgeKinds], + })), + policy: configuration.policy.map((rule): JsonObject => ({ + name: rule.name, + type: rule.type, + from: policySelectorJson(rule.from), + to: policySelectorJson(rule.to), + kinds: [...rule.kinds], + })), + }; +} + +/** + * The one finding an inventory answer ever carries (SPEC 11.6, 14.23): + * recorded state that exists but cannot be read as a record. The concerned + * path is the graph-data area — the record's layout is deliberately + * unenumerated (13.3), so no path inside it is named and the finding has no + * in-source locations. + */ +function unreadableRecordFinding(): Finding { + return pathFinding( + 23, + `the recorded generation state under the graph-data area exists but ` + + `cannot be read as a record, so the recorded derived-file paths are ` + + `unavailable — a successful \`xspec build\` (or a finishing ` + + `rename/move regeneration) replaces the record (SPEC 14.23, 13.3)`, + GRAPH_DATA_AREA, + ); +} + +/** The `inventory` command handler (SPEC 11.6). */ +export async function inventoryCommand( + invocation: Invocation, + context: CommandContext, +): Promise<ExitCode> { + const { workspace } = context; + const { configuration } = workspace; + + // SPEC 11.6: discovery — the walk and glob classification, no parsing. + const classification = await discoverSources(workspace.root, configuration); + + // SPEC 14.14: configuration errors keep their precedence — a + // discovery-level configuration error (a file matched by both a spec and + // a code group, SPEC 7.2) is usage-class, exit 2, no inventory. The + // finding-class conditions of discovery (14.19) are reported where their + // conditions assign them (build/check), never here (SPEC 11.6). + const configurationErrors = classification.findings.filter( + (finding) => codeExitClass(finding.code) === 2, + ); + if (configurationErrors.length > 0) { + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + workspace.configAnchor, + configurationErrors, + ); + return 2; + } + + // SPEC 11.6: the record-supplied datum (13.3, 14.23), durable-file + // presence (6.1: occupancy alone, no content read), and the session + // files by name alone (10.1) — no journal or session content is read. + const record = await readDerivedFileRecord(workspace.root); + const occupied = await journalOccupied(workspace.root); + const sessions = await listSessionFilePaths(workspace.root); + + const sources = listDiscoveredSources(classification); + const derivedEntries: JsonValue[] = []; + for (const source of sources) { + if (source.kind !== "spec") continue; + // SPEC 11.6/13.1: per discovered spec source, the derived paths + // determined by configuration and discovery alone — the non-`.mdx` + // file's members the stated structural-absence null (12.7). + const derived = specSourceDerivedPaths(source.bytes, configuration); + derivedEntries.push({ + source: pathTextJson(source.path), + module: derived.module === null ? null : pathTextJson(derived.module), + markdown: + derived.markdown === null ? null : pathTextJson(derived.markdown), + }); + } + + // SPEC 14.23: an unreadable record is the one finding an inventory + // answer ever carries — the datum explicitly unavailable, never + // fabricated and never read as an empty record; everything else in full. + const findings = orderFindings( + record.state === "unreadable" ? [unreadableRecordFinding()] : [], + ); + const recorded: JsonValue = + record.state === "readable" + ? [...record.paths] + : record.state === "absent" + ? [] // SPEC 11.6: a missing store is an empty record. + : unavailableJson(); + + // SPEC 12.7: the ten-member inventory document form. The anchoring is + // pure invocation input (SPEC 11.6, 12.0): the workspace root and the + // configuration file relative to the invocation working directory in the + // canonical spelling (workspace/anchor.ts). + const document: JsonValue = { + findings: findings.map(findingToJson), + root: anchoredPathSpelling(context.cwd, workspace.root), + config: workspace.configAnchor, + configuration: configurationViewJson(configuration), + sources: sources.map((source): JsonObject => ({ + path: pathTextJson(source.path), + groups: source.groups.map((name): JsonObject => ({ + name, + kind: source.kind, + })), + })), + derived: derivedEntries, + recorded, + graphData: GRAPH_DATA_AREA, + journal: { path: JOURNAL_PATH, occupied }, + sessions, + }; + context.stdout.write(canonicalJson(document)); + // SPEC 12.0/11.6: an answer carrying a finding or explicitly-unavailable + // data exits 1, emitted in full; a complete, finding-free answer exits 0. + return findings.length > 0 ? 1 : 0; +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 4b0a4b9..7c60bf8 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -172,6 +172,13 @@ const HANDLERS: ReadonlyMap<string, () => Promise<CommandHandler>> = new Map( path, async () => (await import("./commands/at.js")).atCommand, ]; + case "inventory": + // SPEC 11.6. + return [ + path, + async () => + (await import("./commands/inventory.js")).inventoryCommand, + ]; case "rename": // SPEC 6.4. return [ diff --git a/src/core/discovery.ts b/src/core/discovery.ts index 04f92f6..829cfc5 100644 --- a/src/core/discovery.ts +++ b/src/core/discovery.ts @@ -239,6 +239,69 @@ export function markdownEmitDestinations( return destinations; } +/** SPEC 13.4/13.1: the module suffix replacing a source's `.mdx`. */ +const XSPEC_MODULE_SUFFIX = utf8Encoder.encode(".xspec.ts"); + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +} + +/** One discovered spec source's derived paths (SPEC 13.1, 13.2, 11.6). */ +export interface SpecSourceDerivedPaths { + /** + * The generated-module path (`NAME.mdx` → `NAME.xspec.ts`, SPEC 13.1), + * or null for a spec-group file without the `.mdx` extension (14.19), + * which generates nothing — structurally absent (SPEC 11.6, 12.7). + */ + readonly module: PathText | null; + /** + * The Markdown emit destination (`NAME.mdx` → `NAME.md` under + * `markdown.outDir`, SPEC 13.2, 7.3), or null: for a non-`.mdx` source, + * and for every source while emission is disabled — destinations exist + * exactly while emission is enabled (SPEC 7.3, 11.6). + */ + readonly markdown: PathText | null; +} + +/** + * SPEC 13.1/13.2/11.6: a discovered spec source's derived paths, determined + * by configuration and discovery alone — by the `NAME.mdx` name shape over + * the path's exact bytes, never by parsing or by what exists on disk. Total + * over invalid source paths (SPEC 14.19): a non-UTF-8 source's derived + * paths are themselves byte paths, presented in the marked byte form + * wherever an output carries them (SPEC 12.0, 12.7). + */ +export function specSourceDerivedPaths( + sourceBytes: Uint8Array, + configuration: Configuration, +): SpecSourceDerivedPaths { + if (!bytesEndWith(sourceBytes, MDX_SUFFIX)) { + // SPEC 13.1: per-source derived paths are defined by the `NAME.mdx` + // name shape alone — a spec-group file without the extension has no + // generated-module path and no emit destination. + return { module: null, markdown: null }; + } + const stem = sourceBytes.subarray(0, sourceBytes.length - MDX_SUFFIX.length); + const module = pathTextOf(concatBytes(stem, XSPEC_MODULE_SUFFIX)); + const markdown = configuration.markdown; + if (markdown === undefined || !markdown.emit) { + return { module, markdown: null }; + } + const prefix = outDirPrefixBytes(markdown.outDir); + // SPEC 13.2: `NAME.mdx` emits `NAME.md` — the trailing "x" dropped — + // placed per `markdown.outDir` preserving workspace-relative paths (7.3). + const destination = sourceBytes.subarray(0, sourceBytes.length - 1); + return { + module, + markdown: pathTextOf( + prefix === null ? destination : concatBytes(prefix, destination), + ), + }; +} + /** Why a path is a derived-file path (SPEC 13.4). */ export type DerivedPathKind = "xspec-name" | "xspec-dir" | "markdown-destination"; diff --git a/src/core/graph-data.ts b/src/core/graph-data.ts index 344776f..db38386 100644 --- a/src/core/graph-data.ts +++ b/src/core/graph-data.ts @@ -58,6 +58,15 @@ import type { import type { NodeHashes } from "./hashes.js"; import type { WorkspaceTextModel } from "./text-model.js"; +/** + * SPEC 13.3/11.6: the graph-data area — the location under which graph + * data is kept, spelled as its workspace-relative path with no trailing + * separator. The record's layout under it is deliberately unenumerated, so + * the area itself is the concerned path of every condition-23 finding + * (SPEC 14.23) and of 14.10's unit forms — no path inside it is named. + */ +export const GRAPH_DATA_AREA = ".xspec"; + /** SPEC 13.3/13.4: the graph-data file's workspace-relative path. */ export const GRAPH_DATA_PATH = ".xspec/graph.json"; diff --git a/src/workspace/graph-data.ts b/src/workspace/graph-data.ts index 8fd5bae..b55c67a 100644 --- a/src/workspace/graph-data.ts +++ b/src/workspace/graph-data.ts @@ -22,6 +22,7 @@ import * as fsp from "node:fs/promises"; import * as path from "node:path"; +import { compareBytes } from "../core/bytes.js"; import type { GraphData } from "../core/graph-data.js"; import { GRAPH_DATA_PATH, @@ -83,6 +84,83 @@ export async function loadGraphData(root: string): Promise<LoadedGraphData> { return { bytes, data: parseGraphData(text) }; } +/** + * The record-supplied datum's three-way outcome (SPEC 13.3, 14.23): the + * recorded generation state is absent (an empty record — nothing has been + * generated, or the record was removed), readable as a record (the recorded + * derived-file paths), or exists but cannot be read as a record — condition + * 23 for the surfaces that consult the record without refreshing it + * (`inventory`, 11.6; `rename`/`move` previews' delta, 6.6). The refreshing + * reads of 13.3 never use this: they never consult the record and report no + * finding for it. + */ +export type DerivedFileRecord = + | { readonly state: "absent" } + | { + /** The recorded derived-file paths, in byte order (SPEC 11.6, 12.0). */ + readonly state: "readable"; + readonly paths: readonly string[]; + } + | { + /** + * SPEC 14.23: recorded state that exists but cannot be read as a + * record — a non-plain-file occupant, or bytes that are not the + * stored shape (corrupt, merge-conflicted or otherwise). The + * consulting surface reports its record-supplied datum explicitly + * unavailable beside one condition-23 finding whose concerned path is + * the graph-data area, and exits 1 with everything else in full. + */ + readonly state: "unreadable"; + }; + +/** + * Read the recorded derived-file paths as a record (SPEC 13.3, 14.23) — + * the shared record read of the surfaces that consult the record without + * refreshing it (`inventory`, 11.6; preview deltas, 6.6; `check`'s + * unreadable-record staleness arm, 14.10). Never repairs, replaces, or + * otherwise writes: the state persists until a successful `build` or a + * finishing regeneration replaces the record (SPEC 13.3). The occupant is + * classified by lstat (writes.ts): only a plain file is read — anything + * else at the record's path exists but is no readable record. + */ +export async function readDerivedFileRecord( + root: string, +): Promise<DerivedFileRecord> { + const absolute = graphDataAbsolutePath(root); + const occupant = await classifyOccupant(absolute); + if (occupant === "absent") { + return { state: "absent" }; + } + if (occupant !== "file") { + return { state: "unreadable" }; + } + let bytes: Uint8Array; + try { + bytes = await fsp.readFile(absolute); + } catch { + // Vanished between classification and read (SPEC 13.5: concurrent + // commands, last-write-wins): nothing exists to read as a record. + return { state: "absent" }; + } + let text: string; + try { + text = strictUtf8Decoder.decode(bytes); + } catch { + return { state: "unreadable" }; + } + const data = parseGraphData(text); + if (data === null) { + return { state: "unreadable" }; + } + // SPEC 11.6/12.0: the recorded paths as one byte-ordered, duplicate-free + // list (the canonical serialization already writes them so; sorting here + // keeps the datum canonical whatever bytes parsed). + return { + state: "readable", + paths: [...new Set(data.derivedFiles)].sort(compareBytes), + }; +} + /** * Write the graph data (SPEC 13.3): the canonical serialization (core) at * `.xspec/graph.json`, through the derived-file write primitive — atomic diff --git a/src/workspace/journal.ts b/src/workspace/journal.ts index 731feb7..8ddb19e 100644 --- a/src/workspace/journal.ts +++ b/src/workspace/journal.ts @@ -62,6 +62,18 @@ function journalAbsolutePath(root: string): string { return path.join(root, ".xspec", "journal"); } +/** + * SPEC 11.6: whether anything presently occupies the journal's path — + * occupancy is presence alone, whatever kind of filesystem object occupies + * it (a plain file, a directory, a symbolic link broken or not), judged by + * lstat so a link is never probed through (SPEC 13.4). No content is read: + * an absent journal is an empty journal (SPEC 6.1), and the inventory + * reports no 14.13 for whatever the occupant holds. + */ +export async function journalOccupied(root: string): Promise<boolean> { + return (await classifyOccupant(journalAbsolutePath(root))) !== "absent"; +} + /** * The journal loaded from raw file bytes (`null` = the file is absent, an * empty journal, SPEC 6.1) — the I/O-free tail of `loadJournal`, shared diff --git a/src/workspace/reviews.ts b/src/workspace/reviews.ts index 21193fe..33b58f6 100644 --- a/src/workspace/reviews.ts +++ b/src/workspace/reviews.ts @@ -19,6 +19,7 @@ import * as fsp from "node:fs/promises"; import * as path from "node:path"; +import { compareBytes } from "../core/bytes.js"; import type { Finding } from "../core/findings.js"; import type { ReviewSession } from "../core/review.js"; import { @@ -110,6 +111,37 @@ export async function listSessionNames(root: string): Promise<string[]> { return sortSessionNames(names); } +/** + * SPEC 11.6: every directory entry directly under the review-session + * directory whose name is a well-formed session file name + * (`<valid-name>.json`, byte-exact on the extension), selected by name + * alone, whatever kind of filesystem object occupies it — corrupt sessions, + * directories, and symbolic links included: no content is read, so no + * 14.21 arises here. Returned as workspace-relative session file paths in + * byte order of file name (SPEC 11.6's pinned order — the file name, not + * the bare session name). An entry with any other name is not a session + * and is never listed; an absent or non-directory `.xspec/reviews` yields + * no sessions, and a symbolic link there is never traversed (SPEC 13.4). + */ +export async function listSessionFilePaths(root: string): Promise<string[]> { + const directory = reviewsAbsolutePath(root); + if ((await classifyOccupant(directory)) !== "directory") { + return []; + } + let entries: string[]; + try { + entries = await fsp.readdir(directory); + } catch { + return []; + } + const fileNames = entries.filter((entry) => { + if (!entry.endsWith(SESSION_EXTENSION)) return false; + return isValidSessionName(entry.slice(0, -SESSION_EXTENSION.length)); + }); + fileNames.sort(compareBytes); + return fileNames.map((entry) => `${REVIEWS_DIRECTORY}/${entry}`); +} + /** * Load one session by name (SPEC 10.1). The caller has validated the name * (an invalid name is a usage error before any lookup, SPEC 12.0). The From 532b6442df82bd71d86e0e0018bed9766e4f871c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 03:10:03 +0000 Subject: [PATCH 159/226] sdg(phase-10): report every rename/move refusal reason as stable-coded findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B8: replace the ad-hoc first-failure {"refused": ...} refusals with the 12.7 findings report. src/core/refusal.ts evaluates every applicable reason together over the valid workspace — intrinsic-form new IDs, identity change, collisions locating every bearer, structural parent, target parent, destination occupancy and validity (obstructed destination-side directory components included, never 14.22), and the would-be reasons evaluated in identity space over the current graph with the mapping applied: refused-cycle (dependency and spec-import, located at pre-operation coordinates) and refused-unresolvable-reference — one finding per reason, exit 1. The invalid-workspace refusal still reports the analysis findings alone; reanalysis remains only as an unreachable guard. workspace/writes.ts gains probeOccupant and nonDirectoryComponents; graph.ts exports findCycles; the preview (B9) shares this evaluation. Verified green: T6.4-1..7, T6.5-1/2/3/4/6/7, T14-7 (T6.5-5 stays red on C1, T14-4's 14.21 arm on C2, T13.4-6 on C3, T6.6-* on B9/B10, as planned). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 55 +- src/cli/commands/common.ts | 36 -- src/cli/commands/move.ts | 586 +++++------------- src/cli/commands/rename.ts | 133 ++--- src/core/graph.ts | 7 +- src/core/refusal.ts | 1144 ++++++++++++++++++++++++++++++++++++ src/workspace/writes.ts | 58 ++ 7 files changed, 1427 insertions(+), 592 deletions(-) create mode 100644 src/core/refusal.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 5938e34..5cb95b7 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -227,39 +227,28 @@ byte order of file name), and the discovery-level 14.14 exit-2 routing inside the handler. T11.6-1..4 green on Linux; T12.2-2/3, T13.3-2/3, T14-4 inventory-adjacent arms stay red on C4/C5/C2 defects as their notes say.) -### B8. `rename`/`move` refusal contract: every reason, stable codes, 12.7 form - -SPEC 14 (refusal-reason paragraph), 6.4, 6.5, 12.7. Prereqs A1–A2 (codes/form), -B2 (spelling spans for locations). Replace the ad-hoc first-failure refusals -(`emitRefusal` in `src/cli/commands/rename.ts` and `move.ts`, emitting -`{"refused": …}`) with the findings report `{"findings": [...]}` (exit 1): - -- Evaluate and report every applicable reason together, one finding per reason, - each reason on its own terms — e.g. an occupied non-spec-source `.mdx` target - outside every spec group reports both `refused-destination-exists` and - `refused-invalid-destination` (`sectionDestinationProblem` returns one problem - today). -- Per-reason content under the cardinality rule: `refused-invalid-id` / - `refused-identity-unchanged` / `refused-structural-parent` / - `refused-missing-target-parent` concern the stated identity (in - `identities`); `refused-id-collision` locates every colliding bearer; - `refused-unresolvable-reference` locates each rewritten reference spelling - that would not resolve; `refused-cycle` locates the would-be cycle's full - in-source path; `refused-destination-exists` / `refused-invalid-destination` - concern the destination/target path (`path` member). Would-be cycles and - unresolvable references must surface as these refusal codes — today they leak - out of in-memory reanalysis as numbered conditions. -- `refused-invalid-destination` also covers a workspace-relative directory - component of the destination path or of a derived path it would generate - occupied by anything other than a directory (`symlinkComponentOf` in - `src/workspace/writes.ts` checks symlinks only; a plain-file component - currently crashes mid-write) — check destination-side components up front. -- The invalid-workspace refusal reports the workspace's numbered findings alone; - no report ever mixes refusal reasons with numbered conditions. Refusal - evaluation must be shared with `--preview` (B9) — same findings, codes, exit. - -Verify: T6.4-1/3 (`section-6.4.test.ts`), T6.5-1/3/4/6 (`section-6.5*.test.ts`), -T14-7 (`section-14.test.ts`). +(B8 landed: the refusal contract. `src/core/refusal.ts` is the one shared +evaluation — `evaluateRenameRefusals` / `evaluateMoveFileRefusals` / +`evaluateMoveSectionRefusals` return every applicable reason together as +`Finding[]` (one finding per reason, stable codes, 12.7 concerns: +identity-concerning reasons carry `file#id` in `identities`, collisions +locate every bearer, destination reasons carry the `path` member), emitted +through `emitFindingsReport` as `{"findings": [...]}` exit 1; the +invalid-workspace precondition still reports the analysis findings alone +before any reason is evaluated. Would-be cycles and unresolvable +references are evaluated in identity space over the CURRENT graph's +edges/occurrences with the mapping applied (section form re-parented; +`findCycles` exported from `graph.ts`), locating participants at +pre-operation coordinates — reanalysis no longer leaks numbered +conditions and stays only as an unreachable-guard on the success path. +Destination facts: `assessDestinationPath` (core; pure causes + +`componentProbePaths` — destination plus its would-be Markdown emit path) +with `probeOccupant`/`nonDirectoryComponents` (`src/workspace/writes.ts`; +lstat-classified, ENOENT/ENOTDIR/ELOOP → absent) feeding the one +`refused-invalid-destination` finding — obstructed destination-side +components included, never 14.22. B9's `--preview` must call exactly this +evaluation (the CLI face is `assessAndProbeDestination` + the evaluate +functions in `rename.ts`/`move.ts`) for its refusal equivalence.) ### B9. `--preview` for `rename`/`move`: plan surface (mapping + files/edits) diff --git a/src/cli/commands/common.ts b/src/cli/commands/common.ts index 0dd9d36..89446fc 100644 --- a/src/cli/commands/common.ts +++ b/src/cli/commands/common.ts @@ -10,11 +10,6 @@ import * as path from "node:path"; import type { JsonObject, JsonValue } from "../../core/canonical-json.js"; import { canonicalJson } from "../../core/canonical-json.js"; import type { ByteRange } from "../../core/bytes.js"; -import { - containsControl, - containsWhitespace, - FORBIDDEN_SEGMENT_NAMES, -} from "../../core/text.js"; import type { TestHoldSpec } from "../../workspace/lock.js"; import type { Invocation } from "../args.js"; import { flagValue, jsonOutputInEffect } from "../args.js"; @@ -68,37 +63,6 @@ export function testHoldSpecOf( return { given, absolutePath: path.resolve(cwd, given) }; } -/** - * Why `id` is not a valid requirement ID (SPEC 1.4), or null when it is. - * Shared by `rename` and the section form of `move` (SPEC 6.4, 6.5: the new - * ID is valid). Segment splitting on `.` makes the no-`.` rule structural; - * each segment must be non-empty, free of `#`, whitespace, and control - * characters, and none of the forbidden names. - */ -export function requirementIdProblem(id: string): string | null { - for (const segment of id.split(".")) { - if (segment.length === 0) { - return "it has an empty segment"; - } - if (FORBIDDEN_SEGMENT_NAMES.has(segment)) { - return ( - `its segment ${JSON.stringify(segment)} is one of the forbidden ` + - `names ("$", "__proto__", "prototype", "constructor", "then")` - ); - } - if (segment.includes("#")) { - return `its segment ${JSON.stringify(segment)} contains "#"`; - } - if (containsWhitespace(segment)) { - return `its segment ${JSON.stringify(segment)} contains whitespace`; - } - if (containsControl(segment)) { - return `its segment ${JSON.stringify(segment)} contains a control character`; - } - } - return null; -} - /** A source range (SPEC 1.7) as JSON data. */ export function rangeJson(range: ByteRange): JsonObject { return { start: range.start, end: range.end }; diff --git a/src/cli/commands/move.ts b/src/cli/commands/move.ts index 8e1d046..bd9d729 100644 --- a/src/cli/commands/move.ts +++ b/src/cli/commands/move.ts @@ -36,24 +36,28 @@ // reported and the command exits 1. // 4. Valid-workspace precondition (SPEC 6.5 → 6.4): when the current // workspace fails the validations of `xspec build`, the move refuses -// (exit 1) before modifying anything, reporting those findings. -// 5. Move-specific refusals (SPEC 6.5), each exit 1 before modifying -// anything — for the file form: a destination path that is not valid -// UTF-8, contains `#`, is not a well-formed workspace-relative path, -// already exists, belongs to no configured spec group, belongs to a code -// group as well (14.14), would be excluded as a derived-file path (13.4), -// or lacks the `.mdx` extension (14.19). For the section form: a target -// file that is neither a discovered spec source nor a creatable valid -// spec-source path (the same destination-validity family); the exact -// self-move; an invalid `<new-id>` (1.4); a `<new-id>` colliding with an -// ID remaining in the target file after the removal; a missing target -// parent or one inside the moved subtree; a moved reference targeting the -// target file's root node (the local form cannot name it, 2.2). -// 6. The rewritten workspace is re-validated in memory — realizing "all -// rewritten references resolve" and the no-new-cycles rule (import and -// dependency cycles alike, 5.3, 2.1) — and the complete write set passes -// the SPEC 14.22 symlink check; any finding refuses (exit 1) before -// modifying anything. +// (exit 1) before modifying anything, reporting those findings alone — +// no refusal reason evaluated or reported beside them (SPEC 14). +// 5. The refusal contract (SPEC 6.5, 14): every applicable refusal reason +// is evaluated together over the valid workspace (core/refusal.ts) — +// the mirrored identity checks (intrinsic form, identity change, +// collisions after the removal), the target parent, destination +// occupancy and validity (obstructed destination-side directory +// components included), would-be dependency and spec-import cycles, and +// rewritten references that could not resolve — and a refused move +// reports one finding per reason, each with its stable code and +// concerned identity, path, or located participants (at current, +// pre-operation coordinates), as the 12.7 findings report (exit 1), +// modifying nothing. `--preview` (SPEC 6.6) shares exactly this +// evaluation. The destination-side filesystem facts are probed by the +// workspace layer (workspace/writes.ts) over exactly the paths the +// core assessment names. +// 6. The rewritten workspace is re-validated in memory and the complete +// write set passes the SPEC 14.22 symlink check — internal-consistency +// guards on the would-succeed path (the refusal evaluation above +// realizes "all rewritten references resolve" and the no-new-cycles +// rule for the user-facing contract); any finding refuses (exit 1) +// before modifying anything. // // Success writes the rewritten sources, removes the origin (file form), // appends the journal entry, and regenerates; the report is the applied @@ -61,23 +65,27 @@ // information of the preview's `mapping` (SPEC 6.5, 6.4, 6.6) — with // `--json`, the single JSON document (SPEC 12.0). -import * as path from "node:path"; import { computeBuildOutputs } from "../../core/build.js"; import { compareBytes } from "../../core/bytes.js"; -import { canonicalJson } from "../../core/canonical-json.js"; -import type { Configuration } from "../../core/config.js"; import type { DiscoveredSource, SourceClassification, } from "../../core/discovery.js"; import type { ExitCode, Finding } from "../../core/findings.js"; import type { SpecFileAnalysis } from "../../core/graph.js"; -import type { SpecSection } from "../../core/mdx.js"; -import type { SpecReference } from "../../core/spec-references.js"; import { JOURNAL_PATH, serializeJournalEntry } from "../../core/journal.js"; import type { MoveFilePlan, MoveSectionPlan } from "../../core/move.js"; import { planMoveFile, planMoveSection } from "../../core/move.js"; -import { replaceIdPrefix } from "../../core/rename.js"; +import type { + DestinationPathAssessment, + DestinationProbe, +} from "../../core/refusal.js"; +import { + assessDestinationPath, + evaluateMoveFileRefusals, + evaluateMoveSectionRefusals, + UNPROBED_DESTINATION, +} from "../../core/refusal.js"; import { executeBuildOutputs } from "../../workspace/build.js"; import type { LoadedWorkspace } from "../../workspace/config.js"; import { loadGraphData } from "../../workspace/graph-data.js"; @@ -93,8 +101,9 @@ import { analyzeWorkspaceContent, workspaceInputsOf, } from "../../workspace/pipeline.js"; -import { classifyOccupant, describeOccupant } from "../../workspace/writes.js"; import { + nonDirectoryComponents, + probeOccupant, removeSourceFile, symlinkWritePathFindings, writeSourceFile, @@ -107,27 +116,16 @@ import { emitConfigurationErrors, emitFindingsReport, } from "../report.js"; -import { requirementIdProblem, testHoldSpecOf, usageError } from "./common.js"; +import { testHoldSpecOf, usageError } from "./common.js"; /** - * SPEC 6.5/12.0: a refused move is a validation failure — exit 1, the - * refusal report on standard output (SPEC 12.0: reports are standard-output - * content; with `--json`, one JSON document as the entire standard output). + * SPEC 6.5/12.0/12.7: a refused move is a validation failure — exit 1, the + * findings report `{"findings": […]}` on standard output (SPEC 12.0: + * reports are standard-output content; with `--json`, one JSON document as + * the entire standard output). Workspace-precondition findings and + * refusal-reason findings alike go through here — never mixed in one + * report (SPEC 14). */ -function emitRefusal( - json: boolean, - stdout: CliWriter, - message: string, -): ExitCode { - if (json) { - stdout.write(canonicalJson({ refused: { command: "move", message } })); - } else { - stdout.write(`move refused: ${message}\n`); - } - return 1; -} - -/** SPEC 6.5: refusals reported as findings (workspace validation, 14.22). */ function emitFindingsRefusal( json: boolean, stdout: CliWriter, @@ -137,6 +135,45 @@ function emitFindingsRefusal( return 1; } +/** + * Assess a move destination and probe its filesystem facts (SPEC 6.5): + * the pure path assessment (core/refusal.ts), then — for a well-formed, + * probeable path only — the destination occupant (skipped for an already + * discovered section-form target, whose occupant question does not arise) + * and the non-directory directory components of the destination-side + * write paths the assessment names. A malformed spelling is never + * resolved against the workspace root (SPEC 1.5). + */ +async function assessAndProbeDestination( + workspace: LoadedWorkspace, + destination: string, + probeOccupancy: boolean, +): Promise<{ + readonly assessment: DestinationPathAssessment; + readonly probe: DestinationProbe; +}> { + const assessment = assessDestinationPath( + destination, + isValidUtf8ArgumentValue(destination), + workspace.configuration, + ); + if (!assessment.probeable) { + return { assessment, probe: UNPROBED_DESTINATION }; + } + return { + assessment, + probe: { + occupant: probeOccupancy + ? await probeOccupant(workspace.root, destination) + : "file", + obstructedComponents: await nonDirectoryComponents( + workspace.root, + assessment.componentProbePaths, + ), + }, + }; +} + /** The parsed shape of one `move` argument: a bare file, or `file#id`. */ interface MoveArgument { readonly file: string; @@ -156,239 +193,6 @@ function parseMoveArgument(raw: string): MoveArgument { return { file: raw.slice(0, hash), id: raw.slice(hash + 1) }; } -/** - * Why `destination` is not a well-formed workspace-relative spec-source - * path shape (SPEC 1.5: workspace-relative, `/`-separated, no `.`/`..` - * segments — the shape every discovered source path has), or null when it - * is. Checked before any filesystem probe, so a `..`-bearing argument never - * resolves outside the workspace root. - */ -function destinationShapeProblem(destination: string): string | null { - if (destination.length === 0) { - return "it is empty"; - } - if (destination.startsWith("/")) { - return "it is not workspace-relative (SPEC 1.5, 12.0)"; - } - for (const segment of destination.split("/")) { - if (segment === "") { - return "it has an empty path segment"; - } - if (segment === "." || segment === "..") { - return ( - `it has a ${JSON.stringify(segment)} path segment — discovered ` + - `source paths are workspace-relative without "." or ".." (SPEC 1.5)` - ); - } - } - return null; -} - -const utf8Encoder = new TextEncoder(); - -/** The configured groups (spec or code) whose globs match `bytes` (SPEC 7). */ -function matchingGroups( - groups: Configuration["specGroups"], - bytes: Uint8Array, -): string[] { - const names: string[] = []; - for (const group of groups) { - if (group.globs.some((glob) => glob.matches(bytes))) { - names.push(group.name); - } - } - return names; -} - -/** - * SPEC 6.5: why the file-form destination must be refused, or null when it - * is acceptable. Covers the destination-validity family — the path would - * not be a valid discovered spec source after the move — plus the - * destination-exists refusal; each reason is a validation refusal (exit 1), - * never a usage error. - */ -async function fileDestinationProblem( - workspace: LoadedWorkspace, - destination: string, -): Promise<{ readonly problem: string } | { readonly specGroups: string[] }> { - // SPEC 6.5 → 14.19: a destination that is not valid UTF-8 would not be a - // valid discovered spec source. Node decodes non-UTF-8 argv bytes to - // U+FFFD (see cli/args.ts), so U+FFFD marks an undecodable argument. - if (!isValidUtf8ArgumentValue(destination)) { - return { - problem: - `the destination path is not valid UTF-8 — a discovered source ` + - `file's workspace-relative path must be valid UTF-8 (SPEC 6.5, 7, ` + - `14.19)`, - }; - } - // SPEC 6.5 → 1.5/14.19: node identities reserve `#`. - if (destination.includes("#")) { - return { - problem: - `the destination path ${JSON.stringify(destination)} contains "#", ` + - `which node identities reserve (path#id) — it would not be a valid ` + - `discovered spec source (SPEC 6.5, 1.5, 14.19)`, - }; - } - const shape = destinationShapeProblem(destination); - if (shape !== null) { - return { - problem: - `the destination path ${JSON.stringify(destination)} is not a ` + - `well-formed workspace-relative path: ${shape} (SPEC 6.5)`, - }; - } - // SPEC 6.5: refuse a file-form move whose destination file already - // exists — whatever occupies the path (the exact self-move is refused - // here too: its destination is the existing origin). - const occupant = await classifyOccupant( - path.join(workspace.root, ...destination.split("/")), - ); - if (occupant !== "absent") { - return { - problem: - `the destination file ${JSON.stringify(destination)} already ` + - `exists — a file-form move refuses an existing destination ` + - `(SPEC 6.5)`, - }; - } - const bytes = utf8Encoder.encode(destination); - const specGroups = matchingGroups(workspace.configuration.specGroups, bytes); - // SPEC 6.5: a path belonging to no configured spec group — a move never - // takes a node out of the workspace. - if (specGroups.length === 0) { - return { - problem: - `the destination path ${JSON.stringify(destination)} belongs to no ` + - `configured spec group — a move never takes a node out of the ` + - `workspace; choose a destination a spec group's globs match ` + - `(SPEC 6.5, 7)`, - }; - } - // SPEC 6.5 → 14.14: belonging to a code group as well. - const codeGroups = matchingGroups(workspace.configuration.codeGroups, bytes); - if (codeGroups.length > 0) { - return { - problem: - `the destination path ${JSON.stringify(destination)} is matched by ` + - `spec group "${specGroups[0]!}" and code group "${codeGroups[0]!}" ` + - `alike — no file may belong to both a spec and a code group ` + - `(SPEC 6.5, 7.2, 14.14)`, - }; - } - // SPEC 6.5 → 7.1/14.19: lacking the `.mdx` extension. - if (!destination.endsWith(".mdx")) { - return { - problem: - `the destination path ${JSON.stringify(destination)} lacks the ` + - `.mdx extension — every spec-group source must end ".mdx" ` + - `(SPEC 6.5, 7.1, 14.19)`, - }; - } - // SPEC 13.4: derived-file paths are never sources — a file name - // containing `.xspec.` or a path under `.xspec/` is excluded from every - // group, so such a destination would never be discovered. (A configured - // Markdown emit destination always ends ".md" and can never collide with - // a ".mdx" destination.) - const fileName = destination.slice(destination.lastIndexOf("/") + 1); - if (fileName.includes(".xspec.") || destination.startsWith(".xspec/")) { - return { - problem: - `the destination path ${JSON.stringify(destination)} is a ` + - `derived-file path (a file name containing ".xspec." or a path ` + - `under ".xspec/") — derived-file paths are never discovered as ` + - `sources (SPEC 6.5, 13.4)`, - }; - } - return { specGroups }; -} - -/** - * SPEC 6.5 (section form): why a target file that is not already a - * discovered spec source cannot be created at `destination`, or its spec - * groups when it can. The same destination-validity family as the file - * form — the path must be a valid discovered spec source after the move — - * except that the path must be unoccupied (an occupied path that is no - * discovered spec source can never become one by insertion). - */ -async function sectionDestinationProblem( - workspace: LoadedWorkspace, - destination: string, -): Promise<{ readonly problem: string } | { readonly specGroups: string[] }> { - if (!isValidUtf8ArgumentValue(destination)) { - return { - problem: - `the target file path is not valid UTF-8 — a discovered source ` + - `file's workspace-relative path must be valid UTF-8 (SPEC 6.5, 7, ` + - `14.19)`, - }; - } - const shape = destinationShapeProblem(destination); - if (shape !== null) { - return { - problem: - `the target file path ${JSON.stringify(destination)} is not a ` + - `well-formed workspace-relative path: ${shape} (SPEC 6.5)`, - }; - } - const bytes = utf8Encoder.encode(destination); - const specGroups = matchingGroups(workspace.configuration.specGroups, bytes); - if (specGroups.length === 0) { - return { - problem: - `the target file path ${JSON.stringify(destination)} belongs to no ` + - `configured spec group — a move never takes a node out of the ` + - `workspace; choose a target a spec group's globs match (SPEC 6.5, 7)`, - }; - } - const codeGroups = matchingGroups(workspace.configuration.codeGroups, bytes); - if (codeGroups.length > 0) { - return { - problem: - `the target file path ${JSON.stringify(destination)} is matched by ` + - `spec group "${specGroups[0]!}" and code group "${codeGroups[0]!}" ` + - `alike — no file may belong to both a spec and a code group ` + - `(SPEC 6.5, 7.2, 14.14)`, - }; - } - if (!destination.endsWith(".mdx")) { - return { - problem: - `the target file path ${JSON.stringify(destination)} lacks the ` + - `.mdx extension — every spec-group source must end ".mdx" ` + - `(SPEC 6.5, 7.1, 14.19)`, - }; - } - const fileName = destination.slice(destination.lastIndexOf("/") + 1); - if (fileName.includes(".xspec.") || destination.startsWith(".xspec/")) { - return { - problem: - `the target file path ${JSON.stringify(destination)} is a ` + - `derived-file path (a file name containing ".xspec." or a path ` + - `under ".xspec/") — derived-file paths are never discovered as ` + - `sources (SPEC 6.5, 13.4)`, - }; - } - // The path passed every rule yet is no discovered spec source, so - // something undiscoverable occupies it (a directory, a symbolic link — - // discovery never follows them, SPEC 7) — or nothing does and the move - // creates the file (SPEC 6.5). - const occupant = await classifyOccupant( - path.join(workspace.root, ...destination.split("/")), - ); - if (occupant !== "absent") { - return { - problem: - `the target file path ${JSON.stringify(destination)} is occupied ` + - `by ${describeOccupant(occupant)} that is not a discovered spec ` + - `source — the target of a section move must be a discovered spec ` + - `source or a creatable spec-source path (SPEC 6.5, 7)`, - }; - } - return { specGroups }; -} - /** Concatenate byte arrays (the hypothetical post-append journal bytes). */ function concatBytes(parts: readonly Uint8Array[]): Uint8Array { let total = 0; @@ -526,14 +330,25 @@ async function runMoveFile( ): Promise<ExitCode> { const { workspace, stdout, stderr } = context; - // SPEC 6.5: the destination refusals — each refuses (exit 1) before - // modifying anything. - const destinationResult = await fileDestinationProblem( + // SPEC 6.5/14: evaluate every applicable refusal reason together over + // the valid workspace — destination occupancy and validity, identity + // change, and the would-be cycles, one finding per reason — and refuse + // (exit 1) with the 12.7 findings report, nothing modified. + const { assessment, probe } = await assessAndProbeDestination( workspace, destination, + true, ); - if ("problem" in destinationResult) { - return emitRefusal(invocation.json, stdout, destinationResult.problem); + const refusals = evaluateMoveFileRefusals({ + specs: analysis.specs, + graph: analysis.graph, + originPath, + destination, + assessment, + probe, + }); + if (refusals.length > 0) { + return emitFindingsRefusal(invocation.json, stdout, refusals); } // The pure plan: the identity mapping (file part only), the journal @@ -558,7 +373,7 @@ async function runMoveFile( plan, originPath, destination, - destinationResult.specGroups, + assessment.specGroups, ); if (rewritten.configurationErrors.length > 0) { // Unreachable: the destination was validated against the same group @@ -573,8 +388,10 @@ async function runMoveFile( return 2; } if (rewritten.findings.length > 0) { - // SPEC 6.5: the rewrite would not leave a valid workspace — refuse with - // the would-be findings, nothing modified. + // Unreachable: the refusal evaluation above (core/refusal.ts) realizes + // every reason a move can be refused for, so a validated plan leaves a + // valid workspace. Guarded so a regression refuses (exit 1, nothing + // modified) rather than corrupts. return emitFindingsRefusal(invocation.json, stdout, rewritten.findings); } @@ -704,149 +521,64 @@ async function runMoveSection( const { workspace, stdout, stderr } = context; const originPath = originSpec.document.path; const sameFile = targetPath === originPath; - const inMovedSubtree = (id: string): boolean => - id === oldId || id.startsWith(`${oldId}.`); // SPEC 6.5: resolve the target file — the origin itself, another - // discovered spec source, or a creatable spec-source path (the - // destination-validity refusal family; each reason refuses, exit 1, - // before modifying anything). - let targetSpec: SpecFileAnalysis | null; - let createGroups: readonly string[] | null = null; - if (sameFile) { - targetSpec = originSpec; - } else { - const found = analysis.specs.find( - (spec) => spec.document.path === targetPath, - ); - if (found !== undefined) { - targetSpec = found; - } else { - const result = await sectionDestinationProblem(workspace, targetPath); - if ("problem" in result) { - return emitRefusal(invocation.json, stdout, result.problem); - } - targetSpec = null; - createGroups = result.specGroups; - } - } - - // SPEC 6.5 (identity terms): the new identity must differ from the old — - // the exact self-move is refused and appends no journal entry, while a - // cross-file move keeping its ID is valid. - if (sameFile && newId === oldId) { - return emitRefusal( - invocation.json, - stdout, - `'${targetPath}#${newId}' is the moved section's own identity — the ` + - `exact self-move is refused (SPEC 6.5)`, - ); - } - - // SPEC 6.5 → 1.4: the new ID must be valid. A `<new-id>` that is not - // valid UTF-8 cannot be written into a source file faithfully (argv bytes - // that do not decode are irrecoverable; see cli/args.ts). + // discovered spec source, or no discovered source at all (the path the + // move would create, or an occupant the evaluation refuses). + const targetSpec: SpecFileAnalysis | null = sameFile + ? originSpec + : (analysis.specs.find((spec) => spec.document.path === targetPath) ?? + null); + + // A `<new-id>` that is not valid UTF-8 cannot be written into a source + // file faithfully (argv bytes that do not decode are irrecoverable; see + // cli/args.ts): it can never be a valid requirement ID (SPEC 1.6, 1.4), + // refused under its reason's stable code (SPEC 14). if (!isValidUtf8ArgumentValue(newId)) { - return emitRefusal( - invocation.json, - stdout, - `the new ID is not valid UTF-8 — requirement IDs are decoded UTF-8 ` + - `content (SPEC 6.5, 1.6)`, - ); - } - const invalid = requirementIdProblem(newId); - if (invalid !== null) { - return emitRefusal( - invocation.json, - stdout, - `the new ID ${JSON.stringify(newId)} is not a valid requirement ID: ` + - `${invalid} (SPEC 1.4, 6.5)`, - ); - } - - // SPEC 6.5: `<new-id>` must collide with no ID remaining in the target - // file after the removal — the moved subtree's own IDs are vacated by it. - if ( - targetSpec !== null && - targetSpec.document.sections.some( - (section) => - section.id === newId && !(sameFile && inMovedSubtree(section.id)), - ) - ) { - return emitRefusal( - invocation.json, - stdout, - `the new ID ${JSON.stringify(newId)} collides with an ID remaining ` + - `in '${targetPath}' after the removal — IDs are unique within a ` + - `source file (SPEC 1.3, 6.5)`, - ); - } - - // SPEC 6.5: the target parent — the target file's section bearing - // `<new-id>` minus its final segment, needed whenever `<new-id>` has more - // than one segment — must exist and lie outside the moved subtree, - // leaving an insertion point after the removal (the mirrored structural - // parent rule, SPEC 1.3). - const newSegments = newId.split("."); - if (newSegments.length > 1) { - const parentId = newSegments.slice(0, -1).join("."); - const parent = targetSpec?.document.sections.find( - (section) => section.id === parentId, - ); - if (parent === undefined) { - return emitRefusal( - invocation.json, - stdout, - `the target parent '${targetPath}#${parentId}' — the section ` + - `bearing the new ID minus its final segment — does not exist in ` + - `the target file (SPEC 6.5, 1.3)`, - ); - } - if (sameFile && inMovedSubtree(parentId)) { - return emitRefusal( - invocation.json, - stdout, - `the target parent '${targetPath}#${parentId}' lies within the ` + - `moved subtree, leaving no insertion point after the removal ` + - `(SPEC 6.5)`, - ); - } - } - - // SPEC 6.5 → 2.2: a moved reference targeting the target file's root node - // has no rewritable spelling — the local form names IDs in the same file, - // never its root — so the move refuses rather than leave an unresolvable - // rewrite ("all rewritten references resolve"). - if (!sameFile) { - const targetsRootOfTarget = ( - section: SpecSection, - reference: SpecReference, - ): boolean => - section.id !== null && - inMovedSubtree(section.id) && - reference.target.kind === "external" && - reference.target.modulePath === targetPath && - reference.target.segments.length === 0; - const offends = - originSpec.references.dependencies.some((dependency) => - targetsRootOfTarget(dependency.section, dependency.reference), - ) || - originSpec.references.embeddings.some( - (embedding) => - embedding.reference !== null && - targetsRootOfTarget(embedding.embedding.section, embedding.reference), - ); - if (offends) { - return emitRefusal( - invocation.json, - stdout, - `a reference within the moved subtree targets the target file's ` + - `root node — the local reference form names IDs in its own file, ` + - `never the file's root, so no rewrite of it can resolve after ` + - `the move (SPEC 6.5, 2.2)`, - ); - } + return emitFindingsRefusal(invocation.json, stdout, [ + { + code: "refused-invalid-id", + message: + `invalid new ID: the new ID is not valid UTF-8 — requirement ` + + `IDs are decoded UTF-8 content (SPEC 1.6, 1.4); pass a valid ` + + `UTF-8 ID (SPEC 6.5, 14)`, + locations: [], + path: null, + identities: [`${targetPath}#${newId}`], + }, + ]); + } + + // SPEC 6.5/14: evaluate every applicable refusal reason together over + // the valid workspace — the mirrored identity checks, the target + // parent, destination occupancy and validity, would-be cycles, and + // unresolvable rewritten references, one finding per reason — and + // refuse (exit 1) with the 12.7 findings report, nothing modified. The + // destination probes run only where no discovered spec source occupies + // the target path (a discovered target raises no occupancy or validity + // question); its destination-side directory components are vetted + // either way. + const { assessment, probe } = await assessAndProbeDestination( + workspace, + targetPath, + targetSpec === null, + ); + const refusals = evaluateMoveSectionRefusals({ + specs: analysis.specs, + graph: analysis.graph, + origin: originSpec, + oldId, + targetPath, + newId, + target: targetSpec, + assessment, + probe, + }); + if (refusals.length > 0) { + return emitFindingsRefusal(invocation.json, stdout, refusals); } + const createGroups: readonly string[] | null = + targetSpec === null ? assessment.specGroups : null; // The pure plan: the identity mapping, the journal entry, the exact text // edits, and every reference and import rewrite (SPEC 6.5, 6.1). @@ -884,9 +616,11 @@ async function runMoveSection( return 2; } if (rewritten.findings.length > 0) { - // SPEC 6.5: the rewrite would not leave a valid workspace — a move - // creating an import or dependency cycle lands here — refuse with the - // would-be findings, nothing modified. + // Unreachable: the refusal evaluation above (core/refusal.ts) realizes + // every reason a move can be refused for — would-be cycles and + // unresolvable rewritten references included — so a validated plan + // leaves a valid workspace. Guarded so a regression refuses (exit 1, + // nothing modified) rather than corrupts. return emitFindingsRefusal(invocation.json, stdout, rewritten.findings); } diff --git a/src/cli/commands/rename.ts b/src/cli/commands/rename.ts index cef22d8..b14d4d2 100644 --- a/src/cli/commands/rename.ts +++ b/src/cli/commands/rename.ts @@ -25,15 +25,20 @@ // command exits 1. // 4. Valid-workspace precondition (SPEC 6.4): when the current workspace // fails the validations of `xspec build`, the rename refuses (exit 1) -// before modifying anything, reporting those findings. -// 5. New-ID validation (SPEC 6.4): the new ID must be valid (1.4), differ -// from the old ID, collide with no existing ID, and keep the structural -// parent rules (1.3); each failure refuses the rename (exit 1) before -// modifying anything. -// 6. The rewritten workspace is re-validated in memory — realizing "all -// rewritten references resolve" — and the complete write set passes the -// SPEC 14.22 symlink check; any finding refuses (exit 1) before -// modifying anything. +// before modifying anything, reporting those findings alone — no +// refusal reason evaluated or reported beside them (SPEC 14). +// 5. The refusal contract (SPEC 6.4, 14): every applicable refusal reason +// is evaluated together over the valid workspace (core/refusal.ts) — +// the new ID's intrinsic form, identity change, collisions, and the +// structural parent rules — and a refused rename reports one finding +// per reason, each with its stable code and concerned identity or +// located bearer, as the 12.7 findings report (exit 1), modifying +// nothing. `--preview` (SPEC 6.6) shares exactly this evaluation. +// 6. The rewritten workspace is re-validated in memory and the complete +// write set passes the SPEC 14.22 symlink check — internal-consistency +// guards on the would-succeed path (the refusal evaluation above +// realizes "all rewritten references resolve" for the user-facing +// contract); any finding refuses (exit 1) before modifying anything. // // Success writes the rewritten sources, appends the journal entry, and // regenerates; the report is the applied mapping — the complete identity @@ -42,10 +47,9 @@ // (SPEC 12.0). import { computeBuildOutputs } from "../../core/build.js"; -import { canonicalJson } from "../../core/canonical-json.js"; import type { ExitCode, Finding } from "../../core/findings.js"; import { JOURNAL_PATH, serializeJournalEntry } from "../../core/journal.js"; -import type { SpecSection } from "../../core/mdx.js"; +import { evaluateRenameRefusals } from "../../core/refusal.js"; import type { RenamePlan } from "../../core/rename.js"; import { planRename } from "../../core/rename.js"; import { executeBuildOutputs } from "../../workspace/build.js"; @@ -75,27 +79,16 @@ import { emitConfigurationErrors, emitFindingsReport, } from "../report.js"; -import { requirementIdProblem, testHoldSpecOf, usageError } from "./common.js"; +import { testHoldSpecOf, usageError } from "./common.js"; /** - * SPEC 6.4/12.0: a refused rename is a validation failure — exit 1, the - * refusal report on standard output (SPEC 12.0: reports are standard-output - * content; with `--json`, one JSON document as the entire standard output). + * SPEC 6.4/12.0/12.7: a refused rename is a validation failure — exit 1, + * the findings report `{"findings": […]}` on standard output (SPEC 12.0: + * reports are standard-output content; with `--json`, one JSON document as + * the entire standard output). Workspace-precondition findings and + * refusal-reason findings alike go through here — never mixed in one + * report (SPEC 14). */ -function emitRefusal( - json: boolean, - stdout: CliWriter, - message: string, -): ExitCode { - if (json) { - stdout.write(canonicalJson({ refused: { command: "rename", message } })); - } else { - stdout.write(`rename refused: ${message}\n`); - } - return 1; -} - -/** SPEC 6.4: refusals reported as findings (workspace validation, 14.22). */ function emitFindingsRefusal( json: boolean, stdout: CliWriter, @@ -105,37 +98,6 @@ function emitFindingsRefusal( return 1; } -/** - * SPEC 6.4 → 1.3: the renamed section keeps its place in the tree, so the - * new ID must satisfy the structural parent rules at that place — the - * parent's ID plus `"."` plus exactly one segment, or exactly one segment - * for a top-level section. Returns the refusal message, or null when the - * rule holds. - */ -function structuralProblem(section: SpecSection, newId: string): string | null { - const parentId = section.parent === null ? null : section.parent.id; - if (parentId === null) { - // A top-level section (its parent is the implicit root, SPEC 1.2) is - // checked against the empty prefix: exactly one segment (SPEC 1.3). - if (newId.includes(".")) { - return ( - `the renamed section is top-level, so its ID must be exactly one ` + - `segment (SPEC 1.3) — ${JSON.stringify(newId)} has more` - ); - } - return null; - } - const prefix = `${parentId}.`; - if (!newId.startsWith(prefix) || newId.slice(prefix.length).includes(".")) { - return ( - `the renamed section is nested inside ${JSON.stringify(parentId)}, so ` + - `its ID must equal ${JSON.stringify(parentId)} plus "." plus exactly ` + - `one segment (SPEC 1.3)` - ); - } - return null; -} - /** Concatenate byte arrays (the hypothetical post-append journal bytes). */ function concatBytes(parts: readonly Uint8Array[]): Uint8Array { let total = 0; @@ -195,9 +157,8 @@ async function runRename( } // SPEC 6.4 → 12.0: a nonexistent old ID is a usage error, checked before - // source validation. - const section = origin.document.sections.find((s) => s.id === oldId); - if (section === undefined) { + // source validation — parse-local, judged over spelled identities (11.2). + if (!origin.document.sections.some((s) => s.id === oldId)) { return usageError( invocation, context, @@ -208,41 +169,21 @@ async function runRename( // SPEC 6.4: refuse, before modifying anything, when the current workspace // fails the validations of `xspec build` — rename only ever rewrites a - // valid workspace. The findings are the report (SPEC 12.0). + // valid workspace. The invalid-workspace refusal reports the workspace's + // numbered findings alone: no refusal reason is evaluated or reported + // beside them (SPEC 14). if (analysis.findings.length > 0) { return emitFindingsRefusal(invocation.json, stdout, analysis.findings); } - // SPEC 6.4: validate the new ID — each failure refuses (exit 1), nothing + // SPEC 6.4/14: evaluate every applicable refusal reason together over + // the valid workspace — one finding per reason, never only the first + // found, each with its stable code and concerned identity or located + // bearer — and refuse (exit 1) with the 12.7 findings report, nothing // modified. - if (newId === oldId) { - return emitRefusal( - invocation.json, - stdout, - `the new ID must differ from the old ID ${JSON.stringify(oldId)} ` + - `(SPEC 6.4)`, - ); - } - const invalid = requirementIdProblem(newId); - if (invalid !== null) { - return emitRefusal( - invocation.json, - stdout, - `the new ID ${JSON.stringify(newId)} is not a valid requirement ID: ` + - `${invalid} (SPEC 1.4, 6.4)`, - ); - } - const structural = structuralProblem(section, newId); - if (structural !== null) { - return emitRefusal(invocation.json, stdout, `${structural} (SPEC 6.4)`); - } - if (origin.document.sections.some((s) => s.id === newId)) { - return emitRefusal( - invocation.json, - stdout, - `the new ID ${JSON.stringify(newId)} collides with an existing ID in ` + - `'${file}' — IDs are unique within a source file (SPEC 1.3, 6.4)`, - ); + const refusals = evaluateRenameRefusals({ origin, oldId, newId }); + if (refusals.length > 0) { + return emitFindingsRefusal(invocation.json, stdout, refusals); } // The pure plan: the identity mapping, the journal entry, and the minimal @@ -268,8 +209,10 @@ async function runRename( return 2; } if (rewritten.findings.length > 0) { - // SPEC 6.4: the rewrite would not leave a valid workspace — refuse with - // the would-be findings, nothing modified. + // Unreachable: the refusal evaluation above (core/refusal.ts) realizes + // every reason a rename can be refused for, so a validated plan leaves + // a valid workspace. Guarded so a regression refuses (exit 1, nothing + // modified) rather than corrupts. return emitFindingsRefusal(invocation.json, stdout, rewritten.findings); } diff --git a/src/core/graph.ts b/src/core/graph.ts index 1144fd5..5532533 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -1096,9 +1096,12 @@ function importCycleFindings(specs: readonly SpecFileAnalysis[]): Finding[] { * than one node, or a self-loop), the shortest cycle through its * byte-least node, as a closed walk (first identity repeated at the end; * `[a, a]` for a self-loop). Results are ordered by starting identity. - * Adjacency entries naming unknown nodes are ignored. + * Adjacency entries naming unknown nodes are ignored. Exported for the + * `rename`/`move` refusal evaluation (core/refusal.ts), which runs the + * same detection over the would-be post-operation graph (SPEC 6.5, 14 + * `refused-cycle`). */ -function findCycles( +export function findCycles( nodes: readonly string[], adjacency: ReadonlyMap<string, ReadonlySet<string>>, ): string[][] { diff --git a/src/core/refusal.ts b/src/core/refusal.ts new file mode 100644 index 0000000..00c32a8 --- /dev/null +++ b/src/core/refusal.ts @@ -0,0 +1,1144 @@ +// The `rename`/`move` refusal contract (SPEC 6.4, 6.5, 14) — the pure +// evaluation. +// +// SPEC 14 (refusal-reason paragraph): each distinct reason `rename` and +// `move` refuse carries a stable code and, under the location-cardinality +// rule, the file, source range, or identity it concerns; a refused +// operation or preview reports EVERY applicable reason together, one +// finding per reason — never only the first found — each reason's +// applicability read on its own terms. This module evaluates all of them +// over a workspace passing `build`'s validations (the reasons are defined +// only there, SPEC 6.4/6.5 — the invalid-workspace refusal reports the +// workspace's numbered findings alone, upstream of this module) and +// returns the refusal findings as data (IMPLEMENTATION cross-cutting +// rules); the CLI renders them once per output form. `--preview` (SPEC +// 6.6) shares exactly this evaluation: a preview is refused exactly when — +// reporting what, and exiting as — the real operation would be. +// +// Pure core (IMPLEMENTATION Architecture): no I/O. The two filesystem +// facts a move's destination reasons need — what occupies the destination +// path, and which workspace-relative directory components of the +// destination-side write paths are occupied by non-directories — arrive as +// inputs, probed by the workspace layer (workspace/writes.ts) over exactly +// the paths `assessDestinationPath` names. +// +// The would-be reasons — `refused-cycle` and +// `refused-unresolvable-reference` — are evaluated over the post-operation +// workspace modeled in identity space (the current graph's nodes, edges, +// and occurrences with the operation's identity mapping applied, the +// section form's re-parenting included), never by reanalyzing rewritten +// text: the findings locate the participating reference spellings and +// import declarations at their CURRENT, pre-operation coordinates (SPEC +// 14: a refusal renders as precisely as a finding; 6.6: previews report in +// current, pre-operation coordinates). + +import type { ByteRange } from "./bytes.js"; +import { sortByBytes } from "./bytes.js"; +import type { Configuration, ConfiguredGroup } from "./config.js"; +import { specSourceDerivedPaths } from "./discovery.js"; +import type { Finding, FindingLocation, RefusalCode } from "./findings.js"; +import { sortLocations } from "./findings.js"; +import { findCycles } from "./graph.js"; +import type { SpecFileAnalysis, WorkspaceGraph } from "./graph.js"; +import type { SpecSection } from "./mdx.js"; +import type { PathText } from "./path-text.js"; +import { replaceIdPrefix } from "./rename.js"; +import { + containsControl, + containsWhitespace, + FORBIDDEN_SEGMENT_NAMES, +} from "./text.js"; + +/** + * Why `id` is not in intrinsic ID form (SPEC 14: one or more segments + * joined by `.`, each satisfying 1.4), or null when it is. Splitting on + * `.` makes the no-`.` rule structural; each segment must be non-empty, + * free of `#`, whitespace, and control characters, and none of the + * forbidden names. Shared by the refusal evaluation here and the CLI's + * argument diagnostics (SPEC 6.4, 6.5). + */ +export function intrinsicIdProblem(id: string): string | null { + for (const segment of id.split(".")) { + if (segment.length === 0) { + return "it has an empty segment"; + } + if (FORBIDDEN_SEGMENT_NAMES.has(segment)) { + return ( + `its segment ${JSON.stringify(segment)} is one of the forbidden ` + + `names ("$", "__proto__", "prototype", "constructor", "then")` + ); + } + if (segment.includes("#")) { + return `its segment ${JSON.stringify(segment)} contains "#"`; + } + if (containsWhitespace(segment)) { + return `its segment ${JSON.stringify(segment)} contains whitespace`; + } + if (containsControl(segment)) { + return `its segment ${JSON.stringify(segment)} contains a control character`; + } + } + return null; +} + +/** One refusal-reason finding (SPEC 14): stable code, concerned data. */ +function refusalFinding( + code: RefusalCode, + message: string, + parts: { + readonly locations?: readonly FindingLocation[]; + readonly path?: string; + readonly identities?: readonly string[]; + } = {}, +): Finding { + return { + code, + message, + locations: sortLocations(parts.locations ?? []), + path: parts.path ?? null, + identities: parts.identities ?? [], + }; +} + +// --------------------------------------------------------------------------- +// Destination-path assessment (SPEC 6.5: the destination-validity family) +// --------------------------------------------------------------------------- + +/** + * The pure half of `refused-invalid-destination` (SPEC 6.5, 14): whether a + * destination path could be a valid discovered spec source at all, judged + * from its spelling and the configuration alone, plus the derived paths it + * would generate — the paths whose workspace-relative directory components + * the workspace layer must probe for non-directory occupants (SPEC 6.5: + * "or a workspace-relative directory component of the destination path, + * or of a derived path it would generate, occupied by anything other than + * a directory"). + */ +export interface DestinationPathAssessment { + /** + * Why the path would not be a valid discovered spec source (SPEC 6.5 → + * 7, 7.1, 14.19, 13.4), in a fixed evaluation order; empty when the + * spelling and configuration accept it. However many causes hold, they + * feed ONE `refused-invalid-destination` finding (SPEC 14: one finding + * per reason). + */ + readonly causes: readonly string[]; + /** The configured spec groups whose globs match the path (SPEC 7). */ + readonly specGroups: readonly string[]; + /** + * Whether the path is a well-formed workspace-relative path that may be + * probed on disk: a malformed spelling (absolute, `.`/`..` segments, + * empty segments, non-UTF-8) is never resolved against the workspace + * root, so no occupant or component probe runs for it. + */ + readonly probeable: boolean; + /** + * The destination path together with the derived paths it would + * generate (SPEC 13.1, 13.2, 7.3): the generated module and its + * companions share the destination's directory, so probing the + * destination's own components covers them; the Markdown emit + * destination adds its own components while emission is enabled. The + * workspace layer probes the directory components of exactly these. + */ + readonly componentProbePaths: readonly string[]; +} + +/** + * Why `destination` is not a well-formed workspace-relative source-path + * shape (SPEC 1.5: workspace-relative, `/`-separated, no `.`/`..` + * segments — the shape every discovered source path has), or null when it + * is. + */ +function destinationShapeProblem(destination: string): string | null { + if (destination.length === 0) { + return "it is empty"; + } + if (destination.startsWith("/")) { + return "it is not workspace-relative (SPEC 1.5, 12.0)"; + } + for (const segment of destination.split("/")) { + if (segment === "") { + return "it has an empty path segment"; + } + if (segment === "." || segment === "..") { + return ( + `it has a ${JSON.stringify(segment)} path segment — discovered ` + + `source paths are workspace-relative without "." or ".." (SPEC 1.5)` + ); + } + } + return null; +} + +const utf8Encoder = new TextEncoder(); + +/** The configured groups whose globs match `bytes` (SPEC 7). */ +function matchingGroups( + groups: readonly ConfiguredGroup[], + bytes: Uint8Array, +): string[] { + const names: string[] = []; + for (const group of groups) { + if (group.globs.some((glob) => glob.matches(bytes))) { + names.push(group.name); + } + } + return names; +} + +/** + * Assess a move destination path (SPEC 6.5): the file form's `<new-file>` + * or the section form's to-be-created `<target-file>`. `utf8` is whether + * the argument value decoded as valid UTF-8 (cli/args.ts marks + * undecodable argv with U+FFFD); a non-UTF-8 spelling is normally an + * exit-2 usage error first (SPEC 12.0), leaving this cause a dead letter, + * but the reason holds on its own terms (SPEC 14.19: such a path is never + * a valid source path). + */ +export function assessDestinationPath( + destination: string, + utf8: boolean, + configuration: Configuration, +): DestinationPathAssessment { + const causes: string[] = []; + if (!utf8) { + causes.push( + `the path is not valid UTF-8 — a discovered source file's ` + + `workspace-relative path must be valid UTF-8 (SPEC 7, 14.19)`, + ); + } + if (destination.includes("#")) { + causes.push( + `the path contains "#", which node identities reserve (path#id) — ` + + `it would never be a valid discovered spec source (SPEC 1.5, 14.19)`, + ); + } + const shape = destinationShapeProblem(destination); + if (shape !== null) { + causes.push( + `the path is not a well-formed workspace-relative path: ${shape}`, + ); + } + if (causes.length > 0) { + // Malformed spellings match no group and are never probed: a + // `..`-bearing argument must not resolve outside the workspace root. + return { + causes, + specGroups: [], + probeable: false, + componentProbePaths: [], + }; + } + + const bytes = utf8Encoder.encode(destination); + const specGroups = matchingGroups(configuration.specGroups, bytes); + // SPEC 6.5: a path belonging to no configured spec group — a move never + // takes a node out of the workspace. + if (specGroups.length === 0) { + causes.push( + `the path belongs to no configured spec group — a move never takes ` + + `a node out of the workspace; choose a destination a spec group's ` + + `globs match (SPEC 7)`, + ); + } + // SPEC 6.5 → 7.2/14.14: belonging to a code group as well. + const codeGroups = matchingGroups(configuration.codeGroups, bytes); + if (specGroups.length > 0 && codeGroups.length > 0) { + causes.push( + `the path is matched by spec group ${JSON.stringify(specGroups[0]!)} ` + + `and code group ${JSON.stringify(codeGroups[0]!)} alike — no file ` + + `may belong to both a spec and a code group (SPEC 7.2, 14.14)`, + ); + } + // SPEC 6.5 → 7.1/14.19: lacking the `.mdx` extension. + if (!destination.endsWith(".mdx")) { + causes.push( + `the path lacks the .mdx extension — every spec-group source must ` + + `end ".mdx" (SPEC 7.1, 14.19)`, + ); + } + // SPEC 13.4: derived-file paths are never sources — a file name + // containing `.xspec.` or a path under `.xspec/` is excluded from every + // group, so such a destination would never be discovered. (A configured + // Markdown emit destination always ends ".md" and can never collide + // with a ".mdx" destination.) + const fileName = destination.slice(destination.lastIndexOf("/") + 1); + if (fileName.includes(".xspec.") || destination.startsWith(".xspec/")) { + causes.push( + `the path is a derived-file path (a file name containing ".xspec." ` + + `or a path under ".xspec/") — derived-file paths are never ` + + `discovered as sources (SPEC 13.4)`, + ); + } + + // SPEC 6.5/13.1/13.2/7.3: the derived paths the destination would + // generate. The module and companions share the destination's directory + // (13.1: "in the source file's directory"), so the destination path + // itself covers their components; the Markdown emit destination (13.2) + // adds its own. `specSourceDerivedPaths` is total over any byte shape; + // the destination is valid UTF-8 here, so its results are plain strings. + const componentProbePaths: string[] = [destination]; + const derived = specSourceDerivedPaths(bytes, configuration); + if (typeof derived.markdown === "string") { + componentProbePaths.push(derived.markdown); + } + return { causes, specGroups, probeable: true, componentProbePaths }; +} + +/** + * The one `refused-invalid-destination` finding (SPEC 14: one finding per + * reason, concerning the destination path) over the pure causes and the + * probed component obstructions — or null when the destination is valid. + */ +function invalidDestinationFinding( + destination: string, + causes: readonly string[], + obstructedComponents: readonly string[], +): Finding | null { + const all = [...causes]; + for (const component of obstructedComponents) { + all.push( + `its workspace-relative directory component ` + + `${JSON.stringify(component)} (of the destination path or of a ` + + `derived path the destination would generate, SPEC 13.1, 13.2, ` + + `7.3) is occupied by something other than a directory — writes ` + + `never traverse or replace such an occupant (SPEC 13.4, 14.22)`, + ); + } + if (all.length === 0) return null; + return refusalFinding( + "refused-invalid-destination", + `invalid destination ${JSON.stringify(destination)}: the destination ` + + `file path would not be a valid discovered spec source after the ` + + `move, or could not be written and regenerated — ${all.join("; ")} ` + + `(SPEC 6.5)`, + { path: destination }, + ); +} + +// --------------------------------------------------------------------------- +// Identity mappings (the would-be operations, SPEC 6.4, 6.5) +// --------------------------------------------------------------------------- + +/** The identity-space mapping a would-be operation applies (SPEC 6.1). */ +type IdentityMap = (identity: string) => string; + +/** A rename's mapping: `file#oldId(.rest)` → `file#newId(.rest)` (SPEC 6.4). */ +function renameIdentityMap( + file: string, + oldId: string, + newId: string, +): IdentityMap { + const prefix = `${file}#`; + return (identity) => { + if (!identity.startsWith(prefix)) return identity; + const mapped = replaceIdPrefix(identity.slice(prefix.length), oldId, newId); + return mapped === null ? identity : `${prefix}${mapped}`; + }; +} + +/** A file move's mapping: identities change only in the file part (SPEC 6.5). */ +function moveFileIdentityMap(origin: string, destination: string): IdentityMap { + const prefix = `${origin}#`; + return (identity) => { + if (identity === origin) return destination; + if (identity.startsWith(prefix)) { + return `${destination}#${identity.slice(prefix.length)}`; + } + return identity; + }; +} + +/** A section move's mapping: prefix replacement into the target (SPEC 6.5). */ +function moveSectionIdentityMap( + origin: string, + oldId: string, + target: string, + newId: string, +): IdentityMap { + const prefix = `${origin}#`; + return (identity) => { + if (!identity.startsWith(prefix)) return identity; + const mapped = replaceIdPrefix(identity.slice(prefix.length), oldId, newId); + return mapped === null ? identity : `${target}#${mapped}`; + }; +} + +/** The file part of a node identity (SPEC 1.5: `path#id`, or the path). */ +function identityFilePart(identity: string): string { + const hash = identity.indexOf("#"); + return hash === -1 ? identity : identity.slice(0, hash); +} + +// --------------------------------------------------------------------------- +// Shared reason evaluations +// --------------------------------------------------------------------------- + +/** + * `refused-invalid-id` (SPEC 14): the new ID, or an ID the prefix + * replacement produces, is not in intrinsic ID form — one finding + * concerning those identities (`file#id` per SPEC 1.5), or null. The + * produced IDs are `newId` plus each moved descendant's prefix-replaced ID + * (SPEC 6.4, 6.5). + */ +function invalidIdFinding( + targetFile: string, + producedIds: readonly string[], +): Finding | null { + const invalid: string[] = []; + const problems: string[] = []; + for (const id of producedIds) { + const problem = intrinsicIdProblem(id); + if (problem !== null) { + invalid.push(id); + problems.push(`${JSON.stringify(id)}: ${problem}`); + } + } + if (invalid.length === 0) return null; + return refusalFinding( + "refused-invalid-id", + `invalid new ID: the operation would produce identities that are not ` + + `in intrinsic ID form (one or more segments joined by ".", each ` + + `satisfying SPEC 1.4) — ${problems.join("; ")}; choose a valid new ` + + `ID (SPEC 1.4, 14)`, + { identities: invalid.map((id) => `${targetFile}#${id}`) }, + ); +} + +/** + * `refused-id-collision` (SPEC 14): the new ID, or an ID the prefix + * replacement produces, collides with an ID remaining after the + * operation's removals — one finding locating every colliding bearer, or + * null. `remaining` holds the target file's sections minus the vacated + * ones (SPEC 6.4: the old ID and its descendants'; SPEC 6.5: the moved + * subtree, for a same-file move). + */ +function idCollisionFinding( + targetFile: string, + targetFilePath: PathText, + producedIds: readonly string[], + remaining: readonly SpecSection[], +): Finding | null { + const produced = new Set(producedIds); + const locations: FindingLocation[] = []; + const colliding = new Set<string>(); + for (const section of remaining) { + if (section.id !== null && produced.has(section.id)) { + colliding.add(section.id); + locations.push({ file: targetFilePath, range: section.range }); + } + } + if (locations.length === 0) return null; + const ids = sortByBytes([...colliding], (id) => id); + return refusalFinding( + "refused-id-collision", + `ID collision: the operation would produce ` + + `${ids.map((id) => JSON.stringify(id)).join(", ")}, which collide${ + ids.length === 1 ? "s" : "" + } with the located ID${ids.length === 1 ? "" : "s"} remaining in ` + + `${JSON.stringify(targetFile)} after the operation's removals — IDs ` + + `are unique within a source file (SPEC 1.3); choose a new ID that ` + + `collides with nothing (SPEC 6.4, 6.5, 14)`, + { + locations, + identities: ids.map((id) => `${targetFile}#${id}`), + }, + ); +} + +// --------------------------------------------------------------------------- +// Would-be cycles (SPEC 6.5 → 5.3, 2.1; refused-cycle) +// --------------------------------------------------------------------------- + +/** The section form's re-parenting of the moved node (SPEC 6.5). */ +interface Reparent { + /** The pre-operation `contains` edge to drop: parent → moved root. */ + readonly removed: { readonly parent: string; readonly child: string }; + /** The post-operation `contains` edge to add (mapped identities). */ + readonly added: { readonly parent: string; readonly child: string }; +} + +/** + * `refused-cycle`, dependency half (SPEC 14, 5.3): cycles in the would-be + * combined graph of `contains`, `depends`, and `embeds` edges over + * requirement nodes — the current graph's edges with the identity mapping + * applied and, for the section form, the moved root re-parented. Each + * cycle is one finding locating its full in-source path: every CURRENT + * reference spelling recording a participating dependency edge (SPEC 14 + * location cardinality; `contains` steps, the would-be insertion + * included, spell nothing). + */ +function wouldBeDependencyCycleFindings( + graph: WorkspaceGraph, + map: IdentityMap, + reparent: Reparent | null, + extraNodes: readonly string[], +): Finding[] { + const adjacency = new Map<string, Set<string>>(); + const addEdge = (source: string, target: string): void => { + let targets = adjacency.get(source); + if (targets === undefined) adjacency.set(source, (targets = new Set())); + targets.add(target); + }; + for (const edge of graph.edges) { + if (edge.kind === "references") continue; + if (graph.requirementNode(edge.source) === undefined) continue; + if ( + reparent !== null && + edge.kind === "contains" && + edge.source === reparent.removed.parent && + edge.target === reparent.removed.child + ) { + continue; + } + addEdge(map(edge.source), map(edge.target)); + } + if (reparent !== null) { + addEdge(reparent.added.parent, reparent.added.child); + } + + // The current reference spellings behind each would-be dependency edge, + // keyed by mapped (source, target): a cycle locates its full path in + // source at pre-operation coordinates (SPEC 14, 6.6). + const spellings = new Map<string, FindingLocation[]>(); + for (const occurrence of graph.occurrences) { + if (occurrence.kind === "references") continue; + if (occurrence.source === null) continue; + if (graph.requirementNode(occurrence.source) === undefined) continue; + const key = `${map(occurrence.source)}�${map(occurrence.target)}`; + let list = spellings.get(key); + if (list === undefined) spellings.set(key, (list = [])); + list.push({ file: occurrence.file, range: occurrence.range }); + } + + const nodes = [ + ...graph.requirementNodes.map((node) => map(node.identity)), + ...extraNodes, + ]; + return findCycles(nodes, adjacency).map((cycle) => { + const locations: FindingLocation[] = []; + for (let step = 0; step + 1 < cycle.length; step += 1) { + const list = spellings.get(`${cycle[step]!}�${cycle[step + 1]!}`); + if (list !== undefined) locations.push(...list); + } + return refusalFinding( + "refused-cycle", + `the move would create a dependency cycle: ${cycle.join(" → ")} — ` + + `the combined contains/depends/embeds graph over requirement ` + + `nodes must be acyclic (SPEC 5.3); the located reference ` + + `spellings record its participating dependency edges; choose a ` + + `target outside the moved node's dependents (SPEC 6.5, 14)`, + { locations }, + ); + }); +} + +/** How a would-be operation relocates files and spellings (SPEC 6.5). */ +interface RelocationModel { + /** A file's post-operation path (the file form's rename; else identity). */ + readonly postPathOf: (path: string) => string; + /** A post-operation path's current file, or null for a created one. */ + readonly prePathOf: (path: string) => string | null; + /** The post-operation home of one reference spelling (SPEC 6.5). */ + readonly postHomeOf: (path: string, range: ByteRange) => string; + /** Post-operation spec files that exist in no current analysis. */ + readonly createdFiles: readonly string[]; +} + +/** + * `refused-cycle`, spec-import half (SPEC 14, 2.1): cycles in the + * would-be file-level import relation among spec source files. The + * relation is modeled in identity space: an import whose binding was + * already unreferenced stays (SPEC 6.5), and beyond those, a + * post-operation import edge H → T exists exactly when a reference + * spelling homed in H post-operation resolves to a node of T ≠ H — the + * rewrite adds an import when a rewritten reference needs a module + * binding its file lacks and removes one whose binding is left without + * references (SPEC 6.5). Each cycle is one finding locating the CURRENT + * import declarations participating in it (a would-be import the rewrite + * would add exists in no current source and contributes no location). + */ +function wouldBeImportCycleFindings( + specs: readonly SpecFileAnalysis[], + graph: WorkspaceGraph, + map: IdentityMap, + relocation: RelocationModel, +): Finding[] { + const specByPath = new Map<string, SpecFileAnalysis>(); + for (const spec of specs) { + specByPath.set(spec.document.path, spec); + } + const adjacency = new Map<string, Set<string>>(); + const addEdge = (source: string, target: string): void => { + if (source === target) return; + let targets = adjacency.get(source); + if (targets === undefined) adjacency.set(source, (targets = new Set())); + targets.add(target); + }; + + // SPEC 6.5/2.1: an import whose binding was already unreferenced stays — + // its file-level relation survives the operation unchanged (paths + // mapped). + for (const spec of specs) { + const referencedRoots = new Set<string>(); + for (const dependency of spec.references.dependencies) { + const spelling = dependency.reference.spelling; + if (spelling.form === "chain") referencedRoots.add(spelling.rootName); + } + for (const embedded of spec.references.embeddings) { + const spelling = embedded.reference?.spelling; + if (spelling !== undefined && spelling.form === "chain") { + referencedRoots.add(spelling.rootName); + } + } + for (const declared of spec.imports.imports) { + if (declared.targetPath === null || declared.bindingName === null) { + continue; + } + if (!referencedRoots.has(declared.bindingName)) { + addEdge( + relocation.postPathOf(spec.document.path), + relocation.postPathOf(declared.targetPath), + ); + } + } + } + + // Every requirement-side reference spelling, homed and retargeted: the + // spec-file import relation the rewrite leaves behind (SPEC 6.5). Code + // files do not participate in SPEC import cycles (2.1: among spec + // source files). + for (const occurrence of graph.occurrences) { + if (occurrence.source === null) continue; + if (graph.requirementNode(occurrence.source) === undefined) continue; + if (typeof occurrence.file !== "string") continue; // valid workspaces only + const home = relocation.postHomeOf(occurrence.file, occurrence.range); + const targetFile = identityFilePart(map(occurrence.target)); + addEdge(home, targetFile); + } + + const nodes = [ + ...specs.map((spec) => relocation.postPathOf(spec.document.path)), + ...relocation.createdFiles, + ]; + return findCycles(nodes, adjacency).map((cycle) => { + // Locate the CURRENT import declarations participating in the + // would-be cycle (SPEC 14 location cardinality): for each step, every + // import of the step's source file (at its current path) designating + // the step's target (at its current path). Imports the rewrite would + // add exist in no current source and contribute no location. + const locations: FindingLocation[] = []; + for (let step = 0; step + 1 < cycle.length; step += 1) { + const sourcePath = relocation.prePathOf(cycle[step]!); + const targetPath = relocation.prePathOf(cycle[step + 1]!); + if (sourcePath === null || targetPath === null) continue; + const spec = specByPath.get(sourcePath); + if (spec === undefined) continue; + for (const declared of spec.imports.imports) { + if (declared.targetPath === targetPath) { + locations.push({ + file: spec.document.file, + range: declared.statement.range, + }); + } + } + } + return refusalFinding( + "refused-cycle", + `the move would create a spec import cycle: ${cycle.join(" → ")} — ` + + `import cycles among spec source files are invalid (SPEC 2.1); ` + + `the rewrite would add the imports closing this cycle, so the ` + + `move is refused; choose a target that does not make the origin ` + + `and target files import each other (SPEC 6.5, 14)`, + { locations }, + ); + }); +} + +// --------------------------------------------------------------------------- +// Rename (SPEC 6.4) +// --------------------------------------------------------------------------- + +/** The inputs of a rename's refusal evaluation (SPEC 6.4, 14). */ +export interface RenameRefusalInputs { + /** The origin file's analysis (a discovered, parsed spec source). */ + readonly origin: SpecFileAnalysis; + readonly oldId: string; + readonly newId: string; +} + +/** + * Evaluate every applicable rename refusal reason together (SPEC 6.4, 14) + * over a workspace passing `build`'s validations: the new ID's intrinsic + * form, identity change, collisions against the IDs remaining after the + * vacated ones are removed, and the structural parent rules at the + * renamed section's place. A rename maps identities one-to-one within one + * file and preserves every reference's form (SPEC 6.4), so it can create + * no cycle and leave no rewritten reference unresolved — those reasons + * are move-only (SPEC 14) and the "all rewritten references resolve" + * clause is the always-passing side here. + */ +export function evaluateRenameRefusals(inputs: RenameRefusalInputs): Finding[] { + const { origin, oldId, newId } = inputs; + const file = origin.document.path; + const section = origin.document.sections.find((s) => s.id === oldId); + if (section === undefined) { + throw new Error( + `xspec internal error: rename origin ID ${oldId} is not a section of ` + + `${file} — the caller validated its existence (SPEC 6.4)`, + ); + } + const findings: Finding[] = []; + + // SPEC 14 `refused-identity-unchanged`: the new identity equals the old, + // concerning it. + if (newId === oldId) { + findings.push( + refusalFinding( + "refused-identity-unchanged", + `identity unchanged: the new ID ${JSON.stringify(newId)} equals ` + + `the old ID — a rename must change the identity (SPEC 6.4, 14)`, + { identities: [`${file}#${newId}`] }, + ), + ); + } + + // The produced IDs (SPEC 6.4): the new ID plus each descendant's + // prefix-replaced ID; the vacated IDs: the old ID and its descendants'. + const producedIds: string[] = []; + const vacated = new Set<string>(); + for (const candidate of origin.document.sections) { + if (candidate.id === null) continue; + const mapped = replaceIdPrefix(candidate.id, oldId, newId); + if (mapped !== null) { + vacated.add(candidate.id); + producedIds.push(mapped); + } + } + + // SPEC 14 `refused-invalid-id`: intrinsic form only. + const invalidId = invalidIdFinding(file, producedIds); + if (invalidId !== null) findings.push(invalidId); + + // SPEC 14 `refused-id-collision`: against the IDs remaining once the + // vacated ones are removed (SPEC 6.4) — an identity-unchanged rename + // therefore collides with nothing. + const remaining = origin.document.sections.filter( + (candidate) => candidate.id !== null && !vacated.has(candidate.id), + ); + const collision = idCollisionFinding( + file, + origin.document.file, + producedIds, + remaining, + ); + if (collision !== null) findings.push(collision); + + // SPEC 14 `refused-structural-parent`: positional conformance (1.3) at + // the renamed section's unchanged place, evaluated only over + // intrinsically valid IDs — no identity reports under both. + if (invalidId === null) { + const parentId = section.parent === null ? null : section.parent.id; + let violated = false; + if (parentId === null) { + // Top-level (the implicit root, SPEC 1.2): exactly one segment. + violated = newId.includes("."); + } else { + const prefix = `${parentId}.`; + violated = + !newId.startsWith(prefix) || newId.slice(prefix.length).includes("."); + } + if (violated) { + findings.push( + refusalFinding( + "refused-structural-parent", + `structural parent violation: the renamed section keeps its ` + + `place in the tree, so its new ID must be ` + + (parentId === null + ? `exactly one segment (it is top-level)` + : `${JSON.stringify(parentId)} plus "." plus exactly one ` + + `segment (it is nested inside ${JSON.stringify(parentId)})`) + + ` (SPEC 1.3); ${JSON.stringify(newId)} is not (SPEC 6.4, 14)`, + { identities: [`${file}#${newId}`] }, + ), + ); + } + } + + return findings; +} + +// --------------------------------------------------------------------------- +// Move (SPEC 6.5) +// --------------------------------------------------------------------------- + +/** The probed destination-side filesystem facts (workspace/writes.ts). */ +export interface DestinationProbe { + /** + * What occupies the destination path itself, judged by `lstat` — never + * through a symbolic link (SPEC 13.4) — "absent" also for a path + * unreachable through a non-directory component (nothing occupies it; + * the component itself reports through `obstructedComponents`). + */ + readonly occupant: "absent" | "file" | "directory" | "symlink" | "other"; + /** + * The workspace-relative directory components of the assessment's + * `componentProbePaths` occupied by anything other than a directory + * (SPEC 6.5), distinct, in byte order; nonexistent components are never + * listed (writes create those, SPEC 13.4). + */ + readonly obstructedComponents: readonly string[]; +} + +/** A destination that was never probed (shape-invalid, SPEC 1.5). */ +export const UNPROBED_DESTINATION: DestinationProbe = { + occupant: "absent", + obstructedComponents: [], +}; + +/** Human words for an occupant kind (diagnostics). */ +function describeOccupantKind( + occupant: Exclude<DestinationProbe["occupant"], "absent">, +): string { + switch (occupant) { + case "file": + return "a plain file"; + case "directory": + return "a directory"; + case "symlink": + return "a symbolic link"; + case "other": + return "a non-plain file"; + } +} + +/** The inputs of a file-form move's refusal evaluation (SPEC 6.5, 14). */ +export interface MoveFileRefusalInputs { + readonly specs: readonly SpecFileAnalysis[]; + readonly graph: WorkspaceGraph; + readonly originPath: string; + readonly destination: string; + readonly assessment: DestinationPathAssessment; + readonly probe: DestinationProbe; +} + +/** + * Evaluate every applicable file-form move refusal reason together (SPEC + * 6.5, 14) over a workspace passing `build`'s validations. A file move + * maps identities one-to-one (file part only) and preserves the shapes of + * both the dependency graph and the import relation, so the would-be + * cycle evaluation runs on principle and finds nothing new on a valid + * workspace; no rewritten reference can fail to resolve (import + * specifiers are rewritten to keep designating the files they designated, + * SPEC 6.5). + */ +export function evaluateMoveFileRefusals( + inputs: MoveFileRefusalInputs, +): Finding[] { + const { specs, graph, originPath, destination, assessment, probe } = inputs; + const findings: Finding[] = []; + + // SPEC 14 `refused-identity-unchanged` (the mirrored identity check, + // SPEC 6.5: the new identity differs from the old — for the file form, + // in its file part): the exact self-move maps every identity to itself. + if (destination === originPath) { + findings.push( + refusalFinding( + "refused-identity-unchanged", + `identity unchanged: the destination equals the origin ` + + `${JSON.stringify(originPath)}, so every identity would map to ` + + `itself — a move must change the identities (SPEC 6.5, 14)`, + { identities: [originPath] }, + ), + ); + } + + // SPEC 14 `refused-destination-exists`: the file form's destination path + // is already occupied, whatever kind of filesystem object occupies it. + if (probe.occupant !== "absent") { + findings.push( + refusalFinding( + "refused-destination-exists", + `destination exists: the destination path ` + + `${JSON.stringify(destination)} is already occupied by ` + + `${describeOccupantKind(probe.occupant)} — a file-form move ` + + `refuses an existing destination, whatever occupies it ` + + `(SPEC 6.5, 14)`, + { path: destination }, + ), + ); + } + + // SPEC 14 `refused-invalid-destination`: one finding over every cause. + const invalidDestination = invalidDestinationFinding( + destination, + assessment.causes, + probe.obstructedComponents, + ); + if (invalidDestination !== null) findings.push(invalidDestination); + + // SPEC 14 `refused-cycle`: evaluated on its own terms over the would-be + // workspace (no new cycle can arise from a pure file rename of the + // graph, but the reason is read on its own terms, SPEC 14). + const map = moveFileIdentityMap(originPath, destination); + findings.push(...wouldBeDependencyCycleFindings(graph, map, null, [])); + const relocation: RelocationModel = { + postPathOf: (path) => (path === originPath ? destination : path), + prePathOf: (path) => (path === destination ? originPath : path), + postHomeOf: (path) => (path === originPath ? destination : path), + createdFiles: [], + }; + findings.push(...wouldBeImportCycleFindings(specs, graph, map, relocation)); + + return findings; +} + +/** The inputs of a section-form move's refusal evaluation (SPEC 6.5, 14). */ +export interface MoveSectionRefusalInputs { + readonly specs: readonly SpecFileAnalysis[]; + readonly graph: WorkspaceGraph; + /** The origin file's analysis (a discovered, parsed spec source). */ + readonly origin: SpecFileAnalysis; + readonly oldId: string; + readonly targetPath: string; + readonly newId: string; + /** + * The discovered target file's analysis — the origin itself for a + * same-file move — or null when no discovered spec source occupies the + * target path (the move would create the file, or the occupant refuses + * it; the probe tells which). + */ + readonly target: SpecFileAnalysis | null; + /** + * The target-path assessment — meaningful when `target` is null (an + * existing discovered target IS a valid spec source; only its + * component probe below still applies). Callers pass a cause-free + * assessment for a discovered target. + */ + readonly assessment: DestinationPathAssessment; + readonly probe: DestinationProbe; +} + +/** + * Evaluate every applicable section-form move refusal reason together + * (SPEC 6.5, 14) over a workspace passing `build`'s validations: the + * mirrored identity checks (intrinsic form, identity change, collisions + * after the removal), the target parent, the destination occupancy and + * validity, the would-be cycles (dependency and spec-import), and the + * rewritten references that could not resolve (a moved reference + * targeting the target file's root node — the local form names IDs of its + * own file, never the file's root, SPEC 2.2, and the imported form would + * be a self-import cycle, SPEC 2.1). + */ +export function evaluateMoveSectionRefusals( + inputs: MoveSectionRefusalInputs, +): Finding[] { + const { specs, graph, origin, oldId, targetPath, newId, target, probe } = + inputs; + const originPath = origin.document.path; + const sameFile = targetPath === originPath; + const movedSection = origin.document.sections.find((s) => s.id === oldId); + if (movedSection === undefined) { + throw new Error( + `xspec internal error: move origin ID ${oldId} is not a section of ` + + `${originPath} — the caller validated its existence (SPEC 6.5)`, + ); + } + const inMovedSubtree = (id: string): boolean => + id === oldId || id.startsWith(`${oldId}.`); + const findings: Finding[] = []; + + // SPEC 14 `refused-identity-unchanged`: the exact self-move — + // `<target-file>#<new-id>` equal to `<file>#<id>` (SPEC 6.5). + if (sameFile && newId === oldId) { + findings.push( + refusalFinding( + "refused-identity-unchanged", + `identity unchanged: ${JSON.stringify(`${targetPath}#${newId}`)} ` + + `is the moved section's own identity — the exact self-move is ` + + `refused and appends no journal entry (SPEC 6.5, 14)`, + { identities: [`${targetPath}#${newId}`] }, + ), + ); + } + + // The produced IDs (SPEC 6.5): the new ID plus each moved descendant's + // prefix-replaced ID. + const producedIds: string[] = []; + for (const candidate of origin.document.sections) { + if (candidate.id === null) continue; + const mapped = replaceIdPrefix(candidate.id, oldId, newId); + if (mapped !== null) producedIds.push(mapped); + } + + // SPEC 14 `refused-invalid-id`: intrinsic form only. + const invalidId = invalidIdFinding(targetPath, producedIds); + if (invalidId !== null) findings.push(invalidId); + + // SPEC 14 `refused-id-collision`: against the IDs remaining in the + // target file after the removal — the moved subtree's own IDs are + // vacated by it (a same-file move), and a distinct target file loses + // nothing (SPEC 6.5). + if (target !== null) { + const remaining = target.document.sections.filter( + (candidate) => + candidate.id !== null && !(sameFile && inMovedSubtree(candidate.id)), + ); + const collision = idCollisionFinding( + targetPath, + target.document.file, + producedIds, + remaining, + ); + if (collision !== null) findings.push(collision); + } + + // SPEC 14 `refused-destination-exists` (section form): the target path + // is occupied by anything other than a discovered spec source — neither + // an insertion target nor an absent path to create (SPEC 6.5). + if (target === null && probe.occupant !== "absent") { + findings.push( + refusalFinding( + "refused-destination-exists", + `destination exists: the target path ` + + `${JSON.stringify(targetPath)} is occupied by ` + + `${describeOccupantKind(probe.occupant)} that is not a ` + + `discovered spec source — neither an insertion target nor an ` + + `absent path to create (SPEC 6.5, 7, 14)`, + { path: targetPath }, + ), + ); + } + + // SPEC 14 `refused-invalid-destination`: the path-validity causes apply + // to a target that is no discovered spec source (a discovered one IS a + // valid source path — the caller passes a cause-free assessment); the + // component obstructions apply to every target's destination-side + // write paths (SPEC 6.5, 14.22). + const invalidDestination = invalidDestinationFinding( + targetPath, + inputs.assessment.causes, + probe.obstructedComponents, + ); + if (invalidDestination !== null) findings.push(invalidDestination); + + // SPEC 14 `refused-missing-target-parent`: the target file's section + // bearing `<new-id>` minus its final segment — needed whenever + // `<new-id>` has more than one segment — is missing or lies within the + // moved subtree, leaving no insertion point after the removal + // (SPEC 6.5), concerning the target-parent identity. + const newSegments = newId.split("."); + let parentUsable = true; + let parentSection: SpecSection | null = null; + if (newSegments.length > 1) { + const parentId = newSegments.slice(0, -1).join("."); + parentSection = + target?.document.sections.find((s) => s.id === parentId) ?? null; + if (parentSection === null) { + parentUsable = false; + findings.push( + refusalFinding( + "refused-missing-target-parent", + `missing target parent: the target parent ` + + `${JSON.stringify(`${targetPath}#${parentId}`)} — the section ` + + `bearing the new ID minus its final segment — does not exist ` + + `in the target file (SPEC 6.5, 1.3, 14)`, + { identities: [`${targetPath}#${parentId}`] }, + ), + ); + } else if (sameFile && inMovedSubtree(parentId)) { + parentUsable = false; + findings.push( + refusalFinding( + "refused-missing-target-parent", + `missing target parent: the target parent ` + + `${JSON.stringify(`${targetPath}#${parentId}`)} lies within ` + + `the moved subtree, leaving no insertion point after the ` + + `removal (SPEC 6.5, 14)`, + { identities: [`${targetPath}#${parentId}`] }, + ), + ); + } + } + + const map = moveSectionIdentityMap(originPath, oldId, targetPath, newId); + const withinMovedRange = (range: ByteRange): boolean => + range.start >= movedSection.range.start && + range.end <= movedSection.range.end; + + // SPEC 14 `refused-unresolvable-reference`: a rewritten reference would + // not resolve — a reference within the moved subtree targeting the + // target file's root node: at the target, the local form names IDs of + // its own file, never the file's root (SPEC 2.2), and the imported form + // would be a self-import (SPEC 2.1) — locating each such reference + // spelling. + const unresolvable: FindingLocation[] = []; + for (const occurrence of graph.occurrences) { + if (occurrence.source === null) continue; + if (graph.requirementNode(occurrence.source) === undefined) continue; + if (occurrence.file !== originPath) continue; + if (!withinMovedRange(occurrence.range)) continue; + if (map(occurrence.target) === targetPath) { + unresolvable.push({ file: occurrence.file, range: occurrence.range }); + } + } + if (unresolvable.length > 0) { + findings.push( + refusalFinding( + "refused-unresolvable-reference", + `unresolvable rewritten reference: the located reference ` + + `spellings within the moved subtree target the target file's ` + + `root node — after the move no rewrite of them could resolve: ` + + `the local form names IDs of its own file, never the file's ` + + `root (SPEC 2.2), and the imported form would be a self-import ` + + `(SPEC 2.1) — retarget those references or choose another ` + + `target file (SPEC 6.5, 14)`, + { locations: unresolvable }, + ), + ); + } + + // SPEC 14 `refused-cycle`: the would-be dependency graph — the moved + // root re-parented from its current parent to the target parent (the + // target file's root for a single-segment `<new-id>`, SPEC 6.5) — and + // the would-be spec import relation. These reasons need a definable + // post-operation shape: with the insertion point missing (above) there + // is no would-be graph to judge. + if (!parentUsable) return findings; + const movedIdentity = `${originPath}#${oldId}`; + const currentParent = movedSection.parent; + const currentParentIdentity = + currentParent === null || currentParent.id === null + ? originPath + : `${originPath}#${currentParent.id}`; + const newParentIdentity = + parentSection === null + ? targetPath + : parentSection.id === null + ? targetPath + : `${targetPath}#${parentSection.id}`; + const reparent: Reparent = { + removed: { parent: currentParentIdentity, child: movedIdentity }, + added: { parent: newParentIdentity, child: map(movedIdentity) }, + }; + const createdTarget = target === null; + findings.push( + ...wouldBeDependencyCycleFindings( + graph, + map, + reparent, + // A created target file's root node exists in no current graph. + createdTarget ? [targetPath] : [], + ), + ); + const relocation: RelocationModel = { + postPathOf: (path) => path, + prePathOf: (path) => (createdTarget && path === targetPath ? null : path), + postHomeOf: (path, range) => + path === originPath && withinMovedRange(range) ? targetPath : path, + createdFiles: createdTarget ? [targetPath] : [], + }; + findings.push(...wouldBeImportCycleFindings(specs, graph, map, relocation)); + + return findings; +} diff --git a/src/workspace/writes.ts b/src/workspace/writes.ts index d3090ad..61041ee 100644 --- a/src/workspace/writes.ts +++ b/src/workspace/writes.ts @@ -61,6 +61,64 @@ export async function classifyOccupant( return "other"; } +/** + * Classify the occupant of a workspace-relative path for the + * `rename`/`move` destination probes (SPEC 6.5, core/refusal.ts): like + * `classifyOccupant`, but a path unreachable through a non-directory or + * looping component classifies as "absent" — nothing occupies the path + * itself; the offending component reports separately through + * `nonDirectoryComponents` (SPEC 6.5: `refused-invalid-destination`). + */ +export async function probeOccupant( + root: string, + rel: string, +): Promise<PathOccupant> { + let stats; + try { + stats = await fsp.lstat(absoluteOf(root, rel)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP") { + return "absent"; + } + throw error; + } + if (stats.isSymbolicLink()) return "symlink"; + if (stats.isFile()) return "file"; + if (stats.isDirectory()) return "directory"; + return "other"; +} + +/** + * SPEC 6.5: the workspace-relative directory components of `rels` occupied + * by anything other than a directory — a plain file, a symbolic link + * (whatever it targets: writes never traverse one, SPEC 13.4), or any + * other non-directory occupant. Distinct components, probed once each, in + * byte order; nonexistent components are never listed (writes create + * those, SPEC 13.4). The `refused-invalid-destination` evaluation + * (core/refusal.ts) consumes this for the destination path and the + * derived paths it would generate. + */ +export async function nonDirectoryComponents( + root: string, + rels: readonly string[], +): Promise<string[]> { + const components = new Set<string>(); + for (const rel of rels) { + for (const component of directoryComponents(rel)) { + components.add(component); + } + } + const obstructed: string[] = []; + for (const component of [...components].sort(compareBytes)) { + const occupant = await probeOccupant(root, component); + if (occupant !== "absent" && occupant !== "directory") { + obstructed.push(component); + } + } + return obstructed; +} + /** Human words for an occupant kind, for diagnostics. */ export function describeOccupant(occupant: PathOccupant): string { switch (occupant) { From 2a7a1393e264384583b4c292fadb07b51203252a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 03:42:49 +0000 Subject: [PATCH 160/226] sdg(phase-10): preview rename and move plans with the derived-file delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --preview on rename/move (SPEC 6.6, 12.7, 14.23; FIX_PLAN B9+B10, landed together as the plan anticipated): full validation and planning, performed on nothing. The planners collect classed preview edits (core/preview.ts) in the same pass as the applied edits — reference rewrites at 5.7 occurrence spans, id-rewrites at the attribute's own characters, removals extended over line drops, import additions at one deterministic offset now shared by the real edit and the preview (6.5) — and the CLI emits the four-member preview document: refusals keep the form with null plan members through the shared refusal path; success reads the derived-file record once (B7's reader), reports the record-based delta against the post-operation generation set (generatedDerivedPaths), and reports an unreadable record as the unavailable delta beside the shared condition-23 finding, exit 1, everything else in full. A preview takes no workspace exclusivity and --test-hold with --preview is a usage error before any lock. T6.6-2/4/5/6 and T12.7-3 green; T6.6-3 red only on its C1-shared usage arms (mixed-synopsis / non-UTF-8 operands), noted under C1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 85 +++++------- src/cli/args.ts | 21 ++- src/cli/commands/inventory.ts | 29 +--- src/cli/commands/move.ts | 132 +++++++++++++++--- src/cli/commands/preview.ts | 97 +++++++++++++ src/cli/commands/rename.ts | 88 ++++++++++-- src/cli/report.ts | 95 +++++++++++++ src/core/build.ts | 45 +++++- src/core/graph-data.ts | 23 ++++ src/core/move.ts | 250 ++++++++++++++++++++++++++++------ src/core/preview.ts | 130 ++++++++++++++++++ src/core/rename.ts | 58 +++++++- 12 files changed, 892 insertions(+), 161 deletions(-) create mode 100644 src/cli/commands/preview.ts create mode 100644 src/core/preview.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 5cb95b7..1dac88a 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -250,57 +250,33 @@ components included, never 14.22. B9's `--preview` must call exactly this evaluation (the CLI face is `assessAndProbeDestination` + the evaluate functions in `rename.ts`/`move.ts`) for its refusal equivalence.) -### B9. `--preview` for `rename`/`move`: plan surface (mapping + files/edits) - -SPEC 6.6, 12.7, 13.5. Prereqs B2, B8. `--preview` is not in the command table -(exit 2 unknown flag today). Full validation and planning, zero modification (no -sources, journal, derived files, or graph data touched): - -- Non-mutating under 13.5: acquires no workspace exclusivity; `--test-hold` - together with `--preview` is a usage error (exit 2). Byte-deterministic. -- Document `{"findings", "mapping", "files", "delta"}` (delta itself is B10 — - emit it as the record-based value or land B9+B10 together if inseparable). - `mapping`: `{"from", "to"}` per mapped identity, by `from` bytes. `files`: one - `{"file", "edits"}` per file the operation would rewrite, relocate, or create, - by path bytes — `file` the pre-operation path (for target-file creation, the - path the creation would occupy); edits `{"class", "range"}` ordered by range - start, end, class-name bytes; classes exactly `"reference-rewrite"`, - `"id-rewrite"`, `"import-specifier-rewrite"`, `"import-addition"`, - `"import-removal"`, `"origin-deletion"`, `"target-insertion"`, - `"target-parent-rewrite"`, `"file-relocation"`, `"file-creation"`. -- Exact pre-operation ranges: a rewrite spans the construct it rewrites (a - reference occurrence's 5.7 span; the `id` attribute's own characters; the - import specifier literal; the target parent's self-closing tag); a removal - spans every byte removed — origin-deletion extends over each line the - line-drop rule additionally drops (contiguous leftover whitespace + - terminator), as does import-removal; import-addition, target-insertion, and - file-creation are zero-length insertion points (file-creation at the new - file's start — the one location without pre-operation coordinates, the created - file's only edit, subsuming its entire initial content); file-relocation spans - the entire moved file. Ranges may nest. No replacement text anywhere. -- Refusal equivalence: refused exactly when the real operation would be, same - findings/codes/exit (shared evaluation from B8); the refused document keeps - the preview form with `mapping`, `files`, `delta` null. -- Refactor so the real operation and the preview share one plan: in a - pre-existing file the real import-addition offset must equal the preview's - (6.5). - -Verify: T6.6-2..5 (`section-6.6*.test.ts`), T12.7-3. - -### B10. Preview derived-file delta and the condition-23 outcome - -SPEC 6.6 (delta), 14.23, 12.7. Prereqs B9, B7 (shared record reader). `delta` is -`{"generated", "removed"}`, both directions one datum, paths in byte order: -derived paths the operation would newly generate (nothing currently recorded -there) and recorded derived paths left no longer generated (the pre-move module -path after a file move included). Both directions consult the recorded -derived-file paths; a preview never refreshes the record. Unreadable record → -`delta` is `{"unavailable": true}` as one datum, an `unreadable-record` finding -(concerned path `.xspec`) accompanies, exit 1, the rest of the preview emitted -in full; the real operation is not refused in that state. A refused preview -consults no record — never a condition-23 finding beside a refusal. - -Verify: T6.6-6 arms (`section-6.6*.test.ts`). +(B9+B10 landed together: `--preview` for `rename`/`move`, delta included. +The classed edit model is `src/core/preview.ts` — the ten `PreviewEditClass` +names, `PreviewCollector` (files by path bytes, edits by start/end/class +bytes), `derivedFileDelta`. The planners collect preview edits in the same +pass as the applied edits (`RenamePlan.previewFiles`, `MoveFilePlan.…`, +`MoveSectionPlan.…`): reference rewrites span the 5.7 occurrence (a `d` +entry's `reference.range`, an embedding's `embedding.range`, +`CodeReference.occurrenceRange`), id-rewrites the attribute's own +`attributeRange`, removals the extended span (`removalSpan` over the shared +line-drop machinery), and import additions one deterministic offset shared +by preview and real edit — `offsetAfterLine`/`importAdditionEdit` in +`core/move.ts`: after the last surviving import's line, at the removed +block's line start, or offset 0 for an import-less file (the applied edit +inserts `decl\n` at exactly the previewed offset, 6.5; an import line +directly before/after JSX parses fine, so no blank-line separator). CLI: +`--preview` in `args.ts`; the handlers thread a `preview` flag through the +shared validation (every findings-refusal emits the four-member document +with `mapping`/`files`/`delta` null via `emitRefusedPreview`; the preview +returns before the unreachable-guard reanalysis and takes no exclusivity; +`--test-hold`+`--preview` exits 2 before any lock). Success is +`emitSuccessfulPreview` (`src/cli/commands/preview.ts`): B7's +`readDerivedFileRecord` (the one record consult), post-op generation set +via `generatedDerivedPaths` (`core/build.ts` — post spec paths with the +operation's path substitution applied), 14.23 → delta unavailable beside +the shared `unreadableRecordFinding` (`core/graph-data.ts`; inventory now +reuses it), exit 1 with everything else in full. T6.6-2/4/5/6 and T12.7-3 +green; T6.6-3 red only on the C1-shared arms below.) --- @@ -321,7 +297,12 @@ misroutes in `src/cli/commands/move.ts` / `src/cli/args.ts`, all currently exit - An operand with more than one `#` is a malformed value: usage error in `parseMoveArgument`, never an invalid-ID refusal. -Verify: T6.5-5 (`section-6.5*.test.ts`). +Verify: T6.5-5 (`section-6.5*.test.ts`), and T6.6-3 (`section-6.6.test.ts`) +goes green with this task: its usage sweep runs the same mixed-synopsis and +non-UTF-8 invocations with and without `--preview` (both must exit 2), and +today it aborts at the first of them — everything else in T6.6-3 (refusal +equivalence, scheduling, `--test-hold`+`--preview`) already passes, so run +it after T6.5-5. ### C2. Argument checks precede the invalid-workspace gate on gated reads diff --git a/src/cli/args.ts b/src/cli/args.ts index fd1ea73..39c09f8 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -122,6 +122,14 @@ const TEST_HOLD_FLAG: FlagSpec = { valueName: "<path>", }; +/** + * SPEC 6.6: `rename` and `move` accept `--preview` — full validation and + * planning, performed on nothing. Combining it with `--test-hold` is a + * usage error (a preview acquires no exclusivity and does not take the + * acquisition-tied seam), checked by the command handlers. + */ +const PREVIEW_FLAG: FlagSpec = { name: "--preview", takesValue: false }; + /** * The known command table (SPEC 12.5), in specification order. Argument * forms: `build` 12.1, `check` 12.2, `ids` 12.3, `show` 12.4, `coverage` 8.2, @@ -322,15 +330,20 @@ const COMMANDS: readonly CommandSpec[] = [ // its only output form, with or without `--json`). No flags beyond the // globals: the inventory is a pure report of the workspace's shape. { path: "inventory", positionals: [], flags: [], jsonOnly: true }, - // SPEC 6.4: `rename <file> <old-id> <new-id>`. + // SPEC 6.4: `rename <file> <old-id> <new-id> [--preview]` (6.6). { path: "rename", positionals: ["<file>", "<old-id>", "<new-id>"], - flags: [TEST_HOLD_FLAG], + flags: [TEST_HOLD_FLAG, PREVIEW_FLAG], }, // SPEC 6.5: `move <old-file> <new-file>` or - // `move <file>#<id> <target-file>#<new-id>` — two positionals either way. - { path: "move", positionals: ["<old>", "<new>"], flags: [TEST_HOLD_FLAG] }, + // `move <file>#<id> <target-file>#<new-id>` — two positionals either way, + // `[--preview]` on both forms (6.6). + { + path: "move", + positionals: ["<old>", "<new>"], + flags: [TEST_HOLD_FLAG, PREVIEW_FLAG], + }, // SPEC 12.6: `version` — JSON-only (a single JSON document is its only // output form, with or without `--json`); workspace-independent, so // `--config` (a global) is accepted and never consulted — `main` diff --git a/src/cli/commands/inventory.ts b/src/cli/commands/inventory.ts index 5641e7a..2471b99 100644 --- a/src/cli/commands/inventory.ts +++ b/src/cli/commands/inventory.ts @@ -25,13 +25,12 @@ import { canonicalJson } from "../../core/canonical-json.js"; import type { Configuration, PolicySelector } from "../../core/config.js"; import { specSourceDerivedPaths } from "../../core/discovery.js"; import type { SourceClassification } from "../../core/discovery.js"; -import type { ExitCode, Finding } from "../../core/findings.js"; +import type { ExitCode } from "../../core/findings.js"; +import { codeExitClass, orderFindings } from "../../core/findings.js"; import { - codeExitClass, - orderFindings, - pathFinding, -} from "../../core/findings.js"; -import { GRAPH_DATA_AREA } from "../../core/graph-data.js"; + GRAPH_DATA_AREA, + unreadableRecordFinding, +} from "../../core/graph-data.js"; import { JOURNAL_PATH } from "../../core/journal.js"; import type { PathText } from "../../core/path-text.js"; import { comparePathTexts, pathTextJson } from "../../core/path-text.js"; @@ -154,24 +153,6 @@ function configurationViewJson(configuration: Configuration): JsonObject { }; } -/** - * The one finding an inventory answer ever carries (SPEC 11.6, 14.23): - * recorded state that exists but cannot be read as a record. The concerned - * path is the graph-data area — the record's layout is deliberately - * unenumerated (13.3), so no path inside it is named and the finding has no - * in-source locations. - */ -function unreadableRecordFinding(): Finding { - return pathFinding( - 23, - `the recorded generation state under the graph-data area exists but ` + - `cannot be read as a record, so the recorded derived-file paths are ` + - `unavailable — a successful \`xspec build\` (or a finishing ` + - `rename/move regeneration) replaces the record (SPEC 14.23, 13.3)`, - GRAPH_DATA_AREA, - ); -} - /** The `inventory` command handler (SPEC 11.6). */ export async function inventoryCommand( invocation: Invocation, diff --git a/src/cli/commands/move.ts b/src/cli/commands/move.ts index bd9d729..69827cc 100644 --- a/src/cli/commands/move.ts +++ b/src/cli/commands/move.ts @@ -109,7 +109,12 @@ import { writeSourceFile, } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; -import { isValidUtf8ArgumentValue, jsonOutputInEffect } from "../args.js"; +import { + flagPresent, + flagValue, + isValidUtf8ArgumentValue, + jsonOutputInEffect, +} from "../args.js"; import type { CliWriter, CommandContext } from "../io.js"; import { emitAppliedMappingReport, @@ -117,6 +122,7 @@ import { emitFindingsReport, } from "../report.js"; import { testHoldSpecOf, usageError } from "./common.js"; +import { emitRefusedPreview, emitSuccessfulPreview } from "./preview.js"; /** * SPEC 6.5/12.0/12.7: a refused move is a validation failure — exit 1, the @@ -124,13 +130,19 @@ import { testHoldSpecOf, usageError } from "./common.js"; * reports are standard-output content; with `--json`, one JSON document as * the entire standard output). Workspace-precondition findings and * refusal-reason findings alike go through here — never mixed in one - * report (SPEC 14). + * report (SPEC 14). A refused `--preview` reports exactly the same + * findings and exit, in the preview document form with `mapping`, `files`, + * and `delta` null (SPEC 6.6, 12.7). */ function emitFindingsRefusal( + preview: boolean, json: boolean, stdout: CliWriter, findings: readonly Finding[], ): ExitCode { + if (preview) { + return emitRefusedPreview(json, stdout, findings); + } emitFindingsReport(json, stdout, findings); return 1; } @@ -208,12 +220,17 @@ function concatBytes(parts: readonly Uint8Array[]): Uint8Array { return out; } -/** The move operation, run under workspace exclusivity (SPEC 13.5). */ +/** + * The move operation — run under workspace exclusivity (SPEC 13.5), or as + * its `--preview` (SPEC 6.6), which shares every validation and the plan, + * takes no exclusivity, and modifies nothing. + */ async function runMove( invocation: Invocation, context: CommandContext, originArg: string, destinationArg: string, + preview: boolean, ): Promise<ExitCode> { const { workspace, stdout, stderr } = context; @@ -270,7 +287,12 @@ async function runMove( (s) => s.document.path === origin.file, ); if (originSpec === undefined) { - return emitFindingsRefusal(invocation.json, stdout, analysis.findings); + return emitFindingsRefusal( + preview, + invocation.json, + stdout, + analysis.findings, + ); } // SPEC 6.5 → 12.0: a nonexistent origin ID (section form) is a usage @@ -293,7 +315,12 @@ async function runMove( // workspace fails the validations of `xspec build` — move only ever // rewrites a valid workspace. The findings are the report (SPEC 12.0). if (analysis.findings.length > 0) { - return emitFindingsRefusal(invocation.json, stdout, analysis.findings); + return emitFindingsRefusal( + preview, + invocation.json, + stdout, + analysis.findings, + ); } if (origin.id !== null) { @@ -308,6 +335,7 @@ async function runMove( origin.id, destination.file, destination.id, + preview, ); } @@ -317,6 +345,7 @@ async function runMove( analysis, origin.file, destinationArg, + preview, ); } @@ -327,13 +356,15 @@ async function runMoveFile( analysis: WorkspaceAnalysis, originPath: string, destination: string, + preview: boolean, ): Promise<ExitCode> { const { workspace, stdout, stderr } = context; // SPEC 6.5/14: evaluate every applicable refusal reason together over // the valid workspace — destination occupancy and validity, identity // change, and the would-be cycles, one finding per reason — and refuse - // (exit 1) with the 12.7 findings report, nothing modified. + // (exit 1) with the 12.7 findings report, nothing modified. `--preview` + // shares exactly this evaluation (SPEC 6.6). const { assessment, probe } = await assessAndProbeDestination( workspace, destination, @@ -348,11 +379,13 @@ async function runMoveFile( probe, }); if (refusals.length > 0) { - return emitFindingsRefusal(invocation.json, stdout, refusals); + return emitFindingsRefusal(preview, invocation.json, stdout, refusals); } // The pure plan: the identity mapping (file part only), the journal - // entry, and the minimal import-specifier rewrites (SPEC 6.5, 6.1). + // entry, the minimal import-specifier rewrites, and the classed preview + // edits — one plan for the real operation and its preview (SPEC 6.5, + // 6.1, 6.6). const plan = planMoveFile( analysis.specs, analysis.code, @@ -360,6 +393,24 @@ async function runMoveFile( destination, ); + // SPEC 6.6: a preview reports the plan and performs it on nothing. The + // post-operation generation set follows the post-move source set — the + // origin's entry replaced by the destination — so the delta carries the + // destination's newly generated derived paths and the recorded pre-move + // paths left no longer generated (SPEC 6.6, 13.1–13.3). + if (preview) { + return emitSuccessfulPreview( + invocation.json, + stdout, + workspace, + plan.entry.mapping, + plan.previewFiles, + analysis.classification.specSources.map((source) => + source.path === originPath ? destination : source.path, + ), + ); + } + // Re-validate the rewritten workspace in memory before touching anything // (SPEC 6.5: all rewritten references resolve, no import or dependency // cycle arises, and the finishing regeneration cannot fail). The journal @@ -392,7 +443,12 @@ async function runMoveFile( // every reason a move can be refused for, so a validated plan leaves a // valid workspace. Guarded so a regression refuses (exit 1, nothing // modified) rather than corrupts. - return emitFindingsRefusal(invocation.json, stdout, rewritten.findings); + return emitFindingsRefusal( + false, + invocation.json, + stdout, + rewritten.findings, + ); } // SPEC 6.5/6.4/12.1: the finishing regeneration's outputs, derived @@ -424,7 +480,7 @@ async function runMoveFile( ...outputs.writePaths, ]); if (writeFindings.length > 0) { - return emitFindingsRefusal(invocation.json, stdout, writeFindings); + return emitFindingsRefusal(false, invocation.json, stdout, writeFindings); } // All validation passed — modify: write the rewritten sources (atomic per @@ -517,6 +573,7 @@ async function runMoveSection( oldId: string, targetPath: string, newId: string, + preview: boolean, ): Promise<ExitCode> { const { workspace, stdout, stderr } = context; const originPath = originSpec.document.path; @@ -535,7 +592,7 @@ async function runMoveSection( // cli/args.ts): it can never be a valid requirement ID (SPEC 1.6, 1.4), // refused under its reason's stable code (SPEC 14). if (!isValidUtf8ArgumentValue(newId)) { - return emitFindingsRefusal(invocation.json, stdout, [ + return emitFindingsRefusal(preview, invocation.json, stdout, [ { code: "refused-invalid-id", message: @@ -575,13 +632,15 @@ async function runMoveSection( probe, }); if (refusals.length > 0) { - return emitFindingsRefusal(invocation.json, stdout, refusals); + return emitFindingsRefusal(preview, invocation.json, stdout, refusals); } const createGroups: readonly string[] | null = targetSpec === null ? assessment.specGroups : null; // The pure plan: the identity mapping, the journal entry, the exact text - // edits, and every reference and import rewrite (SPEC 6.5, 6.1). + // edits, every reference and import rewrite, and the classed preview + // edits — one plan for the real operation and its preview (SPEC 6.5, + // 6.1, 6.6). const plan = planMoveSection( analysis.specs, analysis.code, @@ -591,6 +650,24 @@ async function runMoveSection( newId, ); + // SPEC 6.6: a preview reports the plan and performs it on nothing. The + // post-operation generation set follows the post-move source set — a + // created target file joins it — so the delta carries the created file's + // newly generated derived paths (SPEC 6.6, 13.1–13.3). + if (preview) { + return emitSuccessfulPreview( + invocation.json, + stdout, + workspace, + plan.entry.mapping, + plan.previewFiles, + [ + ...analysis.classification.specSources.map((source) => source.path), + ...(plan.createsTargetFile ? [targetPath] : []), + ], + ); + } + // Re-validate the rewritten workspace in memory before touching anything // (SPEC 6.5: all rewritten references resolve, structural rules hold, and // no import or dependency cycle arises — 2.1, 5.3 — so the finishing @@ -621,7 +698,12 @@ async function runMoveSection( // unresolvable rewritten references included — so a validated plan // leaves a valid workspace. Guarded so a regression refuses (exit 1, // nothing modified) rather than corrupts. - return emitFindingsRefusal(invocation.json, stdout, rewritten.findings); + return emitFindingsRefusal( + false, + invocation.json, + stdout, + rewritten.findings, + ); } // SPEC 6.5/6.4/12.1: the finishing regeneration's outputs, derived @@ -650,7 +732,7 @@ async function runMoveSection( ...outputs.writePaths, ]); if (writeFindings.length > 0) { - return emitFindingsRefusal(invocation.json, stdout, writeFindings); + return emitFindingsRefusal(false, invocation.json, stdout, writeFindings); } // All validation passed — modify: write the rewritten sources (atomic per @@ -732,7 +814,7 @@ async function reanalyzeSectionMoved( }); } -/** The `move` command handler (SPEC 6.5). */ +/** The `move` command handler (SPEC 6.5, 6.6). */ export async function moveCommand( invocation: Invocation, context: CommandContext, @@ -742,6 +824,22 @@ export async function moveCommand( // Unreachable: the parser enforces the two positionals (SPEC 6.5). throw new Error("xspec internal error: move without its arguments"); } + // SPEC 6.6/13.5: a preview invocation is a non-mutating command — it + // acquires no workspace exclusivity and does not take the + // acquisition-tied test seam, so `--test-hold` together with `--preview` + // is a usage error (exit 2), no hold file created, nothing modified. + if (flagPresent(invocation, "--preview")) { + if (flagValue(invocation, "--test-hold") !== undefined) { + return usageError( + invocation, + context, + `--test-hold cannot be combined with --preview: a preview acquires ` + + `no workspace exclusivity and does not take the acquisition-tied ` + + `test seam (SPEC 6.6, 13.5, 12.0)`, + ); + } + return runMove(invocation, context, originArg, destinationArg, true); + } // SPEC 13.5: workspace exclusivity around the whole operation, with the // `--test-hold` seam immediately after acquisition; a workspace held by // another mutating command fails promptly as a usage error (12.0), @@ -749,7 +847,7 @@ export async function moveCommand( const outcome = await withMutationExclusivity( context.workspace.root, testHoldSpecOf(invocation, context.cwd), - () => runMove(invocation, context, originArg, destinationArg), + () => runMove(invocation, context, originArg, destinationArg, false), ); if (!outcome.ok) { return usageError(invocation, context, outcome.usageMessage); diff --git a/src/cli/commands/preview.ts b/src/cli/commands/preview.ts new file mode 100644 index 0000000..70ed0c1 --- /dev/null +++ b/src/cli/commands/preview.ts @@ -0,0 +1,97 @@ +// The shared `--preview` completion for `rename` and `move` (SPEC 6.6). +// +// A preview performs the full validation and planning of the operation and +// reports its consequences while modifying nothing — no sources, no +// journal, no derived files, no graph data. The command handlers share the +// operation's own validation and plan derivation (SPEC 6.6: refused exactly +// when the real operation would be; the plan is one plan) and finish here: +// the derived-file delta over the recorded derived-file paths (SPEC 6.6, +// 13.3) and the preview report in both output forms (SPEC 12.0, 12.7). +// +// The delta (SPEC 6.6): `generated` is the derived paths the operation +// would newly generate — paths where nothing is currently recorded as +// generated — and `removed` the recorded derived paths the operation would +// leave no longer generated. Both directions consult the record alone; a +// preview, writing nothing, never refreshes it. Recorded state that exists +// but cannot be read as a record is condition 23 (SPEC 14.23): the delta is +// reported explicitly unavailable — never fabricated, never read as an +// empty record — one `unreadable-record` finding accompanies (concerned +// path the graph-data area), the invocation exits 1, and every other part +// of the preview is emitted in full. A refused preview consults no record — +// the refusal findings alone, `mapping`/`files`/`delta` null (SPEC 12.7) — +// so no condition-23 finding ever accompanies a refusal. + +import { generatedDerivedPaths } from "../../core/build.js"; +import type { ExitCode, Finding } from "../../core/findings.js"; +import { + GRAPH_DATA_PATH, + unreadableRecordFinding, +} from "../../core/graph-data.js"; +import type { IdentityMapping } from "../../core/journal.js"; +import type { PreviewFileEdits } from "../../core/preview.js"; +import { derivedFileDelta } from "../../core/preview.js"; +import type { LoadedWorkspace } from "../../workspace/config.js"; +import { readDerivedFileRecord } from "../../workspace/graph-data.js"; +import type { CliWriter } from "../io.js"; +import { emitPreviewReport } from "../report.js"; + +/** + * SPEC 6.6/12.7: a refused preview keeps the preview document form — the + * refusal findings (workspace-precondition findings and refusal-reason + * findings alike, exactly what the real operation would report) with + * `mapping`, `files`, and `delta` null — and exits 1. No record is + * consulted (SPEC 6.6). + */ +export function emitRefusedPreview( + json: boolean, + stdout: CliWriter, + findings: readonly Finding[], +): ExitCode { + emitPreviewReport(json, stdout, findings, null); + return 1; +} + +/** + * Complete a preview whose operation would proceed (SPEC 6.6): read the + * recorded derived-file paths (the one record consult, SPEC 13.3, 14.23), + * derive the delta against the post-operation generation set over + * `postSpecPaths` (the spec source paths as they would stand after the + * operation), and emit the full preview report. Exit 0 for the complete, + * finding-free answer; exit 1 with everything emitted in full where the + * record exists but cannot be read (SPEC 14.23, 12.0). + */ +export async function emitSuccessfulPreview( + json: boolean, + stdout: CliWriter, + workspace: LoadedWorkspace, + mapping: readonly IdentityMapping[], + files: readonly PreviewFileEdits[], + postSpecPaths: readonly string[], +): Promise<ExitCode> { + const record = await readDerivedFileRecord(workspace.root); + if (record.state === "unreadable") { + emitPreviewReport(json, stdout, [unreadableRecordFinding()], { + mapping, + files, + delta: "unavailable", + }); + return 1; + } + // SPEC 6.6: an absent record records nothing — the empty-record success + // path, never condition 23. The graph-data path is never recorded + // (SPEC 13.3); a record naming it anyway is dropped defensively, as the + // build's orphan domain drops it. + const recorded = + record.state === "readable" + ? record.paths.filter((path) => path !== GRAPH_DATA_PATH) + : []; + emitPreviewReport(json, stdout, [], { + mapping, + files, + delta: derivedFileDelta( + recorded, + generatedDerivedPaths(workspace.configuration, postSpecPaths), + ), + }); + return 0; +} diff --git a/src/cli/commands/rename.ts b/src/cli/commands/rename.ts index b14d4d2..838cef1 100644 --- a/src/cli/commands/rename.ts +++ b/src/cli/commands/rename.ts @@ -72,7 +72,7 @@ import { writeSourceFile, } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; -import { jsonOutputInEffect } from "../args.js"; +import { flagPresent, flagValue, jsonOutputInEffect } from "../args.js"; import type { CliWriter, CommandContext } from "../io.js"; import { emitAppliedMappingReport, @@ -80,6 +80,7 @@ import { emitFindingsReport, } from "../report.js"; import { testHoldSpecOf, usageError } from "./common.js"; +import { emitRefusedPreview, emitSuccessfulPreview } from "./preview.js"; /** * SPEC 6.4/12.0/12.7: a refused rename is a validation failure — exit 1, @@ -87,13 +88,19 @@ import { testHoldSpecOf, usageError } from "./common.js"; * reports are standard-output content; with `--json`, one JSON document as * the entire standard output). Workspace-precondition findings and * refusal-reason findings alike go through here — never mixed in one - * report (SPEC 14). + * report (SPEC 14). A refused `--preview` reports exactly the same + * findings and exit, in the preview document form with `mapping`, `files`, + * and `delta` null (SPEC 6.6, 12.7). */ function emitFindingsRefusal( + preview: boolean, json: boolean, stdout: CliWriter, findings: readonly Finding[], ): ExitCode { + if (preview) { + return emitRefusedPreview(json, stdout, findings); + } emitFindingsReport(json, stdout, findings); return 1; } @@ -113,13 +120,18 @@ function concatBytes(parts: readonly Uint8Array[]): Uint8Array { return out; } -/** The rename operation, run under workspace exclusivity (SPEC 13.5). */ +/** + * The rename operation — run under workspace exclusivity (SPEC 13.5), or + * as its `--preview` (SPEC 6.6), which shares every validation and the + * plan, takes no exclusivity, and modifies nothing. + */ async function runRename( invocation: Invocation, context: CommandContext, file: string, oldId: string, newId: string, + preview: boolean, ): Promise<ExitCode> { const { workspace, stdout, stderr } = context; const analysis = await analyzeWorkspace(workspace); @@ -153,7 +165,12 @@ async function runRename( // validation findings are reported and the command exits 1. const origin = analysis.specs.find((s) => s.document.path === file); if (origin === undefined) { - return emitFindingsRefusal(invocation.json, stdout, analysis.findings); + return emitFindingsRefusal( + preview, + invocation.json, + stdout, + analysis.findings, + ); } // SPEC 6.4 → 12.0: a nonexistent old ID is a usage error, checked before @@ -173,23 +190,47 @@ async function runRename( // numbered findings alone: no refusal reason is evaluated or reported // beside them (SPEC 14). if (analysis.findings.length > 0) { - return emitFindingsRefusal(invocation.json, stdout, analysis.findings); + return emitFindingsRefusal( + preview, + invocation.json, + stdout, + analysis.findings, + ); } // SPEC 6.4/14: evaluate every applicable refusal reason together over // the valid workspace — one finding per reason, never only the first // found, each with its stable code and concerned identity or located // bearer — and refuse (exit 1) with the 12.7 findings report, nothing - // modified. + // modified. `--preview` shares exactly this evaluation (SPEC 6.6). const refusals = evaluateRenameRefusals({ origin, oldId, newId }); if (refusals.length > 0) { - return emitFindingsRefusal(invocation.json, stdout, refusals); + return emitFindingsRefusal(preview, invocation.json, stdout, refusals); } - // The pure plan: the identity mapping, the journal entry, and the minimal - // in-place rewrites of every affected source (SPEC 6.4, 6.1). + // The pure plan: the identity mapping, the journal entry, the minimal + // in-place rewrites of every affected source, and the classed preview + // edits — one plan for the real operation and its preview (SPEC 6.4, + // 6.1, 6.6). const plan = planRename(analysis.specs, analysis.code, file, oldId, newId); + // SPEC 6.6: a preview reports the plan and performs it on nothing — the + // complete identity mapping the operation would journal (the journal + // entry's canonical `from`-byte order), the per-file edits, and the + // record-based derived-file delta (a rename regenerates every derived + // path in place, so the post-operation generation set is the current + // source set's). + if (preview) { + return emitSuccessfulPreview( + invocation.json, + stdout, + workspace, + plan.entry.mapping, + plan.previewFiles, + analysis.classification.specSources.map((source) => source.path), + ); + } + // Re-validate the rewritten workspace in memory before touching anything // (SPEC 6.4: structural rules remain satisfied and all rewritten // references resolve; the finishing regeneration cannot fail). The @@ -213,7 +254,12 @@ async function runRename( // every reason a rename can be refused for, so a validated plan leaves // a valid workspace. Guarded so a regression refuses (exit 1, nothing // modified) rather than corrupts. - return emitFindingsRefusal(invocation.json, stdout, rewritten.findings); + return emitFindingsRefusal( + false, + invocation.json, + stdout, + rewritten.findings, + ); } // SPEC 6.4/12.1: the finishing regeneration's outputs, derived exactly as @@ -240,7 +286,7 @@ async function runRename( ...outputs.writePaths, ]); if (writeFindings.length > 0) { - return emitFindingsRefusal(invocation.json, stdout, writeFindings); + return emitFindingsRefusal(false, invocation.json, stdout, writeFindings); } // All validation passed — modify: rewrite the sources (atomic per file, @@ -297,7 +343,7 @@ async function reanalyzeRewritten( }); } -/** The `rename` command handler (SPEC 6.4). */ +/** The `rename` command handler (SPEC 6.4, 6.6). */ export async function renameCommand( invocation: Invocation, context: CommandContext, @@ -307,6 +353,22 @@ export async function renameCommand( // Unreachable: the parser enforces the three positionals (SPEC 6.4). throw new Error("xspec internal error: rename without its arguments"); } + // SPEC 6.6/13.5: a preview invocation is a non-mutating command — it + // acquires no workspace exclusivity and does not take the + // acquisition-tied test seam, so `--test-hold` together with `--preview` + // is a usage error (exit 2), no hold file created, nothing modified. + if (flagPresent(invocation, "--preview")) { + if (flagValue(invocation, "--test-hold") !== undefined) { + return usageError( + invocation, + context, + `--test-hold cannot be combined with --preview: a preview acquires ` + + `no workspace exclusivity and does not take the acquisition-tied ` + + `test seam (SPEC 6.6, 13.5, 12.0)`, + ); + } + return runRename(invocation, context, file, oldId, newId, true); + } // SPEC 13.5: workspace exclusivity around the whole operation, with the // `--test-hold` seam immediately after acquisition; a workspace held by // another mutating command fails promptly as a usage error (12.0), @@ -314,7 +376,7 @@ export async function renameCommand( const outcome = await withMutationExclusivity( context.workspace.root, testHoldSpecOf(invocation, context.cwd), - () => runRename(invocation, context, file, oldId, newId), + () => runRename(invocation, context, file, oldId, newId, false), ); if (!outcome.ok) { return usageError(invocation, context, outcome.usageMessage); diff --git a/src/cli/report.ts b/src/cli/report.ts index 901b7a2..6f5310f 100644 --- a/src/cli/report.ts +++ b/src/cli/report.ts @@ -27,6 +27,7 @@ import type { Finding, FindingLocation } from "../core/findings.js"; import { orderFindings } from "../core/findings.js"; import type { IdentityMapping } from "../core/journal.js"; import { pathTextJson, renderPathText } from "../core/path-text.js"; +import type { PreviewDelta, PreviewFileEdits } from "../core/preview.js"; import type { CliWriter, CommandIo } from "./io.js"; /** @@ -163,6 +164,100 @@ export function unavailableJson(): JsonObject { return { unavailable: true }; } +/** + * A successful preview's plan (SPEC 6.6): the complete identity mapping the + * operation would journal (canonical `from`-byte order, core/journal.ts), + * the classed per-file edits (core/preview.ts), and the derived-file delta + * — the record-supplied datum, `"unavailable"` exactly where recorded state + * exists but cannot be read as a record (SPEC 14.23). + */ +export interface PreviewPlanReport { + readonly mapping: readonly IdentityMapping[]; + readonly files: readonly PreviewFileEdits[]; + readonly delta: PreviewDelta | "unavailable"; +} + +/** + * Emit the `rename`/`move` preview report (SPEC 6.6, 12.7): the four-member + * preview document `{"findings", "mapping", "files", "delta"}` under + * `--json` — `mapping`, `files`, and `delta` null together exactly on + * refusal (`plan` null), the delta the unavailability marker where the + * record cannot be read — and a human report presenting the same + * information (SPEC 12.0). Both forms are byte-deterministic: identities, + * workspace-relative paths, byte offsets, and static text only. + */ +export function emitPreviewReport( + json: boolean, + stdout: CliWriter, + findings: readonly Finding[], + plan: PreviewPlanReport | null, +): void { + const ordered = orderFindings(findings); + if (json) { + const document: JsonValue = { + findings: ordered.map(findingToJson), + mapping: + plan === null + ? null + : plan.mapping.map((pair) => ({ from: pair.from, to: pair.to })), + files: + plan === null + ? null + : plan.files.map((entry) => ({ + file: entry.path, + edits: entry.edits.map((edit) => ({ + class: edit.class, + range: { start: edit.range.start, end: edit.range.end }, + })), + })), + delta: + plan === null + ? null + : plan.delta === "unavailable" + ? unavailableJson() + : { + generated: [...plan.delta.generated], + removed: [...plan.delta.removed], + }, + }; + stdout.write(canonicalJson(document)); + return; + } + const lines: string[] = ordered.map(renderFindingLine); + if (plan === null) { + // SPEC 6.6: a refused preview reports the refusal findings alone. + const count = ordered.length; + lines.push(`${String(count)} finding${count === 1 ? "" : "s"}\n`); + stdout.write(lines.join("")); + return; + } + lines.push("mapping:\n"); + for (const pair of plan.mapping) { + lines.push(` ${pair.from} -> ${pair.to}\n`); + } + lines.push("files:\n"); + for (const entry of plan.files) { + lines.push(` ${entry.path}\n`); + for (const edit of entry.edits) { + lines.push( + ` ${String(edit.range.start)}-${String(edit.range.end)} ${edit.class}\n`, + ); + } + } + if (plan.delta === "unavailable") { + lines.push("delta: unavailable\n"); + } else { + lines.push("delta:\n"); + for (const path of plan.delta.generated) { + lines.push(` generated ${path}\n`); + } + for (const path of plan.delta.removed) { + lines.push(` removed ${path}\n`); + } + } + stdout.write(lines.join("")); +} + /** * One reference occurrence record as JSON data — exactly the five-member * record form of SPEC 12.7: `{"file", "range", "kind", "source", "target"}` diff --git a/src/core/build.ts b/src/core/build.ts index 31ab31a..17aff0c 100644 --- a/src/core/build.ts +++ b/src/core/build.ts @@ -27,7 +27,7 @@ import { compareBytes, sortByBytes } from "./bytes.js"; import type { Configuration } from "./config.js"; import { canonicalOutDirPrefix } from "./discovery.js"; import type { GeneratedFile } from "./emission.js"; -import { generateSpecModule } from "./emission.js"; +import { generateSpecModule, specModulePaths } from "./emission.js"; import type { GraphData, StoredInputs } from "./graph-data.js"; import { buildGraphSnapshot, @@ -135,3 +135,46 @@ export function computeBuildOutputs( writePaths: [...files.map((file) => file.path), GRAPH_DATA_PATH], }; } + +/** + * The derived-file paths a build over `specPaths` would generate — each + * source's generated module and companions (SPEC 13.1, the `NAME.mdx` name + * shape via emission's `specModulePaths`) plus, exactly while `markdown` is + * present with `emit` true, its Markdown destination (SPEC 13.2, 7.3) — in + * byte order, graph data excluded (SPEC 13.3: the record holds the + * generated derived files; graph data records no path of its own). The + * path-only companion of `computeBuildOutputs`' enumeration, serving the + * preview delta's post-operation generation set (SPEC 6.6): the paths are a + * function of the source names and the configuration alone. + */ +export function generatedDerivedPaths( + configuration: Configuration, + specPaths: readonly string[], +): readonly string[] { + const paths: string[] = []; + const markdown = configuration.markdown; + const emitMarkdown = markdown !== undefined && markdown.emit; + const prefix = emitMarkdown + ? (canonicalOutDirPrefix(markdown.outDir) ?? "") + : ""; + for (const specPath of specPaths) { + if (!specPath.endsWith(".mdx")) { + // SPEC 13.1: per-source derived paths are defined by the `NAME.mdx` + // name shape alone; a valid workspace discovers no other spec-source + // names (SPEC 14.19), so this arm is defensive. + continue; + } + const modulePaths = specModulePaths(specPath); + paths.push( + modulePaths.module, + modulePaths.runtime, + modulePaths.types, + modulePaths.typesMap, + ); + if (emitMarkdown) { + // SPEC 13.2: the `.mdx` source emits `.md` — the trailing "x" dropped. + paths.push(prefix + specPath.slice(0, -1)); + } + } + return [...new Set(paths)].sort(compareBytes); +} diff --git a/src/core/graph-data.ts b/src/core/graph-data.ts index db38386..4175bcd 100644 --- a/src/core/graph-data.ts +++ b/src/core/graph-data.ts @@ -49,6 +49,8 @@ import type { ByteRange } from "./bytes.js"; import { compareBytes } from "./bytes.js"; import type { JsonValue } from "./canonical-json.js"; import { canonicalJson } from "./canonical-json.js"; +import type { Finding } from "./findings.js"; +import { pathFinding } from "./findings.js"; import type { DependencyEdgeKind, GraphEdge, @@ -70,6 +72,27 @@ export const GRAPH_DATA_AREA = ".xspec"; /** SPEC 13.3/13.4: the graph-data file's workspace-relative path. */ export const GRAPH_DATA_PATH = ".xspec/graph.json"; +/** + * The one condition-23 finding (SPEC 14.23): recorded generation state that + * exists but cannot be read as a record, reported by the surfaces that + * consult the record without refreshing it — `inventory` (SPEC 11.6) and + * the `rename`/`move` preview delta (SPEC 6.6) — beside their explicitly + * unavailable record-supplied datum. The concerned path is the graph-data + * area itself: the record's layout is deliberately unenumerated (SPEC + * 13.3), so no path inside it is named and the finding has no in-source + * locations. + */ +export function unreadableRecordFinding(): Finding { + return pathFinding( + 23, + `the recorded generation state under the graph-data area exists but ` + + `cannot be read as a record, so the recorded derived-file paths are ` + + `unavailable — a successful \`xspec build\` (or a finishing ` + + `rename/move regeneration) replaces the record (SPEC 14.23, 13.3)`, + GRAPH_DATA_AREA, + ); +} + /** * The stored format version: a parsed file of any other version is * malformed (parse yields null), so it reads as not matching the current diff --git a/src/core/move.ts b/src/core/move.ts index bc81c9b..ec896bc 100644 --- a/src/core/move.ts +++ b/src/core/move.ts @@ -67,6 +67,8 @@ import type { SpecFileAnalysis } from "./graph.js"; import type { IdentityMapping, JournalEntry } from "./journal.js"; import { createJournalEntry } from "./journal.js"; import type { SpecSection } from "./mdx.js"; +import type { PreviewFileEdits } from "./preview.js"; +import { PreviewCollector } from "./preview.js"; import { isDotAccessSegmentName, replaceIdPrefix, @@ -96,6 +98,14 @@ export interface MoveFilePlan { * itself ceases to exist (the workspace layer removes it). */ readonly rewrites: readonly SourceRewrite[]; + /** + * The preview plan surface (SPEC 6.6): every file the operation would + * rewrite or relocate, with every edit classed and located in + * pre-operation coordinates — the moved file's entry under its current + * path — collected in the same pass that derives the applied edits, so + * the real operation and its preview share one plan. + */ + readonly previewFiles: readonly PreviewFileEdits[]; } /** @@ -204,6 +214,14 @@ export function planMoveFile( const destinationModule = moduleSpecifierTargetOf(destinationPath); const edits = new EditCollector(); + // SPEC 6.6: the preview edits, collected beside the applied edits. The + // relocation spans the entire moved file, its entry under the current, + // pre-operation path. + const preview = new PreviewCollector(); + preview.add(originPath, "file-relocation", { + start: 0, + end: encoder.encode(origin.document.text).length, + }); /** Rewrite one import's specifier literal to designate `targetModule`. */ const specifierEdit = ( @@ -220,6 +238,10 @@ export function planMoveFile( imported.specifierQuote, ), }); + // SPEC 6.6: an import-specifier rewrite spans the specifier literal's + // characters, quotes included, in the file's pre-operation coordinates + // (the moved file's own edits under its current path). + preview.add(path, "import-specifier-rewrite", imported.specifierRange); }; // SPEC 6.5: relocation rewrites the moved file's own import specifiers — @@ -334,6 +356,7 @@ export function planMoveFile( mapping, ), rewrites, + previewFiles: preview.files(), }; } @@ -488,6 +511,54 @@ function deletionEditsWithLineDrops( return edits; } +/** + * The single span a deletion removes (SPEC 6.6): the range's own bytes, + * extended over the leftover whitespace and line terminator of each line + * the line-drop rule additionally drops (SPEC 6.5, 3) — bytes contiguous + * with the range, so the result is one range. The preview's + * `origin-deletion` and `import-removal` ranges are exactly this span, + * judged per edit over the same machinery the applied deletion uses. + */ +function removalSpan(bytes: Uint8Array, range: ByteRange): ByteRange { + const edits = deletionEditsWithLineDrops(bytes, [range]); + const first = edits[0]; + const last = edits[edits.length - 1]; + if (first === undefined || last === undefined) { + throw new Error("xspec internal error: a deletion produced no edits"); + } + return { start: first.range.start, end: last.range.end }; +} + +/** + * SPEC 6.5: the deterministic import-addition offset anchored after the + * line containing `position` — the byte just past that line's terminator + * (the end of the file when the line is unterminated). In a file existing + * before the operation, this is exactly the offset the preview reports + * (SPEC 6.6) and the offset the real operation inserts at. + */ +function offsetAfterLine(bytes: Uint8Array, position: number): number { + return terminatorEndAt(bytes, lineContentEndAfter(bytes, position)); +} + +/** + * SPEC 6.5: the import-addition edit at `offset` in the file's original + * bytes — each declaration inserted as a line of its own, its characters + * followed by a U+000A line terminator, the block preceded by one exactly + * when the insertion point is not at the start of a line. Shared by the + * real rewrite and the preview (SPEC 6.6: the real insertion offset equals + * the previewed one). + */ +function importAdditionEdit( + bytes: Uint8Array, + offset: number, + lines: readonly string[], +): SourceEdit { + const atLineStart = offset === 0 || isTerminatorByte(bytes[offset - 1]!); + const text = + (atLineStart ? "" : "\n") + lines.map((line) => `${line}\n`).join(""); + return { range: { start: offset, end: offset }, replacement: text }; +} + /** * ECMAScript reserved words, which an import binding can never use — the * fresh-identifier chooser (SPEC 6.5) skips them. @@ -603,6 +674,12 @@ function bump(counts: Map<string, number>, key: string): void { interface LocatedReference { readonly section: SpecSection; readonly reference: SpecReference; + /** + * The occurrence span (SPEC 5.7): a `d` entry's own expression; an MDX + * embedding's full braced container — the construct a preview's + * `reference-rewrite` edit spans (SPEC 6.6). + */ + readonly occurrence: ByteRange; } /** Every reference of a spec file, `d` and `text(...)` alike, in document order. */ @@ -612,6 +689,7 @@ function locatedReferencesOf(spec: SpecFileAnalysis): LocatedReference[] { references.push({ section: dependency.section, reference: dependency.reference, + occurrence: dependency.reference.range, }); } for (const embedding of spec.references.embeddings) { @@ -624,6 +702,7 @@ function locatedReferencesOf(spec: SpecFileAnalysis): LocatedReference[] { references.push({ section: embedding.embedding.section, reference: embedding.reference, + occurrence: embedding.embedding.range, }); } return references; @@ -902,6 +981,17 @@ export interface MoveSectionPlan { readonly rewrites: readonly SourceRewrite[]; /** Whether the plan creates the target file (absent before the move). */ readonly createsTargetFile: boolean; + /** + * The preview plan surface (SPEC 6.6): every file the operation would + * rewrite or create, with every edit classed and located in + * pre-operation coordinates — a created target file's entry holding + * exactly its one `file-creation` edit, the moved text's own rewrites + * located in the origin file inside the origin deletion's range — + * collected in the same pass that derives the applied edits, so the real + * operation and its preview share one plan (the import-addition offsets + * included, SPEC 6.5). + */ + readonly previewFiles: readonly PreviewFileEdits[]; } /** An import declaration line for a spec file (SPEC 2.1, 6.5 additions). */ @@ -1010,6 +1100,12 @@ export function planMoveSection( // applied to the extracted slice; outer edits apply to each file's // remaining content. const outerEdits = new EditCollector(); + // SPEC 6.6: the preview edits, collected beside the applied edits — the + // moved text's own rewrites in the origin file, at pre-operation + // coordinates inside the origin deletion's range; a created target file + // carries exactly its one `file-creation` edit, everything the creation + // composes subsumed. + const preview = new PreviewCollector(); const innerEdits: SourceEdit[] = []; const addInner = (edit: SourceEdit): void => { if ( @@ -1056,6 +1152,10 @@ export function planMoveSection( range: attribute.valueRange, replacement: attributeValueText(mapped, attribute.quote), }); + // SPEC 6.6: an `id`-attribute rewrite spans the attribute's own + // characters — the re-identification's rewrites locate in the origin + // file, inside the origin deletion's range (containment is geometry). + preview.add(originPath, "id-rewrite", attribute.attributeRange); } // SPEC 6.5: rewrite every reference across the workspace to resolve to @@ -1065,7 +1165,7 @@ export function planMoveSection( for (const spec of specs) { const path = spec.document.path; for (const located of locatedReferencesOf(spec)) { - const { section, reference } = located; + const { section, reference, occurrence } = located; const declaredInMoved = spec === origin && section.id !== null && @@ -1097,6 +1197,7 @@ export function planMoveSection( reference.spelling.quote, ), }); + preview.add(originPath, "reference-rewrite", occurrence); } else if (!sameFile) { // A moved reference to a node staying behind: local → imported, // rooted at the target file's binding of the origin module @@ -1112,6 +1213,7 @@ export function planMoveSection( reference.target.idPath.split("."), ), }); + preview.add(originPath, "reference-rewrite", occurrence); } continue; } @@ -1134,6 +1236,7 @@ export function planMoveSection( replacement: renderChain(name, mappedLocal.split(".")), }); } + preview.add(path, "reference-rewrite", occurrence); continue; } @@ -1164,6 +1267,7 @@ export function planMoveSection( range: chainSpan(reference.spelling), replacement: jsStringLiteral(segments.join("."), '"'), }); + preview.add(originPath, "reference-rewrite", occurrence); } else { // The chain must root at the target file's binding of the same // module — an existing binding, or a fresh added import @@ -1177,6 +1281,7 @@ export function planMoveSection( range: reference.spelling.rootRange, replacement: name, }); + preview.add(originPath, "reference-rewrite", occurrence); } } continue; @@ -1201,18 +1306,23 @@ export function planMoveSection( '"', ), }); + preview.add(path, "reference-rewrite", occurrence); continue; } if (sameFile) { // The module is unchanged; only the segment prefix is re-identified. - for (const edit of chainPrefixEdits( + const prefixEdits = chainPrefixEdits( reference.spelling, oldSegments, newSegments, null, - )) { + ); + for (const edit of prefixEdits) { outerEdits.add(path, edit); } + if (prefixEdits.length > 0) { + preview.add(path, "reference-rewrite", occurrence); + } continue; } // Another spec file's chain into the moved subtree: re-rooted at that @@ -1221,14 +1331,18 @@ export function planMoveSection( const filePlan = planFor(spec, path); filePlan.depart(reference.spelling.rootName); const rootName = filePlan.bindingFor(targetPath); - for (const edit of chainPrefixEdits( + const prefixEdits = chainPrefixEdits( reference.spelling, oldSegments, newSegments, rootName, - )) { + ); + for (const edit of prefixEdits) { outerEdits.add(path, edit); } + if (prefixEdits.length > 0) { + preview.add(path, "reference-rewrite", occurrence); + } } } @@ -1290,14 +1404,24 @@ export function planMoveSection( } } } - for (const edit of chainPrefixEdits( + const prefixEdits = chainPrefixEdits( reference.spelling, oldSegments, newSegments, rootName, - )) { + ); + for (const edit of prefixEdits) { outerEdits.add(analysis.path, edit); } + if (prefixEdits.length > 0) { + // SPEC 6.6/5.7: a marker occurrence spans the bare chain, a TS + // `text(...)` occurrence the whole call expression. + preview.add( + analysis.path, + "reference-rewrite", + reference.occurrenceRange, + ); + } } } @@ -1317,9 +1441,11 @@ export function planMoveSection( // Per-file import add/remove edits (cross-file only): removals are // line-dropped like every 6.5 deletion; additions anchor after the last - // surviving import, at the removed block's position when none survives, - // or at the start of the file (blank-line separated) when the file had no - // imports (deterministic placement, SPEC 6.5). + // surviving import's line, at the removed block's line start when none + // survives, or at the start of the file when the file had no imports — + // one deterministic offset (SPEC 6.5), shared with the preview + // (SPEC 6.6: in a pre-existing file the real insertion offset is exactly + // the previewed one). interface ImportEditSet { readonly deletionRanges: ByteRange[]; readonly additionEdit: SourceEdit | null; @@ -1329,14 +1455,24 @@ export function planMoveSection( plan: SpecImportPlan, bytes: Uint8Array, ): ImportEditSet => { + const path = spec.document.path; const removed = plan.removedImports(); const added = plan.addedImports(); const removedSet = new Set(removed); const deletionRanges = removed.map((imported) => imported.statement.range); + // SPEC 6.6: an import removal's range spans the declaration plus the + // leftover whitespace and terminator of each line its drop empties — + // every byte the edit removes, judged per declaration. + for (const imported of removed) { + preview.add( + path, + "import-removal", + removalSpan(bytes, imported.statement.range), + ); + } if (added.length === 0) { return { deletionRanges, additionEdit: null }; } - const path = spec.document.path; const lines = added.map((addition) => specImportLine(path, addition.modulePath, addition.name), ); @@ -1344,33 +1480,19 @@ export function planMoveSection( (imported) => !removedSet.has(imported), ); const lastSurvivor = survivors[survivors.length - 1]; - if (lastSurvivor !== undefined) { - const anchor = lastSurvivor.statement.range.end; - return { - deletionRanges, - additionEdit: { - range: { start: anchor, end: anchor }, - replacement: lines.map((line) => `\n${line}`).join(""), - }, - }; - } const firstRemoved = removed[0]; - if (firstRemoved !== undefined) { - const anchor = lineStartBefore(bytes, firstRemoved.statement.range.start); - return { - deletionRanges, - additionEdit: { - range: { start: anchor, end: anchor }, - replacement: lines.map((line) => `${line}\n`).join(""), - }, - }; - } + const offset = + lastSurvivor !== undefined + ? offsetAfterLine(bytes, lastSurvivor.statement.range.end) + : firstRemoved !== undefined + ? lineStartBefore(bytes, firstRemoved.statement.range.start) + : 0; + // SPEC 6.6: an import addition is a zero-length insertion point at the + // exact offset the real operation then inserts at (SPEC 6.5). + preview.add(path, "import-addition", { start: offset, end: offset }); return { deletionRanges, - additionEdit: { - range: { start: 0, end: 0 }, - replacement: `${lines.map((line) => `${line}\n`).join("")}\n`, - }, + additionEdit: importAdditionEdit(bytes, offset, lines), }; }; @@ -1436,6 +1558,39 @@ export function planMoveSection( }; } + // SPEC 6.6: the origin deletion — one range spanning every byte the + // origin edit removes: the construct's own characters extended over the + // adjunct-dropped leftover whitespace and line terminators (SPEC 6.5, 3). + preview.add( + originPath, + "origin-deletion", + removalSpan(originBytes, movedRange), + ); + if (createsTargetFile) { + // SPEC 6.6: target-file creation — the insertion point at the start of + // the new file, the one reported location without pre-operation + // coordinates and the created file's only reported edit: creation + // composes the file's entire initial content, subsuming the insertion + // and the import additions the rewrite requires there. + preview.add(targetPath, "file-creation", { start: 0, end: 0 }); + } else { + // SPEC 6.6: the target insertion point, zero-length at its offset in + // pre-operation coordinates — a self-closing target parent's is the + // tag's end, where every byte the operation adds attaches. + preview.add(targetPath, "target-insertion", { + start: insertion.pos, + end: insertion.pos, + }); + if (pairedFormEdit !== null && parentSection !== null) { + // SPEC 6.6: the self-closing-target-parent rewrite spans the tag. + preview.add( + targetPath, + "target-parent-rewrite", + parentSection.openingTagRange, + ); + } + } + if (sameFile) { // One file carries the deletion, the outer rewrites, the paired-form // rewrite of a self-closing target parent, and the insertion. @@ -1567,12 +1722,15 @@ export function planMoveSection( } // Code files: chain retargets plus added imports (SPEC 6.5, 4). Anchored - // after the file's last spec-module import — a code file referencing the - // moved subtree always has one (its chains root at import bindings). + // after the line of the file's last spec-module import — a code file + // referencing the moved subtree always has one (its chains root at + // import bindings) — at the one deterministic offset the preview reports + // (SPEC 6.5, 6.6). for (const analysis of code) { const fileEdits: SourceEdit[] = [ ...(outerEdits.editsFor(analysis.path) ?? []), ]; + const bytes = encoder.encode(analysis.text); const additions = codeAdditions.get(analysis.path); if (additions !== undefined && additions.size > 0) { const anchor = analysis.imports[analysis.imports.length - 1]; @@ -1586,24 +1744,27 @@ export function planMoveSection( .sort((a, b) => compareBytes(a[0], b[0])) .map( ([modulePath, name]) => - `\nimport ${name} from ${jsStringLiteral( + `import ${name} from ${jsStringLiteral( relativeModuleSpecifier( analysis.path, moduleSpecifierTargetOf(modulePath), ), '"', )};`, - ) - .join(""); - fileEdits.push({ - range: { start: anchor.range.end, end: anchor.range.end }, - replacement: lines, + ); + const offset = offsetAfterLine(bytes, anchor.range.end); + // SPEC 6.6: the import addition's zero-length insertion point, at + // the exact offset the real operation then inserts at (SPEC 6.5). + preview.add(analysis.path, "import-addition", { + start: offset, + end: offset, }); + fileEdits.push(importAdditionEdit(bytes, offset, lines)); } if (fileEdits.length > 0) { rewrites.push({ path: analysis.path, - content: applyEdits(encoder.encode(analysis.text), fileEdits), + content: applyEdits(bytes, fileEdits), }); } } @@ -1620,5 +1781,6 @@ export function planMoveSection( ), rewrites, createsTargetFile, + previewFiles: preview.files(), }; } diff --git a/src/core/preview.ts b/src/core/preview.ts new file mode 100644 index 0000000..ec85cc3 --- /dev/null +++ b/src/core/preview.ts @@ -0,0 +1,130 @@ +// The preview plan surface (SPEC 6.6, 12.7) — the pure edit model. +// +// Pure core (IMPLEMENTATION Architecture: deterministic and I/O-free): a +// `rename`/`move` preview reports every file the operation would rewrite, +// relocate, or create, with every edit the operation would make in it, +// classed as exactly one of the ten SPEC 6.6 classes and located by a +// source range (SPEC 1.7) in current, pre-operation coordinates — no +// replacement text anywhere (the preview is a safety report, not an edit +// script). The plan derivations (./rename.ts, ./move.ts) collect these +// entries in the same pass that derives the applied edits, so the real +// operation and the preview share one plan (SPEC 6.6, 6.5). +// +// Ordering (SPEC 12.7): file entries by file path bytes; within a file, +// edits by range start, then range end, then class-name bytes. Ranges MAY +// nest (SPEC 6.6: containment is geometry, not double-reporting) and +// coinciding zero-length insertion points MAY tie, resolved by the +// class-name byte comparison. + +import type { ByteRange } from "./bytes.js"; +import { compareBytes } from "./bytes.js"; + +/** The ten SPEC 6.6/12.7 preview edit classes, exactly. */ +export type PreviewEditClass = + | "reference-rewrite" + | "id-rewrite" + | "import-specifier-rewrite" + | "import-addition" + | "import-removal" + | "origin-deletion" + | "target-insertion" + | "target-parent-rewrite" + | "file-relocation" + | "file-creation"; + +/** One classed preview edit (SPEC 6.6, 12.7): class plus range only. */ +export interface PreviewEdit { + readonly class: PreviewEditClass; + /** + * Pre-operation coordinates (SPEC 6.6): a rewrite spans the construct it + * rewrites, a removal every byte its edit removes, an insertion point is + * zero-length at its offset; target-file creation's insertion point at + * the start of the new file is the one location without pre-operation + * coordinates. + */ + readonly range: ByteRange; +} + +/** One `files` entry (SPEC 12.7): a file with its classed edits. */ +export interface PreviewFileEdits { + /** + * The file's current, pre-operation workspace-relative path — for + * target-file creation, the path the creation would occupy (SPEC 6.6). + * Plans are derived over validated workspaces (SPEC 6.4, 6.5), whose + * discovered paths are all valid UTF-8 (SPEC 14.19), so a plain string. + */ + readonly path: string; + /** The edits, in the pinned SPEC 12.7 order. */ + readonly edits: readonly PreviewEdit[]; +} + +/** The pinned SPEC 12.7 edit order: start, end, class-name bytes. */ +export function comparePreviewEdits(a: PreviewEdit, b: PreviewEdit): number { + if (a.range.start !== b.range.start) { + return a.range.start - b.range.start; + } + if (a.range.end !== b.range.end) { + return a.range.end - b.range.end; + } + return compareBytes(a.class, b.class); +} + +/** + * Collects preview edits per file while a plan derivation runs, and yields + * the `files` entries in the pinned SPEC 12.7 order — file entries by path + * bytes, edits by range start, then range end, then class-name bytes. + */ +export class PreviewCollector { + private readonly editsByPath = new Map<string, PreviewEdit[]>(); + + add(path: string, editClass: PreviewEditClass, range: ByteRange): void { + let edits = this.editsByPath.get(path); + if (edits === undefined) { + edits = []; + this.editsByPath.set(path, edits); + } + edits.push({ class: editClass, range: { ...range } }); + } + + /** The collected entries in the pinned SPEC 12.7 order. */ + files(): readonly PreviewFileEdits[] { + return [...this.editsByPath.entries()] + .sort((a, b) => compareBytes(a[0], b[0])) + .map(([path, edits]) => ({ + path, + edits: [...edits].sort(comparePreviewEdits), + })); + } +} + +/** The two-direction derived-file delta (SPEC 6.6), each in byte order. */ +export interface PreviewDelta { + /** Derived paths the operation would newly generate (SPEC 6.6). */ + readonly generated: readonly string[]; + /** Recorded derived paths left no longer generated (SPEC 6.6). */ + readonly removed: readonly string[]; +} + +/** + * The record-based delta rule (SPEC 6.6): `generated` is the post-operation + * generation set minus the recorded paths — the paths where nothing is + * currently recorded as generated — and `removed` the recorded paths the + * operation would leave no longer generated. Both directions consult the + * record alone; presence on disk decides neither (SPEC 6.6: presence at a + * path cannot tell a generated occupant from a foreign one). Paths in byte + * order (SPEC 12.7). + */ +export function derivedFileDelta( + recordedPaths: readonly string[], + postGenerationPaths: readonly string[], +): PreviewDelta { + const recorded = new Set(recordedPaths); + const post = new Set(postGenerationPaths); + const generated = [...post] + .filter((path) => !recorded.has(path)) + .sort(compareBytes); + const removed = [...recorded] + .filter((path) => !post.has(path)) + .sort(compareBytes); + return { generated, removed }; +} diff --git a/src/core/rename.ts b/src/core/rename.ts index 139bd98..ed7f58f 100644 --- a/src/core/rename.ts +++ b/src/core/rename.ts @@ -41,10 +41,13 @@ import { EditCollector, jsStringLiteral, } from "./edits.js"; +import type { ByteRange } from "./bytes.js"; import type { SpecFileAnalysis } from "./graph.js"; import type { IdentityMapping, JournalEntry } from "./journal.js"; import { createJournalEntry } from "./journal.js"; import type { SpecAttributeValue, SpecDocument } from "./mdx.js"; +import type { PreviewFileEdits } from "./preview.js"; +import { PreviewCollector } from "./preview.js"; import { classifyReference, parseExpressionText } from "./references.js"; import type { ReferenceSpelling, @@ -62,6 +65,13 @@ export interface RenamePlan { readonly entry: JournalEntry; /** Every source file with edits, byte-ordered rewrites applied. */ readonly rewrites: readonly SourceRewrite[]; + /** + * The preview plan surface (SPEC 6.6): every file the operation would + * rewrite, with every edit classed and located in pre-operation + * coordinates — collected in the same pass that derives the applied + * edits, so the real operation and its preview share one plan. + */ + readonly previewFiles: readonly PreviewFileEdits[]; } // --------------------------------------------------------------------------- @@ -207,15 +217,32 @@ function chainReferenceEdit( return segmentEdit(affected, newLastSegment); } +/** One reference with its SPEC 5.7 occurrence span (preview ranges). */ +interface SpannedReference { + readonly reference: SpecReference; + /** + * The occurrence span (SPEC 5.7): a `d` entry's own expression; an MDX + * embedding's full braced container — the construct a preview's + * `reference-rewrite` edit spans (SPEC 6.6). + */ + readonly occurrence: ByteRange; +} + /** One spec file's references, `d` and `text(...)` alike (SPEC 2.2, 2.3). */ -function specReferencesOf(spec: SpecFileAnalysis): SpecReference[] { - const references: SpecReference[] = []; +function specReferencesOf(spec: SpecFileAnalysis): SpannedReference[] { + const references: SpannedReference[] = []; for (const dependency of spec.references.dependencies) { - references.push(dependency.reference); + references.push({ + reference: dependency.reference, + occurrence: dependency.reference.range, + }); } for (const embedding of spec.references.embeddings) { if (embedding.reference !== null) { - references.push(embedding.reference); + references.push({ + reference: embedding.reference, + occurrence: embedding.embedding.range, + }); } } return references; @@ -277,6 +304,10 @@ export function planRename( // descendant, re-identified by prefix replacement. const mapping: IdentityMapping[] = []; const edits = new EditCollector(); + // SPEC 6.6: the preview edits, collected beside the applied edits — one + // classed entry per construct the operation rewrites, at the construct's + // own pre-operation span. + const preview = new PreviewCollector(); for (const section of origin.document.sections) { if (section.id === null) { continue; @@ -300,6 +331,9 @@ export function planRename( range: attribute.valueRange, replacement: attributeValueText(mapped, attribute.quote), }); + // SPEC 6.6: an `id`-attribute rewrite spans the attribute's own + // characters, name through closing quote. + preview.add(originPath, "id-rewrite", attribute.attributeRange); } if (mapping.length === 0) { throw new Error( @@ -310,10 +344,12 @@ export function planRename( // SPEC 6.4: rewrite every reference to the affected identities across all // configured spec sources — local string references in the origin file, - // external chain references everywhere. + // external chain references everywhere. SPEC 6.6: each rewritten + // reference contributes one preview `reference-rewrite` edit spanning its + // occurrence (SPEC 5.7). for (const spec of specs) { const path = spec.document.path; - for (const reference of specReferencesOf(spec)) { + for (const { reference, occurrence } of specReferencesOf(spec)) { if (reference.target.kind === "local") { if (path !== originPath) { continue; // the local form names an ID in its own file (SPEC 2.2) @@ -332,6 +368,7 @@ export function planRename( range: reference.spelling.range, replacement: jsStringLiteral(mapped, reference.spelling.quote), }); + preview.add(path, "reference-rewrite", occurrence); continue; } if (reference.target.modulePath !== originPath) { @@ -345,6 +382,7 @@ export function planRename( ); if (edit !== null) { edits.add(path, edit); + preview.add(path, "reference-rewrite", occurrence); } } } @@ -365,6 +403,13 @@ export function planRename( ); if (edit !== null) { edits.add(analysis.path, edit); + // SPEC 6.6/5.7: a marker occurrence spans the bare chain, a TS + // `text(...)` occurrence the whole call expression. + preview.add( + analysis.path, + "reference-rewrite", + reference.occurrenceRange, + ); } } } @@ -404,5 +449,6 @@ export function planRename( mapping, ), rewrites, + previewFiles: preview.files(), }; } From 4649eb930138ac40cfbc4c069ec253bd6f8fba61 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 04:00:49 +0000 Subject: [PATCH 161/226] sdg(phase-10): classify move operands by spelling at parse level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC 6.5/12.0: a move operand containing more than one `#` is a malformed value and an invocation mixing the two synopses' forms matches neither — both are usage errors the invocation's syntax alone determines, now rejected in `parseArgv` (reported without loading configuration, before any lock or hold), and the non-UTF-8 positional exemption is removed so every argument value is UTF-8-checked (exit 2, never a destination or invalid-ID refusal). The handler's one-direction mixed check and its non-UTF-8 `<new-id>` refusal become unreachable and are removed, guarded by internal errors. Verified: section-6.5 (7/7) and section-6.6 (5/5) pass, T6.5-5 and T6.6-3 included; T12.0-13's move arms behave (smoke), its `show a#b#c` arms stay red for C2, noted in the plan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 41 +++++++++---------- src/cli/args.ts | 88 ++++++++++++++++++++++++++++++---------- src/cli/commands/move.ts | 69 ++++++++++++++----------------- 3 files changed, 116 insertions(+), 82 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 1dac88a..b5d574a 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -282,27 +282,16 @@ green; T6.6-3 red only on the C1-shared arms below.) ## Stage C — localized behavioral fixes -### C1. Move operand classification is by spelling, at exit 2 - -SPEC 6.5 (operand classification), 12.0 (`#` split, UTF-8 arguments). Three -misroutes in `src/cli/commands/move.ts` / `src/cli/args.ts`, all currently exit -1 refusals, all usage errors (exit 2): - -- An invocation mixing the two synopses' forms (one operand containing `#`, the - other not — e.g. file-form origin with a `#`-containing destination) matches - neither synopsis: usage error, never `fileDestinationProblem`. -- A non-UTF-8 operand value is a usage error: remove move's - `utf8ExemptPositionals` exemption in `parseArgv` (`src/cli/args.ts` ~451) — - no argument value may name a non-UTF-8 path (12.0). -- An operand with more than one `#` is a malformed value: usage error in - `parseMoveArgument`, never an invalid-ID refusal. - -Verify: T6.5-5 (`section-6.5*.test.ts`), and T6.6-3 (`section-6.6.test.ts`) -goes green with this task: its usage sweep runs the same mixed-synopsis and -non-UTF-8 invocations with and without `--preview` (both must exit 2), and -today it aborts at the first of them — everything else in T6.6-3 (refusal -equivalence, scheduling, `--test-hold`+`--preview`) already passes, so run -it after T6.5-5. +(C1 landed: move operand classification is parse-level — +`moveOperandsProblem` in `src/cli/args.ts` rejects, inside `parseArgv` +(syntax-determined class: reported without loading configuration, before +any lock or hold), a move operand with more than one `#` (malformed value) +and a mixed-synopsis invocation, both directions; the non-UTF-8 positional +exemption is removed, so every argument value is UTF-8-checked. The +handler's one-direction mixed check and its non-UTF-8 `<new-id>` refusal +are gone — unreachable, guarded by internal errors. T6.5-5, T6.6-3, and +T12.0-13's move arms behave; T12.0-13 still aborts earlier, at its `show +a#b#c` arm — C2's scope, see its note.) ### C2. Argument checks precede the invalid-workspace gate on gated reads @@ -335,6 +324,16 @@ corruption *report* moves behind it — gate failing → gate findings alone, ex a corrupt one has no readable parameters — the corruption (or, failing workspace, the gate) reports instead. `review list` already gates first. +Also observed by C1's spawn, same scope: a `<node>`/`<graph-node>` value with +more than one `#` must exit 2 as a malformed value on `show`/`query node` +(T12.0-13's arms — currently exit 1 via the gate), and T12.0-10's +within-class-2 arm additionally pins `show a#b#c` as reported *without +loading configuration* (byte-identical error documents with the +configuration invalid or missing) — for that one a handler-level check is +too late; C1's `moveOperandsProblem` in `src/cli/args.ts` is the +parse-level pattern (`occurrences --to` already checks its spelling in the +handler, which its arms accept). + Verify: T12.0-10 (`section-12.0*.test.ts`), T10.1-5 (`section-10.1.test.ts`). ### C3. Obstructed write path: any non-directory component, refused before modifying diff --git a/src/cli/args.ts b/src/cli/args.ts index 39c09f8..d7ff02c 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -405,18 +405,20 @@ function usageError(message: string, jsonInEffect: boolean): ParseResult { /** * SPEC 12.0: argument values are interpreted as UTF-8, and a value that is - * not valid UTF-8 is a usage error. Node materializes `process.argv` by - * decoding the OS argument bytes as UTF-8 with U+FFFD substituted for every - * invalid sequence, so invalid input bytes are observable only as U+FFFD in - * the decoded string: a value containing U+FFFD is indistinguishable from - * mis-decoded bytes and is treated as not valid UTF-8. A lone surrogate - * (which no UTF-8 decode produces, but an in-process caller could pass) has - * no UTF-8 encoding and is rejected the same way. + * not valid UTF-8 is a usage error — every argument value, `move`'s + * positional operands included: no argument value may name a non-UTF-8 path + * (12.0), so 6.5's non-UTF-8 destination clause is unreachable through the + * CLI. Node materializes `process.argv` by decoding the OS argument bytes + * as UTF-8 with U+FFFD substituted for every invalid sequence, so invalid + * input bytes are observable only as U+FFFD in the decoded string: a value + * containing U+FFFD is indistinguishable from mis-decoded bytes and is + * treated as not valid UTF-8. A lone surrogate (which no UTF-8 decode + * produces, but an in-process caller could pass) has no UTF-8 encoding and + * is rejected the same way. * - * Exported for `move` (SPEC 6.5): the parser exempts `move`'s positionals — - * a destination path that is not valid UTF-8 is one of 6.5's destination - * *refusals* (exit 1), not a usage error, so the command classifies its own - * arguments with this same predicate. + * Exported for `move` (SPEC 6.5): the destination-validity assessment + * (core/refusal.ts) takes the path's UTF-8 validity as an input — always + * true for a CLI-supplied operand, per the parse rule above. */ export function isValidUtf8ArgumentValue(value: string): boolean { for (let index = 0; index < value.length; index += 1) { @@ -433,6 +435,46 @@ export function isValidUtf8ArgumentValue(value: string): boolean { return true; } +/** + * SPEC 6.5: a `move` operand is classified by spelling alone — an operand + * containing `#` is a `<file>#<id>` pair under the split of 12.0, one + * without is a file. SPEC 12.0: at most one `#` is well-formed in any such + * value, so a spelling containing more than one is a malformed value; and + * an invocation mixing the two synopses' forms (one pair operand, one bare + * file) matches neither synopsis. Both are usage errors the invocation's + * syntax alone determines, so they are parse-level: reported without + * loading configuration (12.0), before workspace exclusivity or any hold + * file (13.5). Returns the diagnostic, or null for a well-formed pair of + * operands. + */ +function moveOperandsProblem(positionals: readonly string[]): string | null { + for (const operand of positionals) { + const first = operand.indexOf("#"); + if (first !== -1 && operand.includes("#", first + 1)) { + return ( + `operand '${operand}' contains more than one '#' — at most one is ` + + `well-formed: an operand containing '#' is a <file>#<id> pair and ` + + `one without is a file (SPEC 6.5, 12.0)` + ); + } + } + const [origin, destination] = positionals; + if ( + origin !== undefined && + destination !== undefined && + origin.includes("#") !== destination.includes("#") + ) { + return ( + `operands '${origin}' and '${destination}' mix the two synopses' ` + + `forms — an operand containing '#' is a <file>#<id> pair and one ` + + `without is a file, so the invocation matches neither ` + + `\`move <old-file> <new-file>\` nor ` + + `\`move <file>#<id> <target-file>#<new-id>\` (SPEC 6.5, 12.0)` + ); + } + return null; +} + /** `"build, check, ids, …"` for diagnostics, in specification order. */ function commandNameList(): string { const names: string[] = []; @@ -490,12 +532,9 @@ export function parseArgv(argv: readonly string[]): ParseResult { let jsonOnlySurface = false; const inEffect = (): boolean => jsonToken || jsonOnlySurface; - // SPEC 12.0: argument values are interpreted as UTF-8, and a value that is - // not valid UTF-8 is a usage error. Checked per token below, because the - // `move` command's positionals are exempt (SPEC 6.5: a destination path - // that is not valid UTF-8 is a destination refusal, exit 1 — the command - // classifies it; a non-UTF-8 origin names no discovered source and stays - // in the usage-error class through the existence check). + // SPEC 12.0: argument values are interpreted as UTF-8, and a value that + // is not valid UTF-8 is a usage error — every token, `move`'s positional + // operands included (no argument value may name a non-UTF-8 path, 12.0). const nonUtf8 = (indexInArgv: number): ParseResult => usageError( `argument ${String(indexInArgv + 1)} is not valid UTF-8 — argument ` + @@ -578,15 +617,11 @@ export function parseArgv(argv: readonly string[]): ParseResult { // subcommand) tokens, so the offset restores the original position for // the non-UTF-8 diagnostics. const tokenOffset = argv.length - tokens.length; - // SPEC 6.5: `move`'s positional arguments are exempt from the parse-level - // UTF-8 usage check (see `isValidUtf8ArgumentValue`); flags and their - // values keep it. - const utf8ExemptPositionals = spec.path === "move"; for (let index = 0; index < tokens.length; index += 1) { const token = tokens[index]!; if (!token.startsWith("--")) { - if (!utf8ExemptPositionals && !isValidUtf8ArgumentValue(token)) { + if (!isValidUtf8ArgumentValue(token)) { return nonUtf8(tokenOffset + index); } positionals.push(token); @@ -710,6 +745,15 @@ export function parseArgv(argv: readonly string[]): ParseResult { ); } } + // SPEC 6.5/12.0: `move` operand classification is by spelling alone — a + // multi-`#` operand is a malformed value, and a mixed-synopsis invocation + // matches neither form (see `moveOperandsProblem`). + if (spec.path === "move") { + const problem = moveOperandsProblem(positionals); + if (problem !== null) { + return usageError(`move: ${problem}`, inEffect()); + } + } return { ok: true, diff --git a/src/cli/commands/move.ts b/src/cli/commands/move.ts index 69827cc..2ddc166 100644 --- a/src/cli/commands/move.ts +++ b/src/cli/commands/move.ts @@ -6,9 +6,15 @@ // files' imports of its generated module rewritten so all references // resolve; the full mapping appended to the journal (SPEC 6.1); finishing // regeneration exactly as `xspec build` (SPEC 12.1, 6.4) — which cannot -// fail, because move only ever rewrites a valid workspace. The form is -// selected by the origin argument: an origin containing `#` names a section -// (the second form), a bare origin names a file. +// fail, because move only ever rewrites a valid workspace. A move operand +// is classified by spelling alone (SPEC 6.5): an operand containing `#` is +// a `<file>#<id>` pair under the split of 12.0, one without is a file — and +// the parser (cli/args.ts) has already rejected, as syntax-determined usage +// errors reported without loading configuration (SPEC 12.0), every +// invocation this classification cannot serve: a non-UTF-8 operand value, a +// multi-`#` operand (a malformed value), and an invocation mixing the two +// synopses' forms. The handler therefore only ever sees two operands of one +// form. // // The section form extracts the section subtree with the exact text edits // of SPEC 6.5 (deletion with the SPEC 3 line-drop rule; insertion before @@ -19,7 +25,10 @@ // additions and exact removals, appends the full mapping to the journal, // and regenerates (core/move.ts holds the pure derivation). // -// Outcome precedence (SPEC 6.5, 6.4, 12.0, 13.5, 14): +// Outcome precedence (SPEC 6.5, 6.4, 12.0, 13.5, 14) — upstream of it all, +// the parse-level operand classification above (SPEC 12.0: within exit +// class 2, an error the invocation's syntax alone determines is reported +// without loading configuration): // // 1. Workspace exclusivity (SPEC 13.5): `move` is a mutating command — while // another one runs, it fails promptly with a usage error (exit 2) @@ -194,8 +203,10 @@ interface MoveArgument { } /** - * Split a `move` argument at its first `#` (SPEC 6.5, 1.5: discovered - * source paths never contain `#`, so the first `#` separates file from ID). + * Split a `move` argument at its `#` (SPEC 6.5 under the split of 12.0). + * The parser has already rejected any operand containing more than one + * `#` as a malformed value (SPEC 12.0), so the split is never ambiguous: + * the operand's sole `#` separates file from ID. */ function parseMoveArgument(raw: string): MoveArgument { const hash = raw.indexOf("#"); @@ -234,21 +245,12 @@ async function runMove( ): Promise<ExitCode> { const { workspace, stdout, stderr } = context; - // SPEC 6.5: the origin argument selects the form — a bare path is the - // file form, `file#id` the section form. + // SPEC 6.5: each operand's spelling selects the form — a bare path is + // the file form, `file#id` the section form. The parser has already + // rejected mixed-synopsis invocations (SPEC 12.0), so the two operands + // parse to one form. const origin = parseMoveArgument(originArg); const destination = parseMoveArgument(destinationArg); - if (origin.id !== null && destination.id === null) { - // A section origin with a bare-file destination matches neither form - // (SPEC 6.5): a malformed invocation, a usage error (12.0). - return usageError( - invocation, - context, - `'${destinationArg}' names no target section — the forms are ` + - `\`move <old-file> <new-file>\` and \`move <file>#<id> ` + - `<target-file>#<new-id>\` (SPEC 6.5)`, - ); - } const analysis = await analyzeWorkspace(workspace); @@ -325,6 +327,8 @@ async function runMove( if (origin.id !== null) { if (destination.id === null) { + // Unreachable: the parser rejects mixed-synopsis invocations + // (SPEC 6.5, 12.0). Guarded so a parse regression fails loudly. throw new Error("xspec internal error: section move without a new ID"); } return runMoveSection( @@ -339,12 +343,18 @@ async function runMove( ); } + if (destination.id !== null) { + // Unreachable: the parser rejects mixed-synopsis invocations (SPEC 6.5, + // 12.0). Guarded so a parse regression fails loudly instead of treating + // a pair operand as a destination path. + throw new Error("xspec internal error: file move with a pair destination"); + } return runMoveFile( invocation, context, analysis, origin.file, - destinationArg, + destination.file, preview, ); } @@ -587,25 +597,6 @@ async function runMoveSection( : (analysis.specs.find((spec) => spec.document.path === targetPath) ?? null); - // A `<new-id>` that is not valid UTF-8 cannot be written into a source - // file faithfully (argv bytes that do not decode are irrecoverable; see - // cli/args.ts): it can never be a valid requirement ID (SPEC 1.6, 1.4), - // refused under its reason's stable code (SPEC 14). - if (!isValidUtf8ArgumentValue(newId)) { - return emitFindingsRefusal(preview, invocation.json, stdout, [ - { - code: "refused-invalid-id", - message: - `invalid new ID: the new ID is not valid UTF-8 — requirement ` + - `IDs are decoded UTF-8 content (SPEC 1.6, 1.4); pass a valid ` + - `UTF-8 ID (SPEC 6.5, 14)`, - locations: [], - path: null, - identities: [`${targetPath}#${newId}`], - }, - ]); - } - // SPEC 6.5/14: evaluate every applicable refusal reason together over // the valid workspace — the mirrored identity checks, the target // parent, destination occupancy and validity, would-be cycles, and From 8acae2a75115ede390d3db405a20a9b0e3f94807 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 04:43:58 +0000 Subject: [PATCH 162/226] sdg(phase-10): run gated-read argument checks before the invalid-workspace gate SPEC 12.0/13.3 (FIX_PLAN C2): identity operands of show/query are judged parse-local against the named file (discovery kind, spelled identities, named units; an unparseable named file masks the id/unit half) before the gate report, via the split read pre-answer step (analyzeWorkspaceForRead + assessWorkspaceRead with a deferred commit); review subcommands judge session existence against the directory alone and report a session's corruption only past the gate, on passing workspaces (T10.1-5); multi-# <node>/<graph-node> values are parse-level malformed values reported without loading configuration (T12.0-10, T12.0-13). Adds a plan task for the newly-diagnosed resolvable-baseline-vs-gate defect (T13.3-3's impact arm) and sharpens C3/C4 with observed failure modes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 108 ++++++++++------- src/cli/args.ts | 98 ++++++++++++++- src/cli/commands/gated-args.ts | 184 +++++++++++++++++++++++++++++ src/cli/commands/query-core.ts | 63 ++++++---- src/cli/commands/query.ts | 81 +++++++++++-- src/cli/commands/review-session.ts | 105 +++++++++++----- src/cli/commands/show.ts | 34 +++++- src/cli/prepare.ts | 86 ++++++++++---- src/workspace/refresh.ts | 99 +++++++++++++--- src/workspace/reviews.ts | 24 ++++ 10 files changed, 726 insertions(+), 156 deletions(-) create mode 100644 src/cli/commands/gated-args.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index b5d574a..e4003ff 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -293,48 +293,52 @@ are gone — unreachable, guarded by internal errors. T6.5-5, T6.6-3, and T12.0-13's move arms behave; T12.0-13 still aborts earlier, at its `show a#b#c` arm — C2's scope, see its note.) -### C2. Argument checks precede the invalid-workspace gate on gated reads - -SPEC 12.0 (precedence bullet), 13.3. On a workspace failing `build`'s -validations, `ids`/`show`/`coverage`/`impact`/`review`/`query` currently emit -the gate report (exit 1) before argument checks. Required: each argument check -runs first, judged from what it consults, identically on valid and failing -workspaces — a profile/group name against configuration, a session name against -the session directory, a requirement- or graph-node identity parse-local against -the named file (a discovered path of the identity's kind; an `id` over the -file's spelled identities; a code unit over the file's named units), an -unparseable named file masking the check (gate report, exit 1). So -`show docs/none.mdx#x` (unknown file) and `query node specs/A.mdx#nope` -(unknown id in a parseable file) exit 2 on a failing workspace. Item IDs stay -behind the gate (judged against session content, which gated commands do not -read there). Files: gate sequencing in `src/workspace/pipeline.ts` and the -command handlers under `src/cli/commands/`. - -The corrupt-session report is likewise gated (SPEC 14.21: reported "only on a -workspace passing `build`'s validations — on a failing one the gate's findings -are reported without any session being read"): `loadSessionForCommand` -(`src/cli/commands/review-session.ts`) currently reports the 14.21 corruption -before the refresh, so `review status <corrupt>` on a failing workspace emits -`corrupt-session` instead of the gate's findings (observed: T10.1-5 expects -`14.1 x1`, got `14.21 x1`). Required order there: session-name validity and -existence (exit 2, judged against the directory) still precede the gate; the -corruption *report* moves behind it — gate failing → gate findings alone, exit -1; gate passing → the 14.21 finding as today. Recorded-baseline resolution -(6.3, exit 2 before source validation) applies only to a readable session; -a corrupt one has no readable parameters — the corruption (or, failing -workspace, the gate) reports instead. `review list` already gates first. - -Also observed by C1's spawn, same scope: a `<node>`/`<graph-node>` value with -more than one `#` must exit 2 as a malformed value on `show`/`query node` -(T12.0-13's arms — currently exit 1 via the gate), and T12.0-10's -within-class-2 arm additionally pins `show a#b#c` as reported *without -loading configuration* (byte-identical error documents with the -configuration invalid or missing) — for that one a handler-level check is -too late; C1's `moveOperandsProblem` in `src/cli/args.ts` is the -parse-level pattern (`occurrences --to` already checks its spelling in the -handler, which its arms accept). - -Verify: T12.0-10 (`section-12.0*.test.ts`), T10.1-5 (`section-10.1.test.ts`). +(C2 landed: gated-read argument checks precede the gate. The read +pre-answer step is split — `analyzeWorkspaceForRead` (pure) + +`assessWorkspaceRead` (gate decision with a deferred `commit()` write) in +`src/workspace/refresh.ts`, CLI faces `analyzeGraphForRead` / +`finishGraphForRead` in `src/cli/prepare.ts` — so handlers run identity +checks between analysis and gate report. The parse-local judgments are +`nodeOperandProblem`/`graphNodeValueProblem` in +`src/cli/commands/gated-args.ts` (discovery kind → spelled identities / +named units; unparseable named file masks the id/unit half), sharing +query-core's exported message builders so the store-backed fast path +reports byte-identically. `loadSessionForCommand` now orders: name → +analyze → existence by directory entry (`sessionOccupied`, +`workspace/reviews.ts`, no content read) → gate (assess; findings exit 1, +session unread) → load (corrupt → 14.21, passing workspaces only) → +recorded-baseline resolution → `commit()`. Multi-`#` `<node>`/`<graph-node>` +values are parse-level malformed values (`identityValueProblem` in +`src/cli/args.ts`, `identityPositionals`/`identityValue` marks on `show`, +`query node`/`subtree`/`ancestors`, `edges`/`reachable` `--from`/`--to`) — +reported without loading configuration.) + +### C2b. A resolvable baseline over a failing workspace hits the gate, not 6.3 + +SPEC 13.3, 12.0, 6.3. Observed at T13.3-3's garbage-journal whole-gate arm: +`impact --base <commit>` where the commit *includes* the garbage journal line +(baseline journal bytes = current journal bytes) exits 2 with the 6.3 +reconstruction error ("the workspace content at baseline ref … cannot be +parsed and validated"); expected: the gate's one 14.13 finding, exit 1 — +the test's own staging comment says "baseline resolution — which precedes +the gate (SPEC 12.0) — succeeds and the gate is `impact --base`'s operative +error". TEST-SPEC T6.3-4 draws the line: an *unresolvable ref* stays exit 2 +with the baseline error even over invalid sources (the precedence arm), and +the garbage line *appended after* the baseline commit stays the exit-2 +replay failure at `impact --base` and `review create --base`; "the +resolvable-ref counterpart over invalid sources is T13.3-3's refresh +failure (exit 1)". Required: `resolveBaseline` (`src/workspace/baseline.ts`) +must not fail on baseline-content validation findings the gate would report +— sequence at `impact --base` (and `review create --base`): unresolvable +ref → exit 2; journal prefix/replay failure → exit 2 (naming the entries); +then the current-workspace gate → exit 1; baseline-content validation +failure (reachable on passing current workspaces — T6.3-4's +invalid-baseline-sources arm) → exit 2. Pin exact semantics against +T6.3-1..4 (all currently green — keep them green) and T13.3-3. + +Verify: T13.3-3's garbage-journal `impact` arm (`section-13.3.test.ts` — +its obstructed-write-path arm is C3's, its later arms C4-adjacent), +T6.3-1..4 (`section-6.3.test.ts`). ### C3. Obstructed write path: any non-directory component, refused before modifying @@ -351,8 +355,22 @@ derived file's own path stays a replacement, not an error; a durable file's own path holding a non-plain-file stays 14.13/14.21; a move's destination-side component stays `refused-invalid-destination` (B8), never condition 22. +Same condition, gate side (observed at T13.3-3's obstructed-write-path arm, +`markdown.outDir` replaced by a plain file): the gated reads' 13.3 gate is +"the findings a `build` would now report", 14.22 over build's FULL write set +included — today `assessWorkspaceRead` (`src/workspace/refresh.ts`) probes +only the graph-data path, and only on a store mismatch, so `ids` et al. +answer exit 0 where T13.3-3 expects the one condition-22 finding, exit 1. +`finishAvailabilityRefresh` (`src/workspace/availability.ts`) already +evaluates build's `writePaths` — mirror that in `assessWorkspaceRead` +(evaluation only; the reads still write nothing on the failing side). Note +`classifyOccupant` currently throws raw ENOTDIR when a parent component is a +plain file (`build` crashes exit 70, T11.2-6's observed failure) — the +writes.ts fix must classify that as the obstructed component, not crash. + Verify: P-8 (`section-16-p8.test.ts` or the P-8 registry file), T13.4 arms -(`section-13.4*.test.ts`). +(`section-13.4*.test.ts`), T13.3-3's obstructed-write-path arms, T11.2-6 +(`section-11.2.test.ts`), T14-4's 14.13/14.22 reporter rows. ### C4. Unreadable recorded state persists; `check` reports the exclusive unit form @@ -375,7 +393,9 @@ mismatch and fabricate a fresh record: after corrupting `.xspec/graph.json`, derived path no longer generated), consulting no readable record, is undetectable and not reported; the other per-file forms report normally. -Verify: T13.3-3 (`section-13.3*.test.ts`), T12.2 arms. +Verify: T13.3-2 (`section-13.3*.test.ts` — its corrupt-record half is this +task's observed failure: `ids --json` repairs the store and `inventory` then +answers exit 0), T12.2-2/3 arms. ### C5. 14.10 unit-form findings concern the graph-data area diff --git a/src/cli/args.ts b/src/cli/args.ts index d7ff02c..dce7d77 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -53,6 +53,14 @@ interface FlagSpec { * list whose every element must be in this set. */ readonly list?: readonly string[]; + /** + * SPEC 12.0: the flag's value is a `<node>`/`<graph-node>` identity — + * `#` splits path from id or unit, at most one is well-formed, and a + * spelling containing more than one is a malformed value, an error the + * invocation's syntax alone determines: parse-level, reported without + * loading configuration. + */ + readonly identityValue?: boolean; } /** One command (or `review`/`query` subcommand) of the SPEC 12.5 table. */ @@ -74,6 +82,14 @@ interface CommandSpec { * the invocation's syntax alone determines (SPEC 12.0). */ readonly positionalConflicts?: readonly string[]; + /** + * SPEC 12.0: the command's positional operands are `<node>`/`<graph-node>` + * identities (the `identityValue` rule, positional side) — a multi-`#` + * spelling is a malformed value, parse-level. Never set for `<file>` + * operands: a bare `<file>` is a whole path in which `#` has no delimiter + * role (`view`, `at`, `rename`'s origin). + */ + readonly identityPositionals?: boolean; /** Command-specific flags; the SPEC 12.0 globals are added for every command. */ readonly flags: readonly FlagSpec[]; /** @@ -152,7 +168,12 @@ const COMMANDS: readonly CommandSpec[] = [ ], }, // SPEC 12.4: `show <node>`. - { path: "show", positionals: ["<node>"], flags: [] }, + { + path: "show", + positionals: ["<node>"], + identityPositionals: true, + flags: [], + }, // SPEC 8.2: `coverage` runs all profiles, `coverage <name>` one; `--check`. { path: "coverage", @@ -222,7 +243,13 @@ const COMMANDS: readonly CommandSpec[] = [ // document, its only output form with or without `--json` (12.0). { path: "review export", positionals: ["<name>"], flags: [], jsonOnly: true }, // SPEC 11: the six query subcommands — JSON-only surfaces (12.0). - { path: "query node", positionals: ["<node>"], flags: [], jsonOnly: true }, + { + path: "query node", + positionals: ["<node>"], + identityPositionals: true, + flags: [], + jsonOnly: true, + }, { path: "query nodes", positionals: [], @@ -245,8 +272,18 @@ const COMMANDS: readonly CommandSpec[] = [ positionals: [], jsonOnly: true, flags: [ - { name: "--from", takesValue: true, valueName: "<graph-node>" }, - { name: "--to", takesValue: true, valueName: "<graph-node>" }, + { + name: "--from", + takesValue: true, + valueName: "<graph-node>", + identityValue: true, + }, + { + name: "--to", + takesValue: true, + valueName: "<graph-node>", + identityValue: true, + }, // SPEC 11: `edges --kinds` filters over all four kinds. { name: "--kinds", @@ -256,10 +293,17 @@ const COMMANDS: readonly CommandSpec[] = [ }, ], }, - { path: "query subtree", positionals: ["<node>"], flags: [], jsonOnly: true }, + { + path: "query subtree", + positionals: ["<node>"], + identityPositionals: true, + flags: [], + jsonOnly: true, + }, { path: "query ancestors", positionals: ["<node>"], + identityPositionals: true, flags: [], jsonOnly: true, }, @@ -273,12 +317,14 @@ const COMMANDS: readonly CommandSpec[] = [ takesValue: true, valueName: "<graph-node>", required: true, + identityValue: true, }, { name: "--to", takesValue: true, valueName: "<graph-node>", required: true, + identityValue: true, }, { name: "--kinds", @@ -475,6 +521,26 @@ function moveOperandsProblem(positionals: readonly string[]): string | null { return null; } +/** + * SPEC 12.0: at most one `#` is well-formed in a `<node>`/`<graph-node>` + * value — its `#` splits path from id or unit, and no identity contains one + * in path, id segment, or unit name (1.4, 1.5, 4.6) — so a spelling + * containing more than one is a malformed value, a usage error the + * invocation's syntax alone determines: parse-level, reported without + * loading configuration. Returns the diagnostic, or null. + */ +function identityValueProblem(value: string, what: string): string | null { + const first = value.indexOf("#"); + if (first !== -1 && value.includes("#", first + 1)) { + return ( + `${what} value '${value}' contains more than one '#' — at most one ` + + `is well-formed: '#' splits path from id or unit, and no identity ` + + `contains one (SPEC 12.0, 1.5)` + ); + } + return null; +} + /** `"build, check, ids, …"` for diagnostics, in specification order. */ function commandNameList(): string { const names: string[] = []; @@ -686,6 +752,14 @@ export function parseArgv(argv: readonly string[]): ParseResult { inEffect(), ); } + if (flag.identityValue === true) { + // SPEC 12.0: a `<graph-node>` flag value with more than one `#` is a + // malformed value — syntax-determined, so parse-level. + const problem = identityValueProblem(value, `${spec.path}: '${token}'`); + if (problem !== null) { + return usageError(problem, inEffect()); + } + } if (token === "--config") config = value; else flags.set(token, value); } @@ -745,6 +819,20 @@ export function parseArgv(argv: readonly string[]): ParseResult { ); } } + // SPEC 12.0: a `<node>` positional with more than one `#` is a malformed + // value — syntax-determined, so parse-level (`show`, `query node`, + // `query subtree`, `query ancestors`). + if (spec.identityPositionals === true) { + for (const positional of positionals) { + const problem = identityValueProblem( + positional, + `${spec.path}: ${spec.positionals[0] ?? "<node>"}`, + ); + if (problem !== null) { + return usageError(problem, inEffect()); + } + } + } // SPEC 6.5/12.0: `move` operand classification is by spelling alone — a // multi-`#` operand is a malformed value, and a mixed-synopsis invocation // matches neither form (see `moveOperandsProblem`). diff --git a/src/cli/commands/gated-args.ts b/src/cli/commands/gated-args.ts new file mode 100644 index 0000000..f273015 --- /dev/null +++ b/src/cli/commands/gated-args.ts @@ -0,0 +1,184 @@ +// Parse-local argument checks of the gated reads (SPEC 12.0, 13.3). +// +// SPEC 12.0: the reads 13.3 gates (`ids`, `show`, `coverage`, `impact`, +// `review`, `query`) run their argument checks before the invalid-workspace +// report of 13.3 — a usage-error argument exits 2 whatever findings the +// workspace carries. A requirement-node or graph-node identity is judged +// parse-local against the named file, as 6.4 judges rename's old ID: +// +// - the path part must be a discovered path of the identity's kind +// (SPEC 11.1) — for `<node>` a spec source, a code source being the +// wrong-kind operand of 12.0; for `<graph-node>` either kind; +// - an id is judged over the named file's spelled identities (SPEC 11.2) — +// a section spells an identity exactly when exactly one `id` attribute +// occurs on its tag with a quoted static-string value, that value the +// spelled identity, well-formed or not (core/mdx.ts `SpecSection.id`); +// - a code unit is judged over the named file's named units (SPEC 4.6); +// - an unparseable named file masks the id/unit half of the check as in +// 6.4: the check passes here and the gated report of 13.3 exits 1. +// +// Each check is judged from what it consults — discovery and the named +// file's parse — identically on valid and failing workspaces (SPEC 12.0). +// On a valid workspace a spelled identity is a defined identity and a named +// unit a code location (SPEC 11.2, 12.1), so these judgments agree exactly +// with the graph-based resolution the answer then runs (query-core.ts) — +// and they share its message builders, so the store-backed fast path +// (query-fast.ts), which judges against the verified store, reports +// byte-identically (SPEC 12.0). + +import type { CodeAnalysis } from "../../core/code-analysis.js"; +import type { SpecDocument } from "../../core/mdx.js"; +import type { WorkspaceAnalysis } from "../../workspace/pipeline.js"; +import { + codeLocationNodeMessage, + unknownGraphNodeMessage, + unknownNodeMessage, +} from "./query-core.js"; + +/** A `<node>`/`<graph-node>` value split at its `#` (SPEC 12.0, 1.5). */ +interface SplitIdentity { + readonly path: string; + /** The id or unit part — undefined for a bare path. */ + readonly rest: string | undefined; +} + +/** Split at the `#` (the parser rejects multi-`#` spellings, SPEC 12.0). */ +function splitIdentity(raw: string): SplitIdentity { + const hash = raw.indexOf("#"); + if (hash === -1) { + return { path: raw, rest: undefined }; + } + return { path: raw.slice(0, hash), rest: raw.slice(hash + 1) }; +} + +/** The parse-local view of the named file the checks consult. */ +interface NamedFileDomain { + /** Discovered spec-source paths (valid paths only, SPEC 14.19/12.0). */ + readonly specPaths: ReadonlySet<string>; + /** Discovered code-source paths (valid paths only). */ + readonly codePaths: ReadonlySet<string>; + /** Parsed spec documents by path — absent = unparseable (SPEC 14.20). */ + readonly spec: (path: string) => SpecDocument | undefined; + /** Parsed code analyses by path — absent = unparseable (SPEC 14.20). */ + readonly code: (path: string) => CodeAnalysis | undefined; +} + +/** The checks' domain over the analyzed workspace (pipeline.ts). */ +function domainOf(analysis: WorkspaceAnalysis): NamedFileDomain { + const { classification } = analysis; + const specs = new Map( + analysis.specs.map((spec) => [spec.document.path, spec.document]), + ); + const code = new Map(analysis.code.map((entry) => [entry.path, entry])); + return { + specPaths: new Set(classification.specSources.map((source) => source.path)), + codePaths: new Set(classification.codeSources.map((source) => source.path)), + spec: (path) => specs.get(path), + code: (path) => code.get(path), + }; +} + +/** + * SPEC 11.2: whether the parsed file spells `id` — some section's exactly-one + * quoted-static `id` attribute carries this exact value (well-formed or not; + * `SpecSection.id` is null in every other case, and null for the root). + */ +function spellsIdentity(document: SpecDocument, id: string): boolean { + return document.sections.some((section) => section.id === id); +} + +/** + * SPEC 4.6: whether the value names one of the file's named units — the + * whole-file location for a bare path, else a unit whose `path#chain` + * (`@N`-disambiguated) identity equals the value. Judged over the parse + * where one exists; the kind itself is discovery's (an unparseable code + * file still classifies as a code location for the wrong-kind judgment — + * the id/unit half is what an unparseable file masks). + */ +function namesCodeLocation( + analysis: CodeAnalysis | undefined, + raw: string, + split: SplitIdentity, +): boolean { + if (split.rest === undefined) { + return true; + } + if (analysis === undefined) { + return true; // masked: the unit cannot be judged (SPEC 12.0, 14.20) + } + return analysis.units.some((unit) => unit.identity === raw); +} + +/** + * The `<node>` argument check of `show` and `query node`/`subtree`/ + * `ancestors` (SPEC 12.4, 11.1 → 12.0), parse-local per the module header. + * Returns the usage-error diagnostic, or null when the check passes — an + * unknown name or wrong-kind operand exits 2 whatever findings the + * workspace carries; a masked (unparseable) named file passes, the gated + * report of 13.3 then exiting 1. + */ +export function nodeOperandProblem( + analysis: WorkspaceAnalysis, + raw: string, +): string | null { + const domain = domainOf(analysis); + const split = splitIdentity(raw); + if (domain.specPaths.has(split.path)) { + const document = domain.spec(split.path); + if (document === undefined) { + return null; // masked: an unparseable named file (SPEC 12.0, 14.20) + } + if (split.rest === undefined || spellsIdentity(document, split.rest)) { + return null; + } + return unknownNodeMessage(raw); + } + if (domain.codePaths.has(split.path)) { + // SPEC 12.0: a code source named where a requirement-node identity is + // required is the wrong-kind operand — the kind is discovery's, never + // masked. The diagnostic mirrors the graph-based resolution exactly + // (query-core.ts `resolveRow`): a value naming a code location gets the + // wrong-kind message, one naming no unit of the file the unknown one. + return namesCodeLocation(domain.code(split.path), raw, split) + ? codeLocationNodeMessage(raw) + : unknownNodeMessage(raw); + } + return unknownNodeMessage(raw); +} + +/** + * The `<graph-node>` flag-value check of `query edges`/`reachable` + * (SPEC 11.1 → 12.0), parse-local per the module header: any graph-node + * identity — a requirement node or a code location. Returns the + * usage-error diagnostic, null when the check passes (a masked named file + * passing as above). + */ +export function graphNodeValueProblem( + analysis: WorkspaceAnalysis, + flag: string, + raw: string, +): string | null { + const domain = domainOf(analysis); + const split = splitIdentity(raw); + if (domain.specPaths.has(split.path)) { + const document = domain.spec(split.path); + if (document === undefined) { + return null; // masked (SPEC 12.0, 14.20) + } + if (split.rest === undefined || spellsIdentity(document, split.rest)) { + return null; + } + return unknownGraphNodeMessage(flag, raw); + } + if (domain.codePaths.has(split.path)) { + const parsed = domain.code(split.path); + if (parsed === undefined) { + return null; // masked (SPEC 12.0, 14.20) + } + if (split.rest === undefined || namesCodeLocation(parsed, raw, split)) { + return null; + } + return unknownGraphNodeMessage(flag, raw); + } + return unknownGraphNodeMessage(flag, raw); +} diff --git a/src/cli/commands/query-core.ts b/src/cli/commands/query-core.ts index 8e707a3..75fb9df 100644 --- a/src/cli/commands/query-core.ts +++ b/src/cli/commands/query-core.ts @@ -150,6 +150,40 @@ export type RowResolution = | { readonly ok: true; readonly row: QueryRow } | { readonly ok: false; readonly message: string }; +/** + * SPEC 11.1/12.4/12.0: the wrong-kind `<node>` diagnostic — the value names + * a code location where a requirement-node identity is required. Shared by + * the graph-based resolution below and the parse-local pre-gate check + * (./gated-args.ts), so the two judgments — identical by construction on + * valid workspaces (SPEC 12.0) — report byte-identically. + */ +export function codeLocationNodeMessage(raw: string): string { + return ( + `'${raw}' names a code location — <node> takes a requirement-node ` + + `identity: path#id, or a bare path for a file's root node ` + + `(SPEC 11, 1.5)` + ); +} + +/** SPEC 11/12.0: the unknown-`<node>` diagnostic (shared as above). */ +export function unknownNodeMessage(raw: string): string { + return ( + `unknown requirement node '${raw}' — expected path#id, or a bare ` + + `path for a file's root node; a path in no configured group is ` + + `unknown (SPEC 11, 1.5, 12.0)` + ); +} + +/** SPEC 11/4.6/12.0: the unknown-`<graph-node>` diagnostic (shared as above). */ +export function unknownGraphNodeMessage(flag: string, raw: string): string { + return ( + `unknown graph node '${raw}' for '${flag}' — expected a requirement ` + + `node (path#id, or a bare path for a spec file's root node) or a code ` + + `location (path, path#unit, or path#unit@N); a path in no configured ` + + `group is unknown (SPEC 11, 1.5, 4.6, 12.0)` + ); +} + /** * Resolve a `<node>` argument: a requirement-node identity — `path#id`, or * a bare path for a file's root node (SPEC 11, 12.4, 1.5). A code-location @@ -161,21 +195,9 @@ export function resolveRow(view: QueryView, raw: string): RowResolution { return { ok: true, row }; } if (view.isCodeLocation(raw)) { - return { - ok: false, - message: - `'${raw}' names a code location — <node> takes a requirement-node ` + - `identity: path#id, or a bare path for a file's root node ` + - `(SPEC 11, 1.5)`, - }; + return { ok: false, message: codeLocationNodeMessage(raw) }; } - return { - ok: false, - message: - `unknown requirement node '${raw}' — expected path#id, or a bare ` + - `path for a file's root node; a path in no configured group is ` + - `unknown (SPEC 11, 1.5, 12.0)`, - }; + return { ok: false, message: unknownNodeMessage(raw) }; } /** @@ -183,7 +205,7 @@ export function resolveRow(view: QueryView, raw: string): RowResolution { * a requirement node or a code location. Returns the usage-error message * for an unknown identity, null when it resolves. */ -function unknownGraphNodeMessage( +function graphNodeProblem( view: QueryView, flag: string, raw: string, @@ -191,12 +213,7 @@ function unknownGraphNodeMessage( if (view.row(raw) !== undefined || view.isCodeLocation(raw)) { return null; } - return ( - `unknown graph node '${raw}' for '${flag}' — expected a requirement ` + - `node (path#id, or a bare path for a spec file's root node) or a code ` + - `location (path, path#unit, or path#unit@N); a path in no configured ` + - `group is unknown (SPEC 11, 1.5, 4.6, 12.0)` - ); + return unknownGraphNodeMessage(flag, raw); } /** The `nodes` filters, validated against the configuration alone. */ @@ -415,7 +432,7 @@ export function answerQuery( if (raw === undefined) { continue; } - const message = unknownGraphNodeMessage(view, flag, raw); + const message = graphNodeProblem(view, flag, raw); if (message !== null) { return usageError(invocation, io, message); } @@ -463,7 +480,7 @@ export function answerQuery( ["--from", from], ["--to", to], ] as const) { - const message = unknownGraphNodeMessage(view, flag, raw); + const message = graphNodeProblem(view, flag, raw); if (message !== null) { return usageError(invocation, io, message); } diff --git a/src/cli/commands/query.ts b/src/cli/commands/query.ts index 1e524b1..ba44309 100644 --- a/src/cli/commands/query.ts +++ b/src/cli/commands/query.ts @@ -10,15 +10,62 @@ // it runs when no verified store can answer (cli/main.ts tries the fast // path first), prepares the refreshed analysis, and answers through the // analysis-backed view (./analysis-view.ts). +// +// SPEC 12.0: the argument checks precede the invalid-workspace report of +// 13.3 — the configuration-level flag checks of `query nodes` +// (query-core.ts), then the `<node>`/`<graph-node>` identity checks, +// judged parse-local against the named file (./gated-args.ts) — so a +// usage-error argument exits 2 whatever findings the workspace carries, +// while configuration errors keep their precedence over every check +// (SPEC 14.14, surfaced by the analysis step). import type { ExitCode } from "../../core/findings.js"; +import type { WorkspaceAnalysis } from "../../workspace/pipeline.js"; import type { Invocation } from "../args.js"; +import { flagValue } from "../args.js"; import type { CommandContext } from "../io.js"; -import { prepareGraphForRead } from "../prepare.js"; +import { analyzeGraphForRead, finishGraphForRead } from "../prepare.js"; import { analysisQueryView } from "./analysis-view.js"; +import { usageError } from "./common.js"; +import { graphNodeValueProblem, nodeOperandProblem } from "./gated-args.js"; import { answerQuery, prevalidateQuery } from "./query-core.js"; import { groupsViewOfConfiguration } from "./query-groups.js"; +/** + * The subcommand's identity-argument checks (SPEC 12.0), parse-local per + * ./gated-args.ts: the `<node>` positional of `node`/`subtree`/`ancestors`, + * the `<graph-node>` values of `edges`/`reachable` — `--from` then `--to`, + * the order the graph-based answering checks them in (query-core.ts). + * Returns the usage-error diagnostic, or null. + */ +function queryIdentityProblem( + invocation: Invocation, + analysis: WorkspaceAnalysis, +): string | null { + switch (invocation.command) { + case "query node": + case "query subtree": + case "query ancestors": + return nodeOperandProblem(analysis, invocation.positionals[0]); + case "query edges": + case "query reachable": { + for (const flag of ["--from", "--to"] as const) { + const raw = flagValue(invocation, flag); + if (raw === undefined) { + continue; + } + const problem = graphNodeValueProblem(analysis, flag, raw); + if (problem !== null) { + return problem; + } + } + return null; + } + default: + return null; + } +} + /** The `query` command handler — all six subcommands (SPEC 11). */ export async function queryCommand( invocation: Invocation, @@ -27,21 +74,33 @@ export async function queryCommand( const { stdout, stderr } = context; const groups = groupsViewOfConfiguration(context.workspace.configuration); - // SPEC 11: configuration-level flag validation precedes source analysis, - // like its 14.14 counterparts (query-core.ts). + // SPEC 11: a single JSON document is `query`'s only output form, with or + // without `--json` — the findings report of a failed refresh included, so + // the prepare steps run with JSON output forced on. + const forced = { ...invocation, json: true }; + + // SPEC 14.14/12.0: the analysis surfaces configuration errors first — + // they precede every argument check that consults configuration, + // discovery, or the workspace. + const analyzed = await analyzeGraphForRead(forced, context); + if (!analyzed.ok) { + return analyzed.exit; + } + + // SPEC 11: the configuration-level flag validation of `query nodes` + // (query-core.ts), then the identity checks — every argument check + // precedes the invalid-workspace report of 13.3 (SPEC 12.0). const prevalidated = prevalidateQuery(invocation, groups, context); if (!prevalidated.ok) { return prevalidated.exit; } + const problem = queryIdentityProblem(invocation, analyzed.analysis); + if (problem !== null) { + return usageError(invocation, context, problem); + } - // SPEC 13.3: refresh-on-read, then answer. SPEC 11: a single JSON - // document is `query`'s only output form, with or without `--json` — the - // findings report of a failed refresh included, so the prepare step runs - // with JSON output forced on. - const prepared = await prepareGraphForRead( - { ...invocation, json: true }, - context, - ); + // SPEC 13.3: the gate report, then refresh-on-read, then answer. + const prepared = await finishGraphForRead(forced, context, analyzed.analysis); if (!prepared.ok) { return prepared.exit; } diff --git a/src/cli/commands/review-session.ts b/src/cli/commands/review-session.ts index c485ebe..edb6800 100644 --- a/src/cli/commands/review-session.ts +++ b/src/cli/commands/review-session.ts @@ -9,18 +9,25 @@ // // 1. Session-name validity (SPEC 10.1 → 12.0: any other name is a usage // error, exit 2). -// 2. Load the session (workspace/reviews.ts): an absent session is an -// unknown session named in arguments — usage error, exit 2 (SPEC 10.7, -// 12.0); a corrupt one is reported as the 14.21 finding, exit 1, -// modifying nothing (SPEC 10.1). -// 3. For a `path-blocks` session, resolve the recorded baseline commit -// (SPEC 10.7: every later generator run uses the recorded parameters). -// A baseline that cannot be resolved or reconstructed fails per 6.3 as a -// usage error (exit 2), and baseline resolution precedes source -// validation (SPEC 12.0) — so this runs before the refresh. -// 4. Refresh-on-read (SPEC 13.3, cli/prepare.ts): validation findings -// report and exit 1, nothing answered, nothing modified. -// 5. Re-run the session's strategy generators with the recorded creation +// 2. Analyze the workspace (a pure read): configuration errors keep their +// exit-2 precedence over every later check (SPEC 14.14, 12.0). +// 3. Session existence, judged against the session directory alone — no +// content read (SPEC 12.0, 10.1): an absent session is an unknown +// session named in arguments, exit 2, whatever findings the workspace +// carries. +// 4. The gate (SPEC 13.3): on a workspace failing `build`'s validations +// the gate's findings report alone, exit 1, and no session file is read +// — a session's corruption (14.21) is reported exactly where sessions +// are read, on a passing workspace (SPEC 10.1). Passing, the session is +// loaded: corrupt → the 14.21 finding, exit 1, modifying nothing. +// 5. For a readable `path-blocks` session, resolve the recorded baseline +// commit (SPEC 10.7: every later generator run uses the recorded +// parameters). A baseline that cannot be resolved or reconstructed +// fails per 6.3 as a usage error (exit 2) — before the refresh write, +// so the failing invocation modifies nothing; a corrupt session has no +// readable parameters, the corruption reporting instead. Then the one +// refresh write of 13.3 commits. +// 6. Re-run the session's strategy generators with the recorded creation // parameters against the current workspace (SPEC 10.4, 10.7), // canonicalized at the derivation seam (core/review-derive.ts // `canonicalizeGeneration` — stored references and generated nodes @@ -81,11 +88,12 @@ import { } from "../../core/review-state.js"; import type { ResolvedBaseline } from "../../workspace/baseline.js"; import { resolveBaseline } from "../../workspace/baseline.js"; -import { loadSession } from "../../workspace/reviews.js"; +import { loadSession, sessionOccupied } from "../../workspace/reviews.js"; import type { WorkspaceAnalysis } from "../../workspace/pipeline.js"; +import { assessWorkspaceRead } from "../../workspace/refresh.js"; import type { Invocation } from "../args.js"; import type { CliWriter, CommandContext } from "../io.js"; -import { prepareGraphForRead } from "../prepare.js"; +import { analyzeGraphForRead } from "../prepare.js"; import { emitFindingsReport } from "../report.js"; import { rangeJson, usageError } from "./common.js"; @@ -249,7 +257,7 @@ export interface SessionReadView { } /** - * Derive a session's read-time view (module header step 5). Nothing is + * Derive a session's read-time view (module header step 6). Nothing is * persisted: read-time invalidation is computed and reported, never written * (SPEC 10.4). The stored session is consumed as-is — stored references * are canonical identities (SPEC 5.4), eternal under journal growth, so no @@ -305,7 +313,7 @@ export function buildSessionReadView( } // --------------------------------------------------------------------------- -// The shared open flow (module header steps 1–5) +// The shared open flow (module header steps 1–6) // --------------------------------------------------------------------------- /** The open outcome: the view, or an already-emitted exit code. */ @@ -315,8 +323,8 @@ export type SessionOpenResult = /** * Open a named session for a read (`status`, `next`, `show`, `export`) — - * the module header's steps 1–5. Failures are fully reported here; the - * caller returns `exit` unchanged. Mutating subcommands share steps 1–4 + * the module header's steps 1–6. Failures are fully reported here; the + * caller returns `exit` unchanged. Mutating subcommands share steps 1–5 * through `loadSessionForCommand` and run their own derivation. */ export async function openSessionForRead( @@ -344,7 +352,7 @@ export async function openSessionForRead( }; } -/** Steps 1–4 of the open flow: the stored session, the current analysis, +/** Steps 1–5 of the open flow: the stored session, the current analysis, * and — for a `path-blocks` session — the resolved recorded baseline. */ export interface LoadedSessionForCommand { readonly ok: true; @@ -366,8 +374,45 @@ export async function loadSessionForCommand( return { ok: false, exit: nameCheck }; } - // Step 2 — load: absent = unknown session (usage, SPEC 10.7 → 12.0); - // corrupt = the 14.21 finding, exit 1, modifying nothing (SPEC 10.1). + // Step 2 — SPEC 14.14/12.0: analyze the current workspace — a pure read; + // configuration errors precede every argument check that consults the + // workspace, the unknown-session check below included. + const analyzed = await analyzeGraphForRead(invocation, context); + if (!analyzed.ok) { + return { ok: false, exit: analyzed.exit }; + } + + // Step 3 — SPEC 12.0/10.1: the session name is judged against the + // session directory — existence by directory entry alone, no session + // content read — before the invalid-workspace report of 13.3: an unknown + // session is a usage error, exit 2, whatever findings the workspace + // carries. + if (!(await sessionOccupied(context.workspace.root, name))) { + return { + ok: false, + exit: unknownSessionError(name, invocation, context), + }; + } + + // Step 4 — the gate (SPEC 13.3): on a workspace failing `build`'s + // validations — validation findings and a refused refresh write + // (SPEC 14.22) alike — the gate's findings are reported alone, exit 1, + // and no session file is read: a session's corruption (14.21) is + // reported exactly where sessions are read, on a passing workspace + // (SPEC 10.1, 12.0). The assessment decides without writing; the one + // refresh write commits below, once every remaining check has passed. + const assessed = await assessWorkspaceRead( + context.workspace, + analyzed.analysis, + ); + if (assessed.kind === "findings") { + emitFindingsReport(invocation.json, context.stdout, assessed.findings); + return { ok: false, exit: 1 }; + } + + // Step 5 — the workspace passes: sessions are read here (SPEC 10.1). + // Corrupt = the 14.21 finding, exit 1, modifying nothing; absent (the + // occupant vanished since step 3) = unknown session (SPEC 10.7 → 12.0). const loaded = await loadSession(context.workspace.root, name); if (loaded.state === "absent") { return { @@ -380,8 +425,11 @@ export async function loadSessionForCommand( return { ok: false, exit: 1 }; } - // Step 3 — SPEC 10.7/6.3/12.0: resolve the recorded baseline before - // source validation; failure is a usage error, nothing modified. + // Step 6 — SPEC 10.7/6.3/12.0: resolve the recorded baseline of a + // readable session before source validation could mask it; failure is a + // usage error, nothing modified (the refresh write has not run yet). A + // corrupt session has no readable parameters — the corruption (or, on a + // failing workspace, the gate) reports instead. let baseline: ResolvedBaseline | undefined; if (loaded.session.parameters.strategy === "path-blocks") { const resolution = await resolveBaseline( @@ -402,16 +450,13 @@ export async function loadSessionForCommand( baseline = resolution.baseline; } - // Step 4 — refresh-on-read (SPEC 13.3): validation findings report and - // exit 1; configuration errors exit 2 (already reported). - const prepared = await prepareGraphForRead(invocation, context); - if (!prepared.ok) { - return { ok: false, exit: prepared.exit }; - } + // Step 7 — refresh-on-read (SPEC 13.3): the one refresh write (a no-op + // when the store already matches), every check passed. + await assessed.commit(); return { ok: true, session: loaded.session, - analysis: prepared.analysis, + analysis: analyzed.analysis, baseline, }; } diff --git a/src/cli/commands/show.ts b/src/cli/commands/show.ts index e65ebe5..3d75634 100644 --- a/src/cli/commands/show.ts +++ b/src/cli/commands/show.ts @@ -8,15 +8,17 @@ // report document (./query-core.ts `nodeReportOf` — one shape, one place), // so the two commands can never disagree. The answer comes from the // refreshed graph (SPEC 13.3, via cli/prepare.ts); an unknown node identity -// is a usage error, exit 2 (SPEC 12.0). +// is a usage error, exit 2 (SPEC 12.0) — judged parse-local against the +// named file before the invalid-workspace report of 13.3 (./gated-args.ts). import type { ExitCode } from "../../core/findings.js"; import type { GraphEdge } from "../../core/graph.js"; import type { Invocation } from "../args.js"; import type { CommandContext } from "../io.js"; -import { prepareGraphForRead } from "../prepare.js"; +import { analyzeGraphForRead, finishGraphForRead } from "../prepare.js"; import { analysisQueryView } from "./analysis-view.js"; import { emitDocument, usageError } from "./common.js"; +import { nodeOperandProblem } from "./gated-args.js"; import type { QueryRow, QueryView } from "./query-core.js"; import { nodeReportOf, resolveRow } from "./query-core.js"; @@ -79,8 +81,29 @@ export async function showCommand( ): Promise<ExitCode> { const { stdout, stderr } = context; - // SPEC 13.3: refresh-on-read, then answer. - const prepared = await prepareGraphForRead(invocation, context); + // SPEC 12.0/13.3: the `<node>` argument check precedes the + // invalid-workspace report — judged parse-local against the named file, + // identically on valid and failing workspaces (./gated-args.ts), so an + // unknown or wrong-kind name exits 2 whatever findings the workspace + // carries, and a failing invocation writes nothing. + const analyzed = await analyzeGraphForRead(invocation, context); + if (!analyzed.ok) { + return analyzed.exit; + } + const problem = nodeOperandProblem( + analyzed.analysis, + invocation.positionals[0], + ); + if (problem !== null) { + return usageError(invocation, context, problem); + } + + // SPEC 13.3: the gate report, then refresh-on-read, then answer. + const prepared = await finishGraphForRead( + invocation, + context, + analyzed.analysis, + ); if (!prepared.ok) { return prepared.exit; } @@ -88,6 +111,9 @@ export async function showCommand( const resolved = resolveRow(view, invocation.positionals[0]); if (!resolved.ok) { + // Defensive: on a passing workspace a spelled identity is a defined + // identity (SPEC 11.2, 12.1), so the parse-local check above passing + // means the graph resolves the node; kept total for the same exit. return usageError(invocation, context, resolved.message); } if (invocation.json) { diff --git a/src/cli/prepare.ts b/src/cli/prepare.ts index 28a57d6..0388cd5 100644 --- a/src/cli/prepare.ts +++ b/src/cli/prepare.ts @@ -22,7 +22,10 @@ import { prepareWorkspaceForAvailability, } from "../workspace/availability.js"; import type { WorkspaceAnalysis } from "../workspace/pipeline.js"; -import { prepareWorkspaceForRead } from "../workspace/refresh.js"; +import { + analyzeWorkspaceForRead, + assessWorkspaceRead, +} from "../workspace/refresh.js"; import type { Invocation } from "./args.js"; import { jsonOutputInEffect } from "./args.js"; import type { CommandContext } from "./io.js"; @@ -43,37 +46,78 @@ export type ReadPreparation = readonly exit: ExitCode; }; +/** The analyzed workspace a gated read's argument checks judge from. */ +export type ReadAnalysisPreparation = + | { readonly ok: true; readonly analysis: WorkspaceAnalysis } + | { readonly ok: false; readonly exit: ExitCode }; + +/** + * The analysis half of the SPEC 13.3 pre-answer step — a pure read, + * nothing modified, failing only with configuration-error precedence + * (SPEC 14.14, 12.0: a configuration error precedes every argument check + * that consults configuration, discovery, or the workspace). Gated reads + * whose argument checks consult discovery or the named files' parses + * (`show`'s and `query`'s identity operands, SPEC 12.0) run those checks + * against the returned analysis, then — the invocation valid — call + * `finishGraphForRead`: the checks precede the invalid-workspace report of + * 13.3, and a failing invocation writes nothing. + */ +export async function analyzeGraphForRead( + invocation: Invocation, + context: CommandContext, +): Promise<ReadAnalysisPreparation> { + const analyzed = await analyzeWorkspaceForRead(context.workspace); + if (analyzed.kind === "configuration") { + emitConfigurationErrors( + context, + jsonOutputInEffect(invocation), + context.workspace.configAnchor, + analyzed.errors, + ); + return { ok: false, exit: 2 }; + } + return { ok: true, analysis: analyzed.analysis }; +} + +/** + * The gate-and-refresh half of the SPEC 13.3 pre-answer step, over an + * analysis from `analyzeGraphForRead`: on a workspace failing `build`'s + * validations, the findings report on standard output with exit 1 and + * nothing modified; on a passing one the refresh write, then the ready + * analysis to answer from. + */ +export async function finishGraphForRead( + invocation: Invocation, + context: CommandContext, + analysis: WorkspaceAnalysis, +): Promise<ReadPreparation> { + const assessed = await assessWorkspaceRead(context.workspace, analysis); + if (assessed.kind === "findings") { + emitFindingsReport(invocation.json, context.stdout, assessed.findings); + return { ok: false, exit: 1 }; + } + await assessed.commit(); + return { ok: true, analysis, graphData: assessed.graphData }; +} + /** * SPEC 13.3: refresh-on-read, then answer. Runs the shared pre-answer step * and either hands back the fresh analysis or emits the failure — findings * report on standard output with exit 1, or configuration diagnostics on * standard error with exit 2 (SPEC 12.0) — leaving the caller to return - * the exit code unchanged. + * the exit code unchanged. The composition of `analyzeGraphForRead` and + * `finishGraphForRead` for commands whose argument checks consult nothing + * past the loaded configuration. */ export async function prepareGraphForRead( invocation: Invocation, context: CommandContext, ): Promise<ReadPreparation> { - const prepared = await prepareWorkspaceForRead(context.workspace); - switch (prepared.kind) { - case "configuration": - emitConfigurationErrors( - context, - jsonOutputInEffect(invocation), - context.workspace.configAnchor, - prepared.errors, - ); - return { ok: false, exit: 2 }; - case "findings": - emitFindingsReport(invocation.json, context.stdout, prepared.findings); - return { ok: false, exit: 1 }; - case "ready": - return { - ok: true, - analysis: prepared.analysis, - graphData: prepared.graphData, - }; + const analyzed = await analyzeGraphForRead(invocation, context); + if (!analyzed.ok) { + return analyzed; } + return finishGraphForRead(invocation, context, analyzed.analysis); } /** The analysis an availability surface answers from, or the emitted exit. */ diff --git a/src/workspace/refresh.ts b/src/workspace/refresh.ts index 16ee1cc..aa9b741 100644 --- a/src/workspace/refresh.ts +++ b/src/workspace/refresh.ts @@ -72,22 +72,56 @@ export type WorkspacePreparation = readonly errors: readonly Finding[]; }; +/** The analysis half of the pre-answer step: pure, nothing modified. */ +export type ReadAnalysis = + | { readonly kind: "analysis"; readonly analysis: WorkspaceAnalysis } + | { readonly kind: "configuration"; readonly errors: readonly Finding[] }; + /** - * The shared pre-answer step (SPEC 13.3): analyze the current workspace; - * on validation findings or configuration errors, fail without modifying - * anything; otherwise ensure the stored graph data matches the current - * sources and configuration — refreshing it if missing or mismatched, - * writing exactly what `xspec build` would write except that no TypeScript - * or Markdown is generated or removed and the recorded derived-file paths - * are left unchanged — and hand back the analysis to answer from. + * Analyze the current workspace for a gated read (SPEC 13.3) — a pure + * read, nothing consulted beyond the sources and nothing modified — + * failing only with configuration-error precedence (SPEC 14.14). The + * gated reads' argument checks that consult discovery or the named files' + * parses (SPEC 12.0: a requirement-node or graph-node identity judged + * parse-local; a session name against the session directory) run between + * this and `assessWorkspaceRead`: configuration errors precede those + * checks, the checks precede the invalid-workspace report (SPEC 12.0), + * and a failing invocation modifies nothing. */ -export async function prepareWorkspaceForRead( +export async function analyzeWorkspaceForRead( workspace: LoadedWorkspace, -): Promise<WorkspacePreparation> { +): Promise<ReadAnalysis> { const analysis = await analyzeWorkspace(workspace); if (analysis.configurationErrors.length > 0) { return { kind: "configuration", errors: analysis.configurationErrors }; } + return { kind: "analysis", analysis }; +} + +/** + * The gate-and-refresh assessment (SPEC 13.3), decision separated from + * write: `findings` is the invalid-workspace report — validation findings, + * or the refused refresh write (SPEC 14.22) — with nothing modified; + * `ready` carries the graph data the read answers beside and a `commit` + * that performs the one refresh write (a no-op when the store already + * matches). The caller commits only once every remaining argument check + * has passed, so a usage-error invocation writes nothing — and the + * decision itself never writes, so a report that must precede other + * evaluation (the corrupt-session report of 10.1 behind this gate) can be + * sequenced after it without a write having happened. + */ +export type ReadRefreshAssessment = + | { readonly kind: "findings"; readonly findings: readonly Finding[] } + | { + readonly kind: "ready"; + readonly graphData: GraphData; + readonly commit: () => Promise<void>; + }; + +export async function assessWorkspaceRead( + workspace: LoadedWorkspace, + analysis: WorkspaceAnalysis, +): Promise<ReadRefreshAssessment> { if (analysis.findings.length > 0) { // SPEC 13.3: current sources fail build validation — report, exit 1, // answer nothing, modify nothing (the store has not even been read). @@ -110,13 +144,10 @@ export async function prepareWorkspaceForRead( workspaceInputsOf(workspace, analysis), ).graphData; + const graphData = refreshedGraphData(stored.data, build); if (graphDataMatchesCurrent(stored.bytes, stored.data, build)) { - // Matching data is served as is — no write, nothing modified. - return { - kind: "ready", - analysis, - graphData: refreshedGraphData(stored.data, build), - }; + // Matching data is served as is — no write, nothing to commit. + return { kind: "ready", graphData, commit: async () => {} }; } // SPEC 14.22: the refresh writes exactly one path; a symbolic link at a @@ -130,7 +161,39 @@ export async function prepareWorkspaceForRead( return { kind: "findings", findings: writeFindings }; } - const graphData = refreshedGraphData(stored.data, build); - await writeGraphData(workspace.root, graphData); - return { kind: "ready", analysis, graphData }; + return { + kind: "ready", + graphData, + commit: () => writeGraphData(workspace.root, graphData), + }; +} + +/** + * The shared pre-answer step (SPEC 13.3): analyze the current workspace; + * on validation findings or configuration errors, fail without modifying + * anything; otherwise ensure the stored graph data matches the current + * sources and configuration — refreshing it if missing or mismatched, + * writing exactly what `xspec build` would write except that no TypeScript + * or Markdown is generated or removed and the recorded derived-file paths + * are left unchanged — and hand back the analysis to answer from. The + * composition of `analyzeWorkspaceForRead` and `assessWorkspaceRead` for + * callers whose argument checks all precede the analysis. + */ +export async function prepareWorkspaceForRead( + workspace: LoadedWorkspace, +): Promise<WorkspacePreparation> { + const analyzed = await analyzeWorkspaceForRead(workspace); + if (analyzed.kind === "configuration") { + return analyzed; + } + const assessed = await assessWorkspaceRead(workspace, analyzed.analysis); + if (assessed.kind === "findings") { + return assessed; + } + await assessed.commit(); + return { + kind: "ready", + analysis: analyzed.analysis, + graphData: assessed.graphData, + }; } diff --git a/src/workspace/reviews.ts b/src/workspace/reviews.ts index 33b58f6..9306bfa 100644 --- a/src/workspace/reviews.ts +++ b/src/workspace/reviews.ts @@ -142,6 +142,30 @@ export async function listSessionFilePaths(root: string): Promise<string[]> { return fileNames.map((entry) => `${REVIEWS_DIRECTORY}/${entry}`); } +/** + * Whether anything occupies the named session's path — existence judged + * against the session directory's exact entry names alone, no content read + * and no occupant classified (SPEC 10.1, 12.0: byte-wise, case-sensitive — + * on a case-insensitive filesystem a path lookup would reach a + * differently-cased entry, so the directory listing is the judge). The + * gated `review` subcommands' existence check (SPEC 12.0: a session name + * judged against the session directory, before the invalid-workspace + * report of 13.3) — which must read no session file, since on a failing + * workspace none is ever read (SPEC 13.3). + */ +export async function sessionOccupied( + root: string, + name: string, +): Promise<boolean> { + let entries: string[]; + try { + entries = await fsp.readdir(reviewsAbsolutePath(root)); + } catch { + return false; + } + return entries.includes(`${name}${SESSION_EXTENSION}`); +} + /** * Load one session by name (SPEC 10.1). The caller has validated the name * (an invalid name is a usage error before any lookup, SPEC 12.0). The From 90037123a6917cffb6225213d95f54d55fc6bb1b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 05:09:53 +0000 Subject: [PATCH 163/226] sdg(phase-10): sequence baseline-content validation past the read gate SPEC 6.3/12.0/13.3 (FIX_PLAN C2b): split baseline resolution into readBaseline (ref + journal prefix/replay, exit 2 before source validation) and validateBaselineContent (exit 2, only past the gate and before the refresh commit). impact --base and review create --base now report the gate's findings (exit 1) when the current workspace fails build's validations and the baseline shares them; replay judges only suffix lines, so a garbage journal line inside the shared prefix is the gate's 14.13, never a replay failure. T6.3-1..4 and T13.3-3's garbage-journal arm green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 42 ++---- src/cli/commands/impact.ts | 79 +++++++--- src/cli/commands/review.ts | 79 +++++++--- src/core/journal.ts | 73 ++++++--- src/workspace/baseline.ts | 299 +++++++++++++++++++++++++++---------- 5 files changed, 404 insertions(+), 168 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index e4003ff..005c9f5 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -313,32 +313,22 @@ values are parse-level malformed values (`identityValueProblem` in `query node`/`subtree`/`ancestors`, `edges`/`reachable` `--from`/`--to`) — reported without loading configuration.) -### C2b. A resolvable baseline over a failing workspace hits the gate, not 6.3 - -SPEC 13.3, 12.0, 6.3. Observed at T13.3-3's garbage-journal whole-gate arm: -`impact --base <commit>` where the commit *includes* the garbage journal line -(baseline journal bytes = current journal bytes) exits 2 with the 6.3 -reconstruction error ("the workspace content at baseline ref … cannot be -parsed and validated"); expected: the gate's one 14.13 finding, exit 1 — -the test's own staging comment says "baseline resolution — which precedes -the gate (SPEC 12.0) — succeeds and the gate is `impact --base`'s operative -error". TEST-SPEC T6.3-4 draws the line: an *unresolvable ref* stays exit 2 -with the baseline error even over invalid sources (the precedence arm), and -the garbage line *appended after* the baseline commit stays the exit-2 -replay failure at `impact --base` and `review create --base`; "the -resolvable-ref counterpart over invalid sources is T13.3-3's refresh -failure (exit 1)". Required: `resolveBaseline` (`src/workspace/baseline.ts`) -must not fail on baseline-content validation findings the gate would report -— sequence at `impact --base` (and `review create --base`): unresolvable -ref → exit 2; journal prefix/replay failure → exit 2 (naming the entries); -then the current-workspace gate → exit 1; baseline-content validation -failure (reachable on passing current workspaces — T6.3-4's -invalid-baseline-sources arm) → exit 2. Pin exact semantics against -T6.3-1..4 (all currently green — keep them green) and T13.3-3. - -Verify: T13.3-3's garbage-journal `impact` arm (`section-13.3.test.ts` — -its obstructed-write-path arm is C3's, its later arms C4-adjacent), -T6.3-1..4 (`section-6.3.test.ts`). +(C2b landed: baseline resolution is split around the gate — +`readBaseline` (ref resolution, tree listing, journal prefix/replay; +failures exit 2 pre-gate) and `validateBaselineContent` (baseline content +parsed and validated as a workspace; exit 2, reachable only past the gate +and before the refresh `commit()`), both `src/workspace/baseline.ts`; +`resolveBaseline` remains their composition for the post-gate +recorded-baseline call site (`loadSessionForCommand`). `impact` and +`review create` sequence: read → `analyzeGraphForRead` → +`assessWorkspaceRead` (findings exit 1) → validate content → +`commit()`. `computeJournalReplay` (`src/core/journal.ts`) now judges only +replay-suffix lines — `ParsedJournal.findings` are `PositionedJournalFinding` +(line-carrying; the extra member never renders) — so a garbage line inside +the shared prefix is the gate's 14.13, never a replay failure. T6.3-1..4 +green; T13.3-3's garbage-journal arm green — its obstructed-write-path arm +stays red on C3, and T13.3-2's corrupt-record arm on C4, as their notes +say.) ### C3. Obstructed write path: any non-directory component, refused before modifying diff --git a/src/cli/commands/impact.ts b/src/cli/commands/impact.ts index ae111a2..e663d3e 100644 --- a/src/cli/commands/impact.ts +++ b/src/cli/commands/impact.ts @@ -3,17 +3,23 @@ // // Flow (SPEC 9, 6.3, 12.0, 13.3): // -// 1. Resolve the baseline — reconstruct and validate the workspace content -// at the ref and compute the journal replay (workspace/baseline.ts). A -// baseline that cannot be read or reconstructed is a usage error, exit 2, -// and baseline resolution precedes source validation (SPEC 12.0): the -// usage error is reported even when the current sources also fail build -// validation. -// 2. Refresh-on-read of the current workspace (SPEC 13.3, cli/prepare.ts): -// validation findings report and exit 1, nothing answered. -// 3. Derive the SPEC 5.6 change categories (core/changes.ts) and the report -// content (core/impact.ts), and render it — human or `--json`, the same -// information (SPEC 12.0). +// 1. Read the baseline — resolve the ref, list the tree at it, and compute +// the journal prefix/replay (workspace/baseline.ts `readBaseline`). An +// unresolvable ref or a prefix/replay failure is a usage error, exit 2, +// preceding source validation (SPEC 12.0): reported even when the +// current sources also fail build validation. +// 2. The SPEC 13.3 gate over the current workspace (cli/prepare.ts, +// workspace/refresh.ts): validation findings report and exit 1, nothing +// answered, nothing modified. +// 3. Validate the baseline content as a workspace (`validateBaselineContent` +// — reachable only past the gate, so a baseline sharing the current +// workspace's findings is the gate's exit-1 report, never this exit-2 +// error): a baseline that cannot be parsed and validated is a usage +// error, exit 2, reported before the refresh write commits. +// 4. Commit the refresh write (a no-op when the store already matches), +// then derive the SPEC 5.6 change categories (core/changes.ts) and the +// report content (core/impact.ts), and render it — human or `--json`, +// the same information (SPEC 12.0). // // `impact` is informational: it exits 0 whether or not differences exist // (SPEC 9.3, 12.0). All output is byte-deterministic for identical input @@ -29,11 +35,16 @@ import type { ImpactRequirementReportEntry, } from "../../core/impact.js"; import { deriveImpactReport } from "../../core/impact.js"; -import { resolveBaseline } from "../../workspace/baseline.js"; +import { + readBaseline, + validateBaselineContent, +} from "../../workspace/baseline.js"; +import { assessWorkspaceRead } from "../../workspace/refresh.js"; import type { Invocation } from "../args.js"; import { flagValue } from "../args.js"; import type { CommandContext } from "../io.js"; -import { prepareGraphForRead } from "../prepare.js"; +import { analyzeGraphForRead } from "../prepare.js"; +import { emitFindingsReport } from "../report.js"; import { emitDocument, usageError } from "./common.js"; /** One impacted-code entry as JSON data (SPEC 9.3: location, the minimized @@ -129,21 +140,43 @@ export async function impactCommand( throw new Error("xspec internal error: impact without --base"); } - // SPEC 6.3/12.0: baseline resolution precedes source validation — an - // unresolvable baseline is a usage error (exit 2, stderr) even when the - // current sources also fail build validation. - const resolution = await resolveBaseline(context.workspace, ref); + // SPEC 6.3/12.0: reading the baseline — ref resolution and the journal + // prefix/replay — precedes source validation: an unresolvable ref or a + // replay failure is a usage error (exit 2, stderr) even when the current + // sources also fail build validation. + const readResolution = await readBaseline(context.workspace, ref); + if (!readResolution.ok) { + return usageError(invocation, context, readResolution.message); + } + + // SPEC 13.3/14.14: analyze the current workspace (configuration errors + // exit 2), then the gate — on a workspace failing `build`'s validations, + // the findings report alone, exit 1, nothing modified; the baseline + // content is not validated past it (module header). + const analyzed = await analyzeGraphForRead(invocation, context); + if (!analyzed.ok) { + return analyzed.exit; + } + const { analysis } = analyzed; + const assessed = await assessWorkspaceRead(context.workspace, analysis); + if (assessed.kind === "findings") { + emitFindingsReport(invocation.json, context.stdout, assessed.findings); + return 1; + } + + // SPEC 6.3/12.0: past the gate, a baseline whose content cannot be + // parsed and validated as a workspace is a usage error (exit 2) — + // reported before the refresh write commits, so a failing invocation + // modifies nothing. + const resolution = await validateBaselineContent(readResolution.read); if (!resolution.ok) { return usageError(invocation, context, resolution.message); } const { baseline } = resolution; - // SPEC 13.3: refresh-on-read, then answer. - const prepared = await prepareGraphForRead(invocation, context); - if (!prepared.ok) { - return prepared.exit; - } - const { analysis } = prepared; + // SPEC 13.3: the one refresh write (a no-op when the store already + // matches), every check passed; then answer. + await assessed.commit(); // SPEC 9: compare the current workspace graph against the baseline graph, // identities mapped through the journal (SPEC 6.3, 5.4) — each side's diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index 51f441a..19a6841 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -10,14 +10,19 @@ // 2. `--coverage`: the named profile must be configured (SPEC 10.7 → // 12.0 "unknown profiles named in arguments", exit 2) — a // configuration-level check preceding source analysis; -// 3. `--base`: baseline resolution (SPEC 6.3 → 12.0, exit 2) — precedes -// source validation; -// 4. refresh-on-read (SPEC 13.3): invalid sources report the validation -// errors, exit 1, nothing created; -// 5. an existing session name — matched ignoring ASCII case (SPEC 10.1) +// 3. `--base`: read the baseline — ref resolution and the journal +// prefix/replay (SPEC 6.3 → 12.0, exit 2) — preceding source +// validation; +// 4. the SPEC 13.3 gate: invalid sources report the validation errors, +// exit 1, nothing created; +// 5. `--base`: validate the baseline content as a workspace (SPEC 6.3 → +// 12.0, exit 2) — past the gate, so a baseline sharing the current +// workspace's findings is the gate's exit-1 report — then commit the +// refresh write (a no-op when the store already matches); +// 6. an existing session name — matched ignoring ASCII case (SPEC 10.1) // — is refused, exit 1, nothing created (SPEC 10.7); an exact-name // corrupt occupant reports the corruption instead (SPEC 10.1, 14.21); -// 6. derive the items (SPEC 10.5–10.7), validate the write path +// 7. derive the items (SPEC 10.5–10.7), validate the write path // (SPEC 14.22), and write the session file (SPEC 10.1, 13.4). // // `list` (read) — refresh-on-read, then every session in byte order of @@ -52,9 +57,16 @@ import { expandDecompositions, } from "../../core/review-derive.js"; import { spellingOfReference } from "../../core/review-state.js"; -import type { ResolvedBaseline } from "../../workspace/baseline.js"; -import { resolveBaseline } from "../../workspace/baseline.js"; +import type { + BaselineRead, + ResolvedBaseline, +} from "../../workspace/baseline.js"; +import { + readBaseline, + validateBaselineContent, +} from "../../workspace/baseline.js"; import { withMutationExclusivity } from "../../workspace/lock.js"; +import { assessWorkspaceRead } from "../../workspace/refresh.js"; import { listSessionNames, loadAllSessions, @@ -65,7 +77,7 @@ import { symlinkWritePathFindings } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; import { flagValue } from "../args.js"; import type { CommandContext } from "../io.js"; -import { prepareGraphForRead } from "../prepare.js"; +import { analyzeGraphForRead, prepareGraphForRead } from "../prepare.js"; import { emitFindingsReport } from "../report.js"; import { emitDocument, testHoldSpecOf, usageError } from "./common.js"; import { @@ -105,7 +117,7 @@ async function runCreate( const baseRef = flagValue(invocation, "--base"); const profileName = flagValue(invocation, "--coverage"); let parameters: SessionParameters; - let baseline: ResolvedBaseline | undefined; + let baselineRead: BaselineRead | undefined; if (profileName !== undefined) { // SPEC 10.7 → 12.0: an unknown profile named in arguments is a usage // error — a configuration-level check preceding source analysis, as @@ -126,33 +138,56 @@ async function runCreate( profile: recordCoverageProfile(workspace.configuration, profile), }; } else if (baseRef !== undefined) { - // SPEC 6.3 → 12.0: baseline resolution precedes source validation — an - // unresolvable baseline is a usage error (exit 2), nothing modified, + // SPEC 6.3 → 12.0: reading the baseline — ref resolution and the + // journal prefix/replay — precedes source validation: an unresolvable + // ref or a replay failure is a usage error (exit 2), nothing modified, // even when the current sources also fail build validation. - const resolution = await resolveBaseline(workspace, baseRef); + const resolution = await readBaseline(workspace, baseRef); if (!resolution.ok) { return usageError(invocation, context, resolution.message); } - baseline = resolution.baseline; + baselineRead = resolution.read; // SPEC 10.7: a baseline session records the commit identity `--base` // resolved to at creation, never the ref spelling. parameters = { strategy: "path-blocks", - baseCommit: resolution.baseline.commit, + baseCommit: resolution.read.commit, }; } else { // SPEC 10.7: an audit session records no creation parameters. parameters = { strategy: "audit" }; } - // SPEC 13.3: refresh-on-read — with invalid sources, report the - // validation errors, exit 1, nothing created (a `review` subcommand like - // any other). - const prepared = await prepareGraphForRead(invocation, context); - if (!prepared.ok) { - return prepared.exit; + // SPEC 13.3/14.14: analyze the current workspace (configuration errors + // exit 2), then the gate — with invalid sources, report the validation + // errors, exit 1, nothing created (a `review` subcommand like any other); + // the baseline content is not validated past it (module header). + const analyzed = await analyzeGraphForRead(invocation, context); + if (!analyzed.ok) { + return analyzed.exit; + } + const { analysis } = analyzed; + const assessed = await assessWorkspaceRead(workspace, analysis); + if (assessed.kind === "findings") { + emitFindingsReport(invocation.json, stdout, assessed.findings); + return 1; } - const { analysis } = prepared; + + // SPEC 6.3 → 12.0: past the gate, a baseline whose content cannot be + // parsed and validated as a workspace is a usage error (exit 2) — + // reported before the refresh write commits, so nothing is modified. + let baseline: ResolvedBaseline | undefined; + if (baselineRead !== undefined) { + const resolution = await validateBaselineContent(baselineRead); + if (!resolution.ok) { + return usageError(invocation, context, resolution.message); + } + baseline = resolution.baseline; + } + + // SPEC 13.3: the one refresh write (a no-op when the store already + // matches), every pre-creation check of the workspace passed. + await assessed.commit(); // SPEC 10.1/10.7: `create` with the name of an existing session is // refused (exit 1, nothing created); a name matching an existing diff --git a/src/core/journal.ts b/src/core/journal.ts index bc3fc27..c998cc2 100644 --- a/src/core/journal.ts +++ b/src/core/journal.ts @@ -91,12 +91,26 @@ export interface PositionedJournalEntry extends JournalEntry { readonly range: ByteRange; } +/** + * A journal parse finding positioned at its offending line. A plain Finding + * everywhere findings flow (the extra member never renders — the JSON form + * extracts the 12.7 members explicitly); the line carries the prefix/suffix + * partition of baseline replay (SPEC 6.3): a malformed line within the + * baseline prefix is the workspace content's own 14.13 — reported by the + * SPEC 13.3 gate on the current side, or by baseline-content validation — + * while one in the replay suffix makes the mapping unresolvable. + */ +export interface PositionedJournalFinding extends Finding { + /** 1-based journal line number of the offending line. */ + readonly line: number; +} + /** The result of parsing a journal file's bytes. */ export interface ParsedJournal { /** The entries of the lines that parsed and validated, in file order. */ readonly entries: readonly PositionedJournalEntry[]; /** One 14.13 finding per malformed, conflicting, or non-canonical line. */ - readonly findings: readonly Finding[]; + readonly findings: readonly PositionedJournalFinding[]; } /** @@ -184,7 +198,7 @@ const decoder = new TextDecoder(); */ export function parseJournal(bytes: Uint8Array): ParsedJournal { const entries: PositionedJournalEntry[] = []; - const findings: Finding[] = []; + const findings: PositionedJournalFinding[] = []; let offset = 0; let line = 0; while (offset < bytes.length) { @@ -204,18 +218,24 @@ export function parseJournal(bytes: Uint8Array): ParsedJournal { } /** One 14.13 finding for a bad journal line, naming the line (SPEC 14.13). */ -function journalFinding(line: number, problem: string): Finding { +function journalFinding( + line: number, + problem: string, +): PositionedJournalFinding { // SPEC 14: a journal condition carries the path it concerns, not an // in-source location; the offending line is named in the message. - return pathFinding( - 13, - `journal error: the entry on line ${String(line)} of ${JOURNAL_PATH} ` + - `${problem} — the journal is a durable, append-only record written ` + - `only by \`xspec rename\` and \`xspec move\` (SPEC 6.1, 13.4); ` + - `restore it from version control or delete the offending line ` + - `(SPEC 14.13)`, - JOURNAL_PATH, - ); + return { + ...pathFinding( + 13, + `journal error: the entry on line ${String(line)} of ${JOURNAL_PATH} ` + + `${problem} — the journal is a durable, append-only record written ` + + `only by \`xspec rename\` and \`xspec move\` (SPEC 6.1, 13.4); ` + + `restore it from version control or delete the offending line ` + + `(SPEC 14.13)`, + JOURNAL_PATH, + ), + line, + }; } type LineResult = @@ -824,10 +844,16 @@ export type JournalReplayResult = * append-only, SPEC 6.1) — and the entries beyond that prefix are the * replay, applied in file order with chained mappings composing. * - * Callers validate the baseline journal first (a baseline whose journal has - * malformed lines fails workspace validation, 14.13, before replay is ever - * computed); with the prefix holding, any malformed current line therefore - * lies in the replay suffix and makes the mapping unresolvable. + * Replay judges only the lines it applies: a malformed line in the replay + * suffix makes the mapping unresolvable (the failure names it), while a + * malformed line within the shared prefix — present identically on both + * sides, so nothing of it is replayed — is not a replay failure. Such a + * line is the workspace content's own journal error (14.13), on both sides + * at once: callers sequence replay before baseline-content validation + * (workspace/baseline.ts), and the SPEC 13.3 gate reports the current + * side's finding first (SPEC 12.0 — replay failures precede the gate, the + * gate precedes baseline-content validation), so the prefix's 14.13 is the + * gate's exit-1 report, never an exit-2 resolution error. */ export function computeJournalReplay( baselineBytes: Uint8Array, @@ -868,17 +894,22 @@ export function computeJournalReplay( }; } } - // SPEC 6.3: replay is unresolvable when the entries to apply cannot be - // parsed and validated — the findings name the offending lines (14.13's - // message form, reused here as the naming duty's carrier). + // SPEC 6.3: replay is unresolvable when the entries to apply — the lines + // beyond the baseline prefix — cannot be parsed and validated; the + // findings name the offending lines (14.13's message form, reused here as + // the naming duty's carrier). Malformed lines within the prefix are not + // replayed and not judged here (module comment above). const parsed = parseJournal(currentBytes); - if (parsed.findings.length > 0) { + const suffixFindings = parsed.findings.filter( + (finding) => finding.line > baselineLines.length, + ); + if (suffixFindings.length > 0) { return { ok: false, problem: `replaying the journal entries absent at the baseline ref ` + `produced no resolvable mapping — ` + - parsed.findings.map((finding) => finding.message).join("; "), + suffixFindings.map((finding) => finding.message).join("; "), }; } return { diff --git a/src/workspace/baseline.ts b/src/workspace/baseline.ts index 226dd59..71d83a7 100644 --- a/src/workspace/baseline.ts +++ b/src/workspace/baseline.ts @@ -14,11 +14,31 @@ // or if the baseline content cannot be parsed and validated as a // workspace, resolution fails with an actionable error naming the // offending entries or files; a baseline that cannot be read or -// reconstructed is a usage error — exit 2 (SPEC 12.0). Baseline resolution -// precedes source validation (SPEC 12.0): callers resolve the baseline -// before analyzing the current sources, so an unresolvable baseline is -// reported as a usage error even when the current sources also fail build -// validation. +// reconstructed is a usage error — exit 2 (SPEC 12.0). +// +// Resolution is split in two, sequenced around the SPEC 13.3 gate by the +// baseline-taking commands (`impact --base`, `review create --base`): +// +// - `readBaseline` — everything up to and including the journal +// prefix/replay: locate the workspace in its repository, resolve the ref, +// list the tree at it, read the configuration and journal blobs, and +// compute the replay against the current journal. Its failures precede +// source validation of the current workspace (SPEC 12.0): an unresolvable +// ref or a prefix/replay failure is reported as a usage error (exit 2) +// even when the current workspace also fails `build`'s validations. +// - `validateBaselineContent` — parse and validate the baseline content as +// a workspace (configuration, sources, journal findings). The callers run +// it only past the gate: on a current workspace failing `build`'s +// validations the gate's findings report first (exit 1) — a baseline +// whose own findings the gate would report (the shared-journal case: +// baseline journal bytes = current journal bytes) is therefore never an +// exit-2 resolution error — while on a passing current workspace a +// baseline that cannot be parsed and validated stays the usage error of +// SPEC 6.3 (exit 2), reported before the refresh write commits. +// +// `resolveBaseline` composes the two for the post-gate call site (a +// session's recorded baseline, review-session.ts, where the gate has +// already passed). // // IMPLEMENTATION (Key libraries, Architecture): the system `git` // executable via read-only plumbing subcommands only — `rev-parse`, @@ -38,6 +58,7 @@ import { computeJournalReplay, JOURNAL_PATH } from "../core/journal.js"; import type { LoadedWorkspace } from "./config.js"; import { parseConfigurationBytes } from "./config.js"; import { runGit } from "./git.js"; +import type { LoadedJournal } from "./journal.js"; import { journalFromBytes, occupiedJournal } from "./journal.js"; import type { WorkspaceAnalysis } from "./pipeline.js"; import { analyzeWorkspaceContent } from "./pipeline.js"; @@ -71,13 +92,64 @@ export type BaselineResolution = * SPEC 6.3 → 12.0: the baseline cannot be read or reconstructed — a * usage error. `message` is the actionable diagnostic naming the * offending entries or files; callers report it on standard error - * and exit 2, before source validation of the current workspace. + * and exit 2. + */ + readonly ok: false; + readonly message: string; + }; + +/** + * A read baseline (SPEC 6.3): the ref resolved to a commit, the workspace + * tree at it listed, and the journal prefix/replay computed — everything + * of baseline resolution except the content's parse and validation, which + * `validateBaselineContent` performs on this value past the SPEC 13.3 + * gate (module header). + */ +export interface BaselineRead { + /** The full hash of the commit the ref resolved to. */ + readonly commit: string; + /** SPEC 6.3: the replay Journal (see `ResolvedBaseline.replay`). */ + readonly replay: Journal; + /** The content-validation continuation's inputs (internal to this + * module — consumed by `validateBaselineContent` verbatim). */ + readonly content: BaselineContentInputs; +} + +/** What `validateBaselineContent` consumes — read once by `readBaseline` + * so the content stage re-reads nothing but the source blobs. */ +export interface BaselineContentInputs { + readonly ref: string; + readonly root: string; + readonly configFileName: string; + /** The configuration blob's bytes at the ref — undefined when no regular + * file occupies the configuration path there (`configIrregular` says + * whether a non-regular tree entry does). */ + readonly configBytes: Uint8Array | undefined; + readonly configIrregular: boolean; + /** Every regular file at the ref, as raw path bytes. */ + readonly files: readonly Buffer[]; + /** Blob object name per file, keyed by `byteKey` of the path bytes. */ + readonly oidByPath: ReadonlyMap<string, string>; + /** The journal content at the ref as a loaded journal (absent = empty; + * a non-plain occupant carries its 14.13 finding, never read). */ + readonly journal: LoadedJournal; +} + +/** The outcome of the read half of baseline resolution (SPEC 6.3). */ +export type BaselineReadResolution = + | { readonly ok: true; readonly read: BaselineRead } + | { + /** + * SPEC 6.3 → 12.0: the baseline cannot be read, or the journal + * prefix/replay fails — a usage error naming the offending entries + * or files, preceding source validation of the current workspace + * (SPEC 12.0): callers report it and exit 2 before the gate. */ readonly ok: false; readonly message: string; }; -function failure(message: string): BaselineResolution { +function failure(message: string): { ok: false; message: string } { return { ok: false, message }; } @@ -197,22 +269,33 @@ function invalidBaselineMessage( ); } +/** The blob-read failure message (repository corruption, shallow clone). */ +function unreadableMessage(ref: string): string { + return ( + `the workspace content at baseline ref '${ref}' cannot be read from ` + + `the repository — git object reads failed; the repository may be ` + + `corrupt or a shallow clone missing the ref's objects (SPEC 6.3)` + ); +} + /** - * Resolve a baseline git ref (SPEC 6.3): reconstruct and validate the - * workspace content as it stood at the ref — sources, configuration, and - * journal — and compute the replay mapping from baseline identities to - * current identities. Reads the repository through read-only git plumbing - * and the current journal from the filesystem; modifies nothing. + * The read half of baseline resolution (SPEC 6.3): resolve the ref to a + * commit, list the workspace tree at it, read the configuration and + * journal blobs, and compute the journal prefix/replay against the current + * journal. Reads the repository through read-only git plumbing and the + * current journal from the filesystem; modifies nothing, and parses no + * baseline source content — `validateBaselineContent` does, past the + * SPEC 13.3 gate (module header). * - * Callers run this before analyzing the current sources: baseline - * resolution precedes source validation (SPEC 12.0), and every failure - * here is a usage error (exit 2) with `message` as the standard-error - * diagnostic. + * Callers run this before analyzing the current sources: these failures + * precede source validation (SPEC 12.0), each a usage error (exit 2) with + * `message` as the standard-error diagnostic — even when the current + * workspace also fails `build`'s validations. */ -export async function resolveBaseline( +export async function readBaseline( workspace: LoadedWorkspace, ref: string, -): Promise<BaselineResolution> { +): Promise<BaselineReadResolution> { const { root, configFileName } = workspace; // --- locate the workspace within its repository ----------------------- @@ -289,7 +372,7 @@ export async function resolveBaseline( ); } - // --- classify the ref's files (SPEC 6.3: configuration at the ref) ---- + // --- classify the ref's tree entries ---------------------------------- const entries = parseTreeListing(listing.stdout); const configPathBytes = Buffer.from(configFileName, "utf8"); const journalPathBytes = Buffer.from(JOURNAL_PATH, "utf8"); @@ -323,7 +406,105 @@ export async function resolveBaseline( } } - if (configOid === undefined) { + // A missing or irregular configuration at the ref is a fact about the + // baseline content's validity as a workspace, not about reading the ref + // — `validateBaselineContent` reports it (module header). + + const primer = await readBlobs(root, [ + ...(configOid === undefined ? [] : [configOid]), + ...(journalOid === undefined ? [] : [journalOid]), + ]); + if (primer === null) return failure(unreadableMessage(ref)); + const configBytes = + configOid === undefined ? undefined : primer.get(configOid); + if (configOid !== undefined && configBytes === undefined) { + return failure(unreadableMessage(ref)); + } + + // SPEC 6.3: the journal content at the ref; absent = empty journal. + const baselineJournalBytes = + journalOid === undefined ? null : (primer.get(journalOid) ?? null); + if (journalOid !== undefined && baselineJournalBytes === null) { + return failure(unreadableMessage(ref)); + } + const journal = + journalOccupant !== undefined + ? occupiedJournal(journalOccupant) + : journalFromBytes(baselineJournalBytes); + + // --- replay: current journal entries absent at the ref (SPEC 6.3) ----- + const currentJournalAbsolute = path.join(root, ".xspec", "journal"); + const occupant = await classifyOccupant(currentJournalAbsolute); + let currentJournalBytes: Uint8Array; + if (occupant === "absent") { + // SPEC 6.3: a journal file absent in the current workspace is read as + // an empty journal. + currentJournalBytes = new Uint8Array(0); + } else if (occupant === "file") { + currentJournalBytes = await fsp.readFile(currentJournalAbsolute); + } else { + return failure( + `the current journal ${JOURNAL_PATH} is occupied by ` + + `${describeOccupant(occupant)}, not a plain file — the journal ` + + `entries appended since baseline ref '${ref}' cannot be read for ` + + `replay (SPEC 6.1, 6.3, 13.4)`, + ); + } + const replay = computeJournalReplay( + baselineJournalBytes ?? new Uint8Array(0), + currentJournalBytes, + ); + if (!replay.ok) { + return failure( + `cannot map baseline identities at ref '${ref}' to current ` + + `identities: ${replay.problem}`, + ); + } + + return { + ok: true, + read: { + commit, + replay: replay.replay, + content: { + ref, + root, + configFileName, + configBytes, + configIrregular, + files, + oidByPath, + journal, + }, + }, + }; +} + +/** + * The validation half of baseline resolution (SPEC 6.3): parse and + * validate the read baseline's content as a workspace — configuration, + * sources, and journal findings, through the same pure core the current + * workspace's pipeline composes. A baseline that cannot be parsed and + * validated is a usage error (exit 2, SPEC 12.0); callers run this only + * past the SPEC 13.3 gate and before the refresh write commits, so a + * failing current workspace reports the gate's findings instead (module + * header) and a failing invocation modifies nothing. + */ +export async function validateBaselineContent( + read: BaselineRead, +): Promise<BaselineResolution> { + const { + ref, + root, + configFileName, + configBytes, + configIrregular, + files, + oidByPath, + journal, + } = read.content; + + if (configBytes === undefined) { return failure( configIrregular ? `the configuration file '${configFileName}' is not a regular ` + @@ -336,20 +517,6 @@ export async function resolveBaseline( ); } - const unreadable = failure( - `the workspace content at baseline ref '${ref}' cannot be read from ` + - `the repository — git object reads failed; the repository may be ` + - `corrupt or a shallow clone missing the ref's objects (SPEC 6.3)`, - ); - - const primer = await readBlobs(root, [ - configOid, - ...(journalOid === undefined ? [] : [journalOid]), - ]); - if (primer === null) return unreadable; - const configBytes = primer.get(configOid); - if (configBytes === undefined) return unreadable; - // SPEC 6.3: the baseline configuration is the configuration content at // the ref — group membership reflects it, not the current configuration. const configParse = parseConfigurationBytes(configBytes, configFileName); @@ -358,17 +525,6 @@ export async function resolveBaseline( } const classification = classifySources(files, configParse.configuration); - // SPEC 6.3: the journal content at the ref; absent = empty journal. - const baselineJournalBytes = - journalOid === undefined ? null : (primer.get(journalOid) ?? null); - if (journalOid !== undefined && baselineJournalBytes === null) { - return unreadable; - } - const journal = - journalOccupant !== undefined - ? occupiedJournal(journalOccupant) - : journalFromBytes(baselineJournalBytes); - // --- analyze the baseline workspace (the shared pipeline body) -------- const sourcePaths = [ ...classification.specSources, @@ -403,7 +559,7 @@ export async function resolveBaseline( ...oidForSource.values(), ...oidForInvalidSource.values(), ]); - if (sourceBlobs === null) return unreadable; + if (sourceBlobs === null) return failure(unreadableMessage(ref)); const analysis = await analyzeWorkspaceContent(configParse.configuration, { classification, @@ -439,37 +595,28 @@ export async function resolveBaseline( return failure(invalidBaselineMessage(ref, analysis.findings)); } - // --- replay: current journal entries absent at the ref (SPEC 6.3) ----- - const currentJournalAbsolute = path.join(root, ".xspec", "journal"); - const occupant = await classifyOccupant(currentJournalAbsolute); - let currentJournalBytes: Uint8Array; - if (occupant === "absent") { - // SPEC 6.3: a journal file absent in the current workspace is read as - // an empty journal. - currentJournalBytes = new Uint8Array(0); - } else if (occupant === "file") { - currentJournalBytes = await fsp.readFile(currentJournalAbsolute); - } else { - return failure( - `the current journal ${JOURNAL_PATH} is occupied by ` + - `${describeOccupant(occupant)}, not a plain file — the journal ` + - `entries appended since baseline ref '${ref}' cannot be read for ` + - `replay (SPEC 6.1, 6.3, 13.4)`, - ); - } - const replay = computeJournalReplay( - baselineJournalBytes ?? new Uint8Array(0), - currentJournalBytes, - ); - if (!replay.ok) { - return failure( - `cannot map baseline identities at ref '${ref}' to current ` + - `identities: ${replay.problem}`, - ); - } - return { ok: true, - baseline: { commit, analysis, replay: replay.replay }, + baseline: { commit: read.commit, analysis, replay: read.replay }, }; } + +/** + * Resolve a baseline git ref whole (SPEC 6.3): `readBaseline` composed + * with `validateBaselineContent`. For the post-gate call site — a + * session's recorded baseline (review-session.ts), resolved after the + * SPEC 13.3 gate has passed — every failure a usage error (exit 2) with + * `message` as the standard-error diagnostic. The baseline-taking commands + * (`impact --base`, `review create --base`) call the halves separately, + * sequencing the gate between them (module header). + */ +export async function resolveBaseline( + workspace: LoadedWorkspace, + ref: string, +): Promise<BaselineResolution> { + const read = await readBaseline(workspace, ref); + if (!read.ok) { + return read; + } + return validateBaselineContent(read.read); +} From ca2afce913defae0cbc5d0b24ab886798d93f64e Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 05:45:47 +0000 Subject: [PATCH 164/226] sdg(phase-10): refuse any non-directory write-path component before modifying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC 13.4/14.22: an obstructed write path is any workspace-relative directory component occupied by a non-directory — plain file, symlink, or other — one condition-22 finding per distinct offending component, concerned path the component. classifyOccupant maps ENOTDIR/ELOOP to absent so per-file staleness and removals never crash below an obstructed component. The 13.3 gate (assessWorkspaceRead) now evaluates build's full write set before any store read, and the query/at store fast path verifies the write set unobstructed, falling back to the full path otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 58 ++++------- src/cli/commands/build.ts | 15 ++- src/cli/commands/check.ts | 14 ++- src/cli/commands/move.ts | 6 +- src/cli/commands/rename.ts | 4 +- src/cli/commands/review-mutate.ts | 11 ++- src/cli/commands/review.ts | 9 +- src/workspace/availability.ts | 4 +- src/workspace/build.ts | 2 +- src/workspace/fast-read.ts | 28 +++++- src/workspace/graph-data.ts | 2 +- src/workspace/journal.ts | 7 +- src/workspace/refresh.ts | 63 ++++++------ src/workspace/writes.ts | 155 +++++++++++++++++------------- 14 files changed, 217 insertions(+), 161 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 005c9f5..77e2d66 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -224,8 +224,8 @@ byte shape alone, total over invalid paths), `journalOccupied(root)` in `src/workspace/journal.ts` (presence alone, lstat), `listSessionFilePaths` in `src/workspace/reviews.ts` (well-formed session file names by name alone, byte order of file name), and the discovery-level 14.14 exit-2 routing -inside the handler. T11.6-1..4 green on Linux; T12.2-2/3, T13.3-2/3, T14-4 -inventory-adjacent arms stay red on C4/C5/C2 defects as their notes say.) +inside the handler. T11.6-1..4 green on Linux; T12.2-2/3 and T13.3-2's +corrupt-record arms stay red on C4/C5 defects as their notes say.) (B8 landed: the refusal contract. `src/core/refusal.ts` is the one shared evaluation — `evaluateRenameRefusals` / `evaluateMoveFileRefusals` / @@ -326,41 +326,25 @@ recorded-baseline call site (`loadSessionForCommand`). `impact` and replay-suffix lines — `ParsedJournal.findings` are `PositionedJournalFinding` (line-carrying; the extra member never renders) — so a garbage line inside the shared prefix is the gate's 14.13, never a replay failure. T6.3-1..4 -green; T13.3-3's garbage-journal arm green — its obstructed-write-path arm -stays red on C3, and T13.3-2's corrupt-record arm on C4, as their notes -say.) - -### C3. Obstructed write path: any non-directory component, refused before modifying - -SPEC 13.4, 14.22. Prereq A1 (token `obstructed-write-path`). -`symlinkComponentOf` (`src/workspace/writes.ts` ~108) detects symlink components -only; a plain-file component flows through — `build` modifies files, then -crashes ENOTDIR exit 70. Required: a workspace-relative directory component of -any path xspec writes occupied by anything other than a directory (plain file, -symlink whatever it targets, any non-directory) refuses the write, reported as -condition 22 before anything is modified; `check` reports it without writing. -One finding per distinct offending component, concerned path the component's -workspace-relative path, however many write paths it refuses. An occupant at a -derived file's own path stays a replacement, not an error; a durable file's own -path holding a non-plain-file stays 14.13/14.21; a move's destination-side -component stays `refused-invalid-destination` (B8), never condition 22. - -Same condition, gate side (observed at T13.3-3's obstructed-write-path arm, -`markdown.outDir` replaced by a plain file): the gated reads' 13.3 gate is -"the findings a `build` would now report", 14.22 over build's FULL write set -included — today `assessWorkspaceRead` (`src/workspace/refresh.ts`) probes -only the graph-data path, and only on a store mismatch, so `ids` et al. -answer exit 0 where T13.3-3 expects the one condition-22 finding, exit 1. -`finishAvailabilityRefresh` (`src/workspace/availability.ts`) already -evaluates build's `writePaths` — mirror that in `assessWorkspaceRead` -(evaluation only; the reads still write nothing on the failing side). Note -`classifyOccupant` currently throws raw ENOTDIR when a parent component is a -plain file (`build` crashes exit 70, T11.2-6's observed failure) — the -writes.ts fix must classify that as the obstructed component, not crash. - -Verify: P-8 (`section-16-p8.test.ts` or the P-8 registry file), T13.4 arms -(`section-13.4*.test.ts`), T13.3-3's obstructed-write-path arms, T11.2-6 -(`section-11.2.test.ts`), T14-4's 14.13/14.22 reporter rows. +green; T13.3-3 green in full since C3; T13.3-2's corrupt-record arm stays +red on C4, as its note says.) + +(C3 landed: 14.22 covers every non-directory component. +`obstructedWritePathFindings`/`obstructedComponentOf` +(`src/workspace/writes.ts`) judge each write path's components +shallowest-first, stopping at the first non-directory or missing one; one +finding per distinct offending component, concerned path the component, +findings in component byte order. `classifyOccupant` maps ENOTDIR/ELOOP to +"absent" (nothing occupies a path below a non-directory — `probeOccupant` +is now its alias), so `check`'s per-file staleness compare reports the +unreachable derived file "missing" instead of crashing; the removal helpers +skip any obstructed path. Gate side: `assessWorkspaceRead` +(`src/workspace/refresh.ts`) computes build with a null store and evaluates +the FULL `build.writePaths` before any store read — obstructions are the +gate's findings, exit 1, store unconsulted; the store-backed fast paths +(`verifyStoreForRead`, `src/workspace/fast-read.ts`, shared by `query` and +`at`) verify the write set unobstructed as step 5 (`generatedDerivedPaths` ++ `GRAPH_DATA_PATH`), falling back to the full path otherwise.) ### C4. Unreadable recorded state persists; `check` reports the exclusive unit form diff --git a/src/cli/commands/build.ts b/src/cli/commands/build.ts index 0bee64f..0b62b3c 100644 --- a/src/cli/commands/build.ts +++ b/src/cli/commands/build.ts @@ -22,7 +22,7 @@ import { analyzeWorkspace, workspaceInputsOf, } from "../../workspace/pipeline.js"; -import { symlinkWritePathFindings } from "../../workspace/writes.js"; +import { obstructedWritePathFindings } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; import { jsonOutputInEffect } from "../args.js"; import type { CommandContext } from "../io.js"; @@ -55,9 +55,11 @@ export async function buildCommand( let outputs: BuildOutputs | null = null; if (findings.length === 0) { // Valid workspace: derive the complete output set (core), then validate - // every write path before touching anything (SPEC 14.22: a symbolic - // link at a workspace-relative directory component of a path xspec - // writes refuses the write, reported before anything is modified). + // every write path before touching anything (SPEC 14.22: a + // workspace-relative directory component of a path xspec writes + // occupied by anything other than a directory refuses the write, + // reported before anything is modified — one finding per distinct + // offending component). const stored = await loadGraphData(workspace.root); outputs = computeBuildOutputs( workspace.configuration, @@ -69,7 +71,10 @@ export async function buildCommand( workspaceInputsOf(workspace, analysis), ); findings.push( - ...(await symlinkWritePathFindings(workspace.root, outputs.writePaths)), + ...(await obstructedWritePathFindings( + workspace.root, + outputs.writePaths, + )), ); } diff --git a/src/cli/commands/check.ts b/src/cli/commands/check.ts index 4a2623c..86a63b4 100644 --- a/src/cli/commands/check.ts +++ b/src/cli/commands/check.ts @@ -16,7 +16,8 @@ // core/policy.ts); // - review sessions are not internally corrupt (SPEC 14.21, judged without // modifying anything; workspace/reviews.ts); -// - write paths a build would use traverse no symbolic link — reported +// - no write path a build would use has a workspace-relative directory +// component occupied by anything other than a directory — reported // without writing (SPEC 14.22). // // `check` never refreshes (SPEC 13.3): it reports staleness instead of @@ -35,7 +36,7 @@ import { workspaceInputsOf, } from "../../workspace/pipeline.js"; import { loadAllSessions } from "../../workspace/reviews.js"; -import { symlinkWritePathFindings } from "../../workspace/writes.js"; +import { obstructedWritePathFindings } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; import { jsonOutputInEffect } from "../args.js"; import type { CommandContext } from "../io.js"; @@ -84,10 +85,13 @@ export async function checkCommand( findings.push( ...(await stalenessFindings(workspace.root, outputs, stored)), ); - // SPEC 14.22: `check` reports a symbolic link in a write path without - // writing — the same findings a `build` would refuse on. + // SPEC 14.22: `check` reports the obstructed write-path components + // without writing — the same findings a `build` would refuse on. findings.push( - ...(await symlinkWritePathFindings(workspace.root, outputs.writePaths)), + ...(await obstructedWritePathFindings( + workspace.root, + outputs.writePaths, + )), ); } diff --git a/src/cli/commands/move.ts b/src/cli/commands/move.ts index 2ddc166..10fc14f 100644 --- a/src/cli/commands/move.ts +++ b/src/cli/commands/move.ts @@ -114,7 +114,7 @@ import { nonDirectoryComponents, probeOccupant, removeSourceFile, - symlinkWritePathFindings, + obstructedWritePathFindings, writeSourceFile, } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; @@ -484,7 +484,7 @@ async function runMoveFile( // SPEC 14.22: validate the complete write set — rewritten sources (the // destination included), the journal, and every regenerated file — before // modifying anything. - const writeFindings = await symlinkWritePathFindings(workspace.root, [ + const writeFindings = await obstructedWritePathFindings(workspace.root, [ ...plan.rewrites.map((rewrite) => rewrite.path), JOURNAL_PATH, ...outputs.writePaths, @@ -717,7 +717,7 @@ async function runMoveSection( // SPEC 14.22: validate the complete write set — rewritten sources (a // created target included), the journal, and every regenerated file — // before modifying anything. - const writeFindings = await symlinkWritePathFindings(workspace.root, [ + const writeFindings = await obstructedWritePathFindings(workspace.root, [ ...plan.rewrites.map((rewrite) => rewrite.path), JOURNAL_PATH, ...outputs.writePaths, diff --git a/src/cli/commands/rename.ts b/src/cli/commands/rename.ts index 838cef1..b75aebe 100644 --- a/src/cli/commands/rename.ts +++ b/src/cli/commands/rename.ts @@ -68,7 +68,7 @@ import { workspaceInputsOf, } from "../../workspace/pipeline.js"; import { - symlinkWritePathFindings, + obstructedWritePathFindings, writeSourceFile, } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; @@ -280,7 +280,7 @@ async function runRename( // SPEC 14.22: validate the complete write set — rewritten sources, the // journal, and every regenerated file — before modifying anything. - const writeFindings = await symlinkWritePathFindings(workspace.root, [ + const writeFindings = await obstructedWritePathFindings(workspace.root, [ ...plan.rewrites.map((rewrite) => rewrite.path), JOURNAL_PATH, ...outputs.writePaths, diff --git a/src/cli/commands/review-mutate.ts b/src/cli/commands/review-mutate.ts index 192bcd0..382da02 100644 --- a/src/cli/commands/review-mutate.ts +++ b/src/cli/commands/review-mutate.ts @@ -43,7 +43,7 @@ import { import { withMutationExclusivity } from "../../workspace/lock.js"; import type { WorkspaceAnalysis } from "../../workspace/pipeline.js"; import { writeSession } from "../../workspace/reviews.js"; -import { symlinkWritePathFindings } from "../../workspace/writes.js"; +import { obstructedWritePathFindings } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; import { flagValue } from "../args.js"; import type { CommandContext } from "../io.js"; @@ -93,9 +93,10 @@ function currentSideOf( } /** - * SPEC 14.22: validate the session file's write path — a symbolic link at a - * workspace-relative directory component refuses the write, reported before - * modifying anything — then write the session. Returns null on success. + * SPEC 14.22: validate the session file's write path — a + * workspace-relative directory component occupied by anything other than a + * directory refuses the write, reported before modifying anything — then + * write the session. Returns null on success. */ async function writeSessionChecked( invocation: Invocation, @@ -103,7 +104,7 @@ async function writeSessionChecked( name: string, session: Parameters<typeof writeSession>[2], ): Promise<ExitCode | null> { - const findings = await symlinkWritePathFindings(context.workspace.root, [ + const findings = await obstructedWritePathFindings(context.workspace.root, [ sessionFilePath(name), ]); if (findings.length > 0) { diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index 19a6841..b27302f 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -73,7 +73,7 @@ import { loadSession, writeSession, } from "../../workspace/reviews.js"; -import { symlinkWritePathFindings } from "../../workspace/writes.js"; +import { obstructedWritePathFindings } from "../../workspace/writes.js"; import type { Invocation } from "../args.js"; import { flagValue } from "../args.js"; import type { CommandContext } from "../io.js"; @@ -263,9 +263,10 @@ async function runCreate( items: derived.items, }; - // SPEC 14.22: a symbolic link at a workspace-relative directory component - // of the write path refuses the write, reported before modifying anything. - const writeFindings = await symlinkWritePathFindings(workspace.root, [ + // SPEC 14.22: a workspace-relative directory component of the write path + // occupied by anything other than a directory refuses the write, reported + // before modifying anything. + const writeFindings = await obstructedWritePathFindings(workspace.root, [ sessionFilePath(name), ]); if (writeFindings.length > 0) { diff --git a/src/workspace/availability.ts b/src/workspace/availability.ts index eb44f3e..36d9869 100644 --- a/src/workspace/availability.ts +++ b/src/workspace/availability.ts @@ -41,7 +41,7 @@ import type { LoadedWorkspace } from "./config.js"; import { loadGraphData, writeGraphData } from "./graph-data.js"; import type { WorkspaceAnalysis } from "./pipeline.js"; import { analyzeWorkspace, workspaceInputsOf } from "./pipeline.js"; -import { symlinkWritePathFindings } from "./writes.js"; +import { obstructedWritePathFindings } from "./writes.js"; /** The outcome of the SPEC 11.2 pre-answer step. */ export type AvailabilityPreparation = @@ -125,7 +125,7 @@ export async function finishAvailabilityRefresh( // would now report. On that failing side these surfaces write nothing // and consult no record (SPEC 11.2); the condition itself is no domain // file's finding and accompanies no answer. - const writeFindings = await symlinkWritePathFindings( + const writeFindings = await obstructedWritePathFindings( workspace.root, build.writePaths, ); diff --git a/src/workspace/build.ts b/src/workspace/build.ts index 4def69c..16ff934 100644 --- a/src/workspace/build.ts +++ b/src/workspace/build.ts @@ -5,7 +5,7 @@ // (src/core/build.ts); this module performs the writes, strictly after the // caller has validated the workspace (SPEC 12.1: a failed build modifies // nothing) and the complete write set (SPEC 14.22, -// writes.ts/symlinkWritePathFindings). Every write goes through the +// writes.ts/obstructedWritePathFindings). Every write goes through the // workspace write layer, so each file is atomic in its observable effect // (SPEC 13.5) and replaces whatever occupies its path (SPEC 13.4). diff --git a/src/workspace/fast-read.ts b/src/workspace/fast-read.ts index 5e3312c..31c388a 100644 --- a/src/workspace/fast-read.ts +++ b/src/workspace/fast-read.ts @@ -28,7 +28,15 @@ // classification the pipeline runs (./discovery.ts) — yields no // findings, exactly the recorded path set, and every discovered file's // bytes hash to the recorded fingerprint (the discovered SET is part -// of the record: a new matching file is a mismatch). +// of the record: a new matching file is a mismatch); +// 5. no path a `build` would write has an obstructed workspace-relative +// directory component (SPEC 14.22): a refused write fails `build`'s +// validations alike (SPEC 13.3), so on such a workspace the gated full +// path reports the findings instead of answering — and the availability +// full path (`at`, SPEC 11.2) answers from the current sources without +// the refresh side effect, which the identical bytes make byte-equal to +// this store; falling back keeps both surfaces byte-identical to their +// full paths. // // The fast path never writes (a verified store needs no refresh; SPEC // 13.3's refreshing reads write only when the store does not match), and @@ -36,6 +44,7 @@ import * as fsp from "node:fs/promises"; import * as path from "node:path"; +import { generatedDerivedPaths } from "../core/build.js"; import { configurationFromStored } from "../core/config-data.js"; import type { Configuration } from "../core/config.js"; import type { GraphData, StoredRequirementNode } from "../core/graph-data.js"; @@ -48,6 +57,7 @@ import { sha256Hex } from "../core/hash.js"; import { discoverSources } from "./discovery.js"; import { readJournalBytes } from "./journal.js"; import type { LocatedWorkspace } from "./locate.js"; +import { obstructedWritePathFindings } from "./writes.js"; /** A verified store: the parsed graph data and the recovered parse. */ export interface VerifiedStore { @@ -133,6 +143,22 @@ export async function verifyStoreForRead( } } + // 5. Build's write set is unobstructed (SPEC 14.22, 13.3): an obstructed + // component fails `build`'s validations, so the full paths answer + // differently there (module header) — fall back. + const writePaths = [ + ...generatedDerivedPaths( + configuration, + classification.specSources.map((source) => source.path), + ), + GRAPH_DATA_PATH, + ]; + if ( + (await obstructedWritePathFindings(located.root, writePaths)).length > 0 + ) { + return null; + } + return { configuration, data }; } diff --git a/src/workspace/graph-data.ts b/src/workspace/graph-data.ts index b55c67a..e1db9ba 100644 --- a/src/workspace/graph-data.ts +++ b/src/workspace/graph-data.ts @@ -167,7 +167,7 @@ export async function readDerivedFileRecord( * in its observable effect (SPEC 13.5), replacing whatever occupies the * path (SPEC 13.4). Byte-deterministic for a given workspace (SPEC 12.0). * Callers validate the write path first (SPEC 14.22, - * `symlinkWritePathFindings`) and write only for workspaces that pass + * `obstructedWritePathFindings`) and write only for workspaces that pass * build validation — a failed build or refresh writes nothing (SPEC 12.1, * 13.3). */ diff --git a/src/workspace/journal.ts b/src/workspace/journal.ts index 8ddb19e..86cbad5 100644 --- a/src/workspace/journal.ts +++ b/src/workspace/journal.ts @@ -177,9 +177,10 @@ export async function readJournalBytes( * atomic in its observable effect (SPEC 13.5) and merging textually with * concurrent additions (SPEC 13.4). Callers are `rename` and `move` only, * running under workspace exclusivity (SPEC 13.5) and after full workspace - * validation (SPEC 6.4) — an occupied journal path or a symlinked `.xspec` - * component has already refused the operation as a finding (14.13, 14.22), - * and the layer's own guards are the terminal defense, thrown as errors. + * validation (SPEC 6.4) — an occupied journal path or an obstructed + * `.xspec` component has already refused the operation as a finding (14.13, + * 14.22), and the layer's own guards are the terminal defense, thrown as + * errors. */ export async function appendJournalEntry( root: string, diff --git a/src/workspace/refresh.ts b/src/workspace/refresh.ts index aa9b741..4221c2b 100644 --- a/src/workspace/refresh.ts +++ b/src/workspace/refresh.ts @@ -7,10 +7,13 @@ // commands refresh it — writing exactly what `xspec build` would write, // except that no TypeScript or Markdown is generated or removed and the // recorded derived-file paths are left unchanged — before answering. When -// the current sources fail `build` validation, they report the validation -// errors and exit 1 without answering and without modifying anything: a -// failed refresh, like a failed build (SPEC 12.1), leaves every derived -// file and all graph data unmodified. +// the current workspace fails the validations of `xspec build` — source +// validation errors, journal errors (14.13), and refused writes over +// build's complete write set (14.22) alike: the findings a `build` would +// now report — they report exactly those findings and exit 1 without +// answering and without modifying anything: a failed refresh, like a failed +// build (SPEC 12.1), leaves every derived file and all graph data +// unmodified. // // `check` never uses this step: it never refreshes and reports staleness // instead (SPEC 13.3, 14.10) — it composes `analyzeWorkspace` and the @@ -29,7 +32,6 @@ import { computeBuildOutputs } from "../core/build.js"; import type { Finding } from "../core/findings.js"; import type { GraphData } from "../core/graph-data.js"; import { - GRAPH_DATA_PATH, graphDataMatchesCurrent, refreshedGraphData, } from "../core/graph-data.js"; @@ -37,7 +39,7 @@ import type { LoadedWorkspace } from "./config.js"; import { loadGraphData, writeGraphData } from "./graph-data.js"; import type { WorkspaceAnalysis } from "./pipeline.js"; import { analyzeWorkspace, workspaceInputsOf } from "./pipeline.js"; -import { symlinkWritePathFindings } from "./writes.js"; +import { obstructedWritePathFindings } from "./writes.js"; /** The outcome of the SPEC 13.3 pre-answer step. */ export type WorkspacePreparation = @@ -54,8 +56,9 @@ export type WorkspacePreparation = } | { /** - * SPEC 13.3: the current sources fail `build` validation (or the - * needed refresh write is refused, SPEC 14.22) — the command reports + * SPEC 13.3: the current workspace fails `build`'s validations — + * source validation errors, journal errors, and refused writes over + * build's complete write set alike (SPEC 14.22) — the command reports * these findings as its report (standard output, SPEC 12.0) and * exits 1 without answering; nothing was modified. */ @@ -101,7 +104,8 @@ export async function analyzeWorkspaceForRead( /** * The gate-and-refresh assessment (SPEC 13.3), decision separated from * write: `findings` is the invalid-workspace report — validation findings, - * or the refused refresh write (SPEC 14.22) — with nothing modified; + * or refused writes over build's complete write set (SPEC 14.22): the + * findings a `build` would now report — with nothing modified; * `ready` carries the graph data the read answers beside and a `commit` * that performs the one refresh write (a no-op when the store already * matches). The caller commits only once every remaining argument check @@ -128,39 +132,44 @@ export async function assessWorkspaceRead( return { kind: "findings", findings: analysis.findings }; } - const stored = await loadGraphData(workspace.root); // What `xspec build` would write for the current sources and // configuration (SPEC 13.3): the same pure derivation `build` runs // (SPEC 12.1), so the refreshed bytes match a real build's byte for byte - // (SPEC 12.0 determinism). Only its graph data is consumed — the refresh - // generates and removes no TypeScript or Markdown. + // (SPEC 12.0 determinism). Its graph data and write set are independent + // of the stored record (`stored` feeds orphan removal alone, which no + // refresh performs), so the store stays unconsulted until the workspace + // has passed the complete gate below. The refresh generates and removes + // no TypeScript or Markdown — only the graph data is ever written. const build = computeBuildOutputs( workspace.configuration, analysis.specs, analysis.graph, analysis.textModel, analysis.hashes, - stored.data, + null, workspaceInputsOf(workspace, analysis), - ).graphData; - - const graphData = refreshedGraphData(stored.data, build); - if (graphDataMatchesCurrent(stored.bytes, stored.data, build)) { - // Matching data is served as is — no write, nothing to commit. - return { kind: "ready", graphData, commit: async () => {} }; - } + ); - // SPEC 14.22: the refresh writes exactly one path; a symbolic link at a - // workspace-relative directory component refuses the write, reported - // before anything is modified — the command cannot answer from stale - // data (SPEC 13.3), so it fails with the finding (exit 1). - const writeFindings = await symlinkWritePathFindings(workspace.root, [ - GRAPH_DATA_PATH, - ]); + // SPEC 13.3: refused writes (14.22) fail `build`'s validations alike — + // judged over build's complete write set, exactly the findings a `build` + // would now report. The gated read reports them and exits 1 without + // answering; evaluation only — nothing is modified and the store stays + // unread on this failing side. + const writeFindings = await obstructedWritePathFindings( + workspace.root, + build.writePaths, + ); if (writeFindings.length > 0) { return { kind: "findings", findings: writeFindings }; } + const stored = await loadGraphData(workspace.root); + const graphData = refreshedGraphData(stored.data, build.graphData); + if (graphDataMatchesCurrent(stored.bytes, stored.data, build.graphData)) { + // Matching data is served as is — no write, nothing to commit. + return { kind: "ready", graphData, commit: async () => {} }; + } + return { kind: "ready", graphData, diff --git a/src/workspace/writes.ts b/src/workspace/writes.ts index 61041ee..3b438d1 100644 --- a/src/workspace/writes.ts +++ b/src/workspace/writes.ts @@ -20,12 +20,16 @@ // to, or replaced: the read side reports it (journal → 14.13, session → // 14.21), and the write primitives here refuse it as a terminal defense. // -// SPEC 13.4 → 14.22: writes never traverse symbolic links. A symbolic link -// at a workspace-relative directory component of any write path refuses the -// write, reported before anything is modified; `check` reports it without -// writing. `symlinkWritePathFindings` is that report's producer — callers -// (build, and every command that writes) run it over their complete write -// set before touching the workspace, and the write primitives re-check as a +// SPEC 13.4 → 14.22: a write path having a workspace-relative directory +// component occupied by anything other than a directory — a plain file, a +// symbolic link (whatever it targets: writes never traverse one), or any +// other non-directory occupant — is refused, reported before anything is +// modified; `check` reports it without writing. One finding per distinct +// offending component, concerned path the component's workspace-relative +// path, however many write paths it refuses. +// `obstructedWritePathFindings` is that report's producer — callers (build, +// and every command that writes) run it over their complete write set +// before touching the workspace, and the write primitives re-check as a // terminal defense. Path components above the workspace root are // unrestricted (SPEC 13.4). @@ -44,7 +48,13 @@ import { pathFinding } from "../core/findings.js"; export type PathOccupant = "absent" | "file" | "directory" | "symlink" | "other"; -/** Classify the occupant of an absolute path (SPEC 13.4). */ +/** + * Classify the occupant of an absolute path (SPEC 13.4). A path unreachable + * through a non-directory or looping component classifies as "absent" — + * nothing occupies the path itself; the offending component is judged and + * reported separately (SPEC 14.22, `obstructedWritePathFindings`; SPEC 6.5, + * `nonDirectoryComponents`) — never a crash on the classifying read. + */ export async function classifyOccupant( absolute: string, ): Promise<PathOccupant> { @@ -52,7 +62,10 @@ export async function classifyOccupant( try { stats = await fsp.lstat(absolute); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return "absent"; + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP") { + return "absent"; + } throw error; } if (stats.isSymbolicLink()) return "symlink"; @@ -62,31 +75,17 @@ export async function classifyOccupant( } /** - * Classify the occupant of a workspace-relative path for the - * `rename`/`move` destination probes (SPEC 6.5, core/refusal.ts): like - * `classifyOccupant`, but a path unreachable through a non-directory or - * looping component classifies as "absent" — nothing occupies the path - * itself; the offending component reports separately through + * Classify the occupant of a workspace-relative path — the `rename`/`move` + * destination probes' entry (SPEC 6.5, core/refusal.ts). A path unreachable + * through a non-directory or looping component classifies as "absent" like + * every classification; the offending component reports separately through * `nonDirectoryComponents` (SPEC 6.5: `refused-invalid-destination`). */ export async function probeOccupant( root: string, rel: string, ): Promise<PathOccupant> { - let stats; - try { - stats = await fsp.lstat(absoluteOf(root, rel)); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP") { - return "absent"; - } - throw error; - } - if (stats.isSymbolicLink()) return "symlink"; - if (stats.isFile()) return "file"; - if (stats.isDirectory()) return "directory"; - return "other"; + return classifyOccupant(absoluteOf(root, rel)); } /** @@ -155,62 +154,84 @@ function directoryComponents(rel: string): string[] { return components; } +/** An offending directory component and what occupies it (SPEC 14.22). */ +export interface ObstructedComponent { + /** The component's workspace-relative path — the concerned path. */ + readonly component: string; + /** Its non-directory occupant, judged by `lstat` (SPEC 13.4). */ + readonly occupant: PathOccupant; +} + /** - * The first workspace-relative directory component of `rel` that is a - * symbolic link, or null when the path traverses none (SPEC 13.4, 14.22). - * Components are examined shallowest first and examination stops at the - * first symbolic link or missing component — an `lstat` of anything deeper - * would itself traverse the link, and below a missing component nothing - * exists (directory creation supplies real directories). Components above - * the workspace root are unrestricted (SPEC 13.4) and never examined. + * The first workspace-relative directory component of `rel` occupied by + * anything other than a directory — a plain file, a symbolic link (whatever + * it targets: writes never traverse one, SPEC 13.4), or any other + * non-directory occupant — or null when every existing component is a real + * directory (SPEC 14.22). Components are examined shallowest first and + * examination stops at the first non-directory or missing component: below + * a non-directory nothing exists to examine (deeper conditions are + * undetectable, SPEC 14, and an `lstat` through a symbolic link would + * itself traverse it), and below a missing component nothing exists — + * writes create those as directories, so a nonexistent component is never + * this condition (SPEC 13.4). Components above the workspace root are + * unrestricted (SPEC 13.4) and never examined. */ -export async function symlinkComponentOf( +export async function obstructedComponentOf( root: string, rel: string, -): Promise<string | null> { +): Promise<ObstructedComponent | null> { for (const component of directoryComponents(rel)) { const occupant = await classifyOccupant(absoluteOf(root, component)); - if (occupant === "symlink") return component; if (occupant === "absent") return null; - // A plain-file or other non-directory occupant is not a symbolic link: - // not this condition (SPEC 14.22). The write itself fails on it. + if (occupant !== "directory") return { component, occupant }; } return null; } -/** The SPEC 14.22 finding for `rel` traversing the symlink `component`. */ -function symlinkFinding(rel: string, component: string): Finding { +/** The SPEC 14.22 finding for one obstructed directory component. */ +function obstructionFinding(obstructed: ObstructedComponent): Finding { + const occupant = + obstructed.occupant === "symlink" + ? `a symbolic link — writes never traverse symbolic links, whatever ` + + `the link targets (SPEC 13.4)` + : `${describeOccupant(obstructed.occupant)}, not a directory ` + + `(SPEC 13.4)`; return pathFinding( 22, - `obstructed write path: writing ${rel} would traverse the ` + - `workspace-relative directory component ${component}, which is a ` + - `symbolic link — writes never traverse symbolic links (SPEC 13.4); ` + - `replace ${component} with a real directory, or redirect the write ` + - `so no path xspec writes passes through it (SPEC 14.22)`, - rel, + `obstructed write path: the workspace-relative directory component ` + + `${obstructed.component} of a path xspec writes is occupied by ` + + `${occupant}; replace ${obstructed.component} with a real directory, ` + + `or redirect the writes so no path xspec writes passes through it ` + + `(SPEC 14.22)`, + obstructed.component, ); } /** * SPEC 14.22 findings over a set of workspace-relative write paths: one - * finding per offending path, naming the first symbolic-link directory - * component it traverses. Deterministic — paths are deduplicated and - * examined in byte order (SPEC 12.0). Callers run this over their complete - * write set before modifying anything ("a command refuses the write and - * reports it before modifying anything"); `check` reports the same findings - * without writing (SPEC 14.22). + * finding per distinct offending component, whatever write paths it + * refuses, each finding's concerned path the component's workspace-relative + * path. Deterministic — paths are deduplicated and examined in byte order, + * findings in byte order of component (SPEC 12.0). Callers run this over + * their complete write set before modifying anything ("a command refuses + * the write and reports it before modifying anything"); `check` reports the + * same findings without writing (SPEC 14.22). */ -export async function symlinkWritePathFindings( +export async function obstructedWritePathFindings( root: string, rels: Iterable<string>, ): Promise<Finding[]> { const unique = [...new Set(rels)].sort(compareBytes); - const findings: Finding[] = []; + const obstructions = new Map<string, ObstructedComponent>(); for (const rel of unique) { - const component = await symlinkComponentOf(root, rel); - if (component !== null) findings.push(symlinkFinding(rel, component)); + const obstructed = await obstructedComponentOf(root, rel); + if (obstructed !== null && !obstructions.has(obstructed.component)) { + obstructions.set(obstructed.component, obstructed); + } } - return findings; + return [...obstructions.values()] + .sort((a, b) => compareBytes(a.component, b.component)) + .map(obstructionFinding); } /** @@ -301,8 +322,8 @@ async function replaceWithFile( * effect (SPEC 13.5), replacing whatever occupies the path — a symbolic * link included, never writing through it (SPEC 13.4). Missing parent * directories are created. Callers have already validated the write path - * (SPEC 14.22, `symlinkWritePathFindings`); a symlinked component here is a - * terminal defense and throws. + * (SPEC 14.22, `obstructedWritePathFindings`); an obstructed component here + * is a terminal defense and throws. */ export async function writeDerivedFile( root: string, @@ -336,14 +357,16 @@ export async function writeSourceFile( * to exist). The occupant is a discovered source — a plain file reached * through real directories (discovery never follows symbolic links, SPEC 7) * — and removal never traverses a symlinked component (SPEC 13.4): a path - * whose directory component became a symbolic link is skipped untouched, as - * in orphan removal. An absent occupant is a completed removal. + * whose directory component became a symbolic link — or any other + * non-directory, below which the source cannot exist — is skipped + * untouched, as in orphan removal. An absent occupant is a completed + * removal. */ export async function removeSourceFile( root: string, rel: string, ): Promise<void> { - if ((await symlinkComponentOf(root, rel)) !== null) return; + if ((await obstructedComponentOf(root, rel)) !== null) return; await fsp.rm(absoluteOf(root, rel), { force: true }); } @@ -356,13 +379,15 @@ export async function removeSourceFile( * recorded path with a symbolic link at a workspace-relative directory * component is skipped untouched: removal never traverses a link (SPEC * 13.4), so the path no longer denotes a location xspec may touch — like an - * orphan whose record is missing, it is outside xspec's knowledge. + * orphan whose record is missing, it is outside xspec's knowledge; below + * any other non-directory component the recorded path cannot exist, so the + * removal is equally complete without touching anything. */ export async function removeDerivedFile( root: string, rel: string, ): Promise<void> { - if ((await symlinkComponentOf(root, rel)) !== null) return; + if ((await obstructedComponentOf(root, rel)) !== null) return; const absolute = absoluteOf(root, rel); const occupant = await classifyOccupant(absolute); if (occupant === "absent") return; From f62eb676ea9b89f1811214af328cbefa42a7676d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 06:04:34 +0000 Subject: [PATCH 165/226] sdg(phase-10): persist unreadable recorded state past every refreshing read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC 13.3/14.23/14.10 (FIX_PLAN C4): recorded generation state that exists but cannot be read as a record is neither read, repaired, nor replaced by the refreshing reads — they answer from the current analysis, report no finding for it, and leave the store byte-for-byte — and `check` reports the state as staleness under the exclusive unreadable-record unit form, one condition-10 finding concerning the graph-data area, the recorded-file per-file form undetectable while it holds. - workspace/graph-data.ts: `LoadedGraphData.state` three-way ("absent" | "readable" | "unreadable"), one classification rule shared with `readDerivedFileRecord` (now derived from the same load). - workspace/refresh.ts, workspace/availability.ts: both refresh points skip predicate and write on the unreadable state. - workspace/check.ts: unreadable → `unreadableRecordStaleFinding()` (path `.xspec`, locations empty), exclusive with the mismatch arm. - core/graph-data.ts: comments brought in line with the new discipline. T13.3-2 green in full (the corrupt-record sweep: every refreshing read exit 0, store untouched, `inventory` still 14.23-unavailable, `build` replacing the state); section-6.6/11.6/14 files green. T12.2-2/3 still red at their family-3/missing arms on C5's concerned-path defect (the mismatch unit form names `.xspec/graph.json`), as the plan records. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 60 +++++++++-------- src/core/graph-data.ts | 74 +++++++++++++-------- src/workspace/availability.ts | 9 ++- src/workspace/check.ts | 46 +++++++++++-- src/workspace/graph-data.ts | 118 +++++++++++++++++++--------------- src/workspace/refresh.ts | 35 ++++++++-- 6 files changed, 220 insertions(+), 122 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 77e2d66..7aa72e3 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -346,40 +346,38 @@ gate's findings, exit 1, store unconsulted; the store-backed fast paths `at`) verify the write set unobstructed as step 5 (`generatedDerivedPaths` + `GRAPH_DATA_PATH`), falling back to the full path otherwise.) -### C4. Unreadable recorded state persists; `check` reports the exclusive unit form - -SPEC 13.3, 14.23, 14.10. Prereq A1 (condition 23), B7 (shared record reader). -Today `graphDataMatchesCurrent`/`refreshedGraphData` -(`src/core/graph-data.ts` ~229–280) treat a malformed store as an ordinary -mismatch and fabricate a fresh record: after corrupting `.xspec/graph.json`, -`ids` exits 0 and rewrites the store, and `check` then exits 0. Required: - -- Refreshing reads (`ids`, `show`, `coverage`, `impact`, `review`, `query`, - `occurrences`, `view`, `at`) never consult, repair, or replace recorded state - that exists but cannot be read as a record, and report no finding for it: they - answer from current analysis, leave the store byte-for-byte, and the state - persists until a successful `build` (which replaces it silently) or a - `rename`/`move` finishing regeneration. -- `check` reports the state as staleness (14.10): the unreadable-record unit - form — one condition-10 (`stale-output`) finding instructing rebuilding, - concerned path the graph-data area — exclusive with the mismatch unit form - (never both), and while it holds the recorded-file per-file form (a recorded - derived path no longer generated), consulting no readable record, is - undetectable and not reported; the other per-file forms report normally. - -Verify: T13.3-2 (`section-13.3*.test.ts` — its corrupt-record half is this -task's observed failure: `ids --json` repairs the store and `inventory` then -answers exit 0), T12.2-2/3 arms. +(C4 landed: the loaded store is three-state — `LoadedGraphData.state` is +`"absent" | "readable" | "unreadable"` (`src/workspace/graph-data.ts`; +`readDerivedFileRecord` now derives from the same `loadGraphData`, one +classification rule — a non-plain occupant, non-UTF-8, or unparseable +bytes are "unreadable", a path below a non-directory "absent"). Both +refresh points — `assessWorkspaceRead` (`src/workspace/refresh.ts`) and +`finishAvailabilityRefresh` (`src/workspace/availability.ts`) — skip the +predicate and the write on "unreadable": the read answers from current +analysis at exit 0, reports no finding, and the store stays byte-for-byte +(symlink occupants included) until `build` or a finishing regeneration +replaces it. `check`'s `stalenessFindings` (`src/workspace/check.ts`) +branches unreadable → `unreadableRecordStaleFinding()` (one condition-10 +finding, concerned path `GRAPH_DATA_AREA`, locations empty), else the +mismatch predicate — never both, and orphan reporting is empty by +construction there (null stored data feeds `computeBuildOutputs`). +T13.3-2 and section-6.6/11.6/14 files green; T12.2-2's family 4 conforms +(verified by hand) but the test aborts earlier, at its family-3 missing +arm, on C5's defect.) ### C5. 14.10 unit-form findings concern the graph-data area -SPEC 14.10, 11.6. The graph-data staleness finding names `.xspec/graph.json` -(`stalenessFindings`, `src/workspace/check.ts` ~132). Required: both unit forms' -concerned path is the graph-data area itself — `.xspec`, the 11.6 spelling, no -trailing separator — never any path inside it (the record's layout is -unenumerated, 13.3). - -Verify: T12.2-2/3 arms (`section-12.2*.test.ts`), T14 arms. +SPEC 14.10, 11.6. The graph-data mismatch/missing staleness finding still +names `.xspec/graph.json`: the `staleFinding(GRAPH_DATA_PATH, "does not +match")` arm of `stalenessFindings` (`src/workspace/check.ts` ~175, beside +C4's unreadable arm). Required: both unit forms' concerned path is the +graph-data area itself — `.xspec`, the 11.6 spelling, no trailing +separator — never any path inside it (the record's layout is unenumerated, +13.3); the finding is one unit-form condition-10 finding instructing +rebuilding, not the per-file message shape. + +Verify: T12.2-2/3 (`section-12.1-12.2.test.ts` — both currently abort at +their missing-arm unit-form assertions on exactly this), T14 arms. ### C6. Review payloads carry source ranges for every present node diff --git a/src/core/graph-data.ts b/src/core/graph-data.ts index 4175bcd..6e44949 100644 --- a/src/core/graph-data.ts +++ b/src/core/graph-data.ts @@ -33,11 +33,16 @@ // outlives the generation set (that is what makes orphan removal and // 14.10's recorded-orphan arm possible, SPEC 13.3, 13.4, 12.1). A refresh // writes exactly what `xspec build` would write except for that record -// clause (SPEC 13.3): with a recoverable record, build's data with the -// stored record preserved; with none — the store missing or malformed — -// there are no recorded paths to preserve, and the written data is exactly -// build's, would-be record included. The predicate compares against the -// same refreshed form, so both judge the store by one rule. +// clause (SPEC 13.3): with a readable record, build's data with the stored +// record preserved; with an absent store there are no recorded paths to +// preserve, and the written data is exactly build's, would-be record +// included. Recorded state that exists but cannot be read as a record — +// malformed bytes, a non-plain occupant (workspace/graph-data.ts's +// "unreadable" state) — is neither read, repaired, nor replaced by any +// refresh (SPEC 13.3, 14.23): only `build` and the finishing +// `rename`/`move` regeneration replace it, and `check` reports it as +// staleness (SPEC 14.10). The predicate compares against the same +// refreshed form, so refresh and `check` judge the store by one rule. // // The content is otherwise opaque (SPEC 13.3): its observable contract is // its location under `.xspec/`, its classification as a derived file @@ -95,10 +100,12 @@ export function unreadableRecordFinding(): Finding { /** * The stored format version: a parsed file of any other version is - * malformed (parse yields null), so it reads as not matching the current - * sources and configuration and is refreshed or rebuilt (SPEC 13.3). - * Version 3 added the reference occurrences (SPEC 5.7, 13.3); version 4 - * added the code-location source ranges (SPEC 1.7). + * malformed (parse yields null) — recorded state that exists but cannot be + * read as a record (SPEC 14.23): the refreshing reads leave it untouched + * and answer from the current analysis, `check` reports it as staleness, + * and a `build` (or finishing regeneration) replaces it (SPEC 13.3, + * 14.10). Version 3 added the reference occurrences (SPEC 5.7, 13.3); + * version 4 added the code-location source ranges (SPEC 1.7). */ const GRAPH_DATA_VERSION = 4; @@ -309,15 +316,18 @@ export function buildGraphSnapshot( * would write, except the recorded derived-file paths are left unchanged. * `build` is what the build would write for the current sources and * configuration — snapshot plus the would-be generated set as its record - * (core/build.ts, `BuildOutputs.graphData`). With a recoverable record the + * (core/build.ts, `BuildOutputs.graphData`). With a readable record the * refresh preserves it (the record is updated only by generation, and it - * legitimately outlives the generation set — SPEC 13.3, 13.4); with none — - * the store missing or malformed — there are no recorded paths to leave - * unchanged, and the refresh writes build's data as is. Files orphaned - * while the record was missing stay outside xspec's knowledge either way - * (SPEC 13.4): the would-be record names only currently generated paths, - * never such orphans. `build` itself does not use this — it records the - * paths it just generated. + * legitimately outlives the generation set — SPEC 13.3, 13.4); with an + * absent store (`stored` null) there are no recorded paths to leave + * unchanged, and the refresh writes build's data as is. An unreadable + * record never reaches a refresh write at all: the refreshing reads leave + * that state untouched (SPEC 13.3, 14.23; workspace/refresh.ts, + * workspace/availability.ts). Files orphaned while the record was missing + * stay outside xspec's knowledge either way (SPEC 13.4): the would-be + * record names only currently generated paths, never such orphans. + * `build` itself does not use this — it records the paths it just + * generated. */ export function refreshedGraphData( stored: GraphData | null, @@ -350,13 +360,17 @@ export function recordedDerivedFiles( * operationally, whether the stored bytes are exactly what a refresh * would write (`refreshedGraphData` over `build`, what `xspec build` * would write for the current sources and configuration). False when the - * store is missing (`storedBytes` null) or malformed (`storedData` null — - * its bytes cannot equal a canonical serialization, which always parses). - * The refreshing reads refresh exactly when this is false (SPEC 13.3); - * `check`, which never refreshes, reports the graph-data file stale - * exactly when this is false (SPEC 14.10) — by the same rule, so the - * retained derived-file record never reads as staleness (SPEC 13.3: the - * record is mandated to be left unchanged). + * store is missing (`storedBytes` null). The unreadable-record state is + * judged before this predicate is ever consulted (SPEC 13.3, 14.23: + * workspace/graph-data.ts's three-way load state): the refreshing reads + * skip both the predicate and the write there, and `check` reports that + * state under 14.10's unreadable-record unit form instead — so the inputs + * here are an absent or readable store. The refreshing reads refresh + * exactly when this is false (SPEC 13.3); `check`, which never refreshes, + * reports the graph data mismatched exactly when this is false + * (SPEC 14.10) — by the same rule, so the retained derived-file record + * never reads as staleness (SPEC 13.3: the record is mandated to be left + * unchanged). */ export function graphDataMatchesCurrent( storedBytes: Uint8Array | null, @@ -474,10 +488,14 @@ const EDGE_KINDS: ReadonlySet<string> = new Set([ * Parse stored graph-data text. Returns null — malformed — for anything * that is not the versioned shape `serializeGraphData` writes: not JSON, * a different version, or structurally invalid fields. A malformed store - * never matches the current sources and configuration (SPEC 13.3), so it - * is refreshed by the reading commands and reported stale by `check` - * (SPEC 14.10); its derived-file record is unrecoverable, leaving any - * orphans outside xspec's knowledge (SPEC 13.4). + * is recorded state that exists but cannot be read as a record + * (SPEC 14.23): the refreshing reads leave it untouched and answer from + * the current analysis (SPEC 13.3), `check` reports it as staleness under + * the unreadable-record unit form (SPEC 14.10), the record-consulting + * surfaces report their record-supplied datum explicitly unavailable + * beside the condition-23 finding, and a successful `build` or finishing + * regeneration replaces it; its derived-file record is unrecoverable, + * leaving any orphans outside xspec's knowledge (SPEC 13.4). */ export function parseGraphData(text: string): GraphData | null { let raw: unknown; diff --git a/src/workspace/availability.ts b/src/workspace/availability.ts index 36d9869..db6bd56 100644 --- a/src/workspace/availability.ts +++ b/src/workspace/availability.ts @@ -136,8 +136,15 @@ export async function finishAvailabilityRefresh( // Passing workspace: read-time refresh participation (SPEC 13.3), as in // ./refresh.ts — matching data is served as is; mismatched or missing // data is rewritten as `build` would write it, the recorded derived-file - // paths left unchanged. + // paths left unchanged. Recorded state that exists but cannot be read as + // a record is neither read, repaired, nor replaced, and no finding is + // reported for it (SPEC 13.3, 14.23): the store stays byte-for-byte + // until a successful `build` or a finishing `rename`/`move` regeneration + // replaces the record. const stored = await loadGraphData(workspace.root); + if (stored.state === "unreadable") { + return; + } if (!graphDataMatchesCurrent(stored.bytes, stored.data, build.graphData)) { await writeGraphData( workspace.root, diff --git a/src/workspace/check.ts b/src/workspace/check.ts index ded693d..8e7f367 100644 --- a/src/workspace/check.ts +++ b/src/workspace/check.ts @@ -24,6 +24,7 @@ import type { BuildOutputs } from "../core/build.js"; import type { Finding } from "../core/findings.js"; import { pathFinding } from "../core/findings.js"; import { + GRAPH_DATA_AREA, GRAPH_DATA_PATH, graphDataMatchesCurrent, } from "../core/graph-data.js"; @@ -68,16 +69,39 @@ function orphanFinding(rel: string): Finding { ); } +/** + * SPEC 14.10's unreadable-record unit form: recorded generation state that + * exists but cannot be read as a record (14.23) is staleness — one + * condition-10 finding instructing rebuilding, concerned path the + * graph-data area itself (the record's layout is deliberately + * unenumerated, SPEC 13.3/11.6, so no path inside it is named). + */ +function unreadableRecordStaleFinding(): Finding { + return pathFinding( + 10, + `stale generated output: the recorded generation state under the ` + + `graph-data area exists but cannot be read as a record; run ` + + `\`xspec build\` to regenerate every derived file and replace the ` + + `record (SPEC 14.10, 14.23)`, + GRAPH_DATA_AREA, + ); +} + /** * The SPEC 14.10 findings of `check` (SPEC 12.2), reading and comparing * only — nothing is written: * * - each derived file the current sources and configuration generate whose * path holds different bytes, no plain file, or nothing at all; - * - the graph data, judged by the shared compare-with-current predicate - * (SPEC 13.3 — the retained derived-file record is never staleness); + * - the graph data, as one unit in two exclusive forms (SPEC 14.10): + * recorded state that exists but cannot be read as a record (SPEC 14.23) + * under the unreadable-record form, concerned path the graph-data area; + * otherwise the shared compare-with-current predicate (SPEC 13.3 — the + * retained derived-file record is never staleness); * - each recorded derived file remaining (anything occupying its path) at - * a path the current build no longer generates (`outputs.orphans`). + * a path the current build no longer generates (`outputs.orphans`) — + * undetectable, and so unreported, while the unreadable-record state + * holds (no readable record is consulted). * * `outputs` is the pure build derivation over the current, validated * workspace; `stored` the loaded graph data it was derived against. @@ -126,15 +150,25 @@ export async function stalenessFindings( } } - // SPEC 13.3/14.10: `check` reports the graph data stale exactly when the - // refreshing reads would refresh it — one shared predicate. - if (!graphDataMatchesCurrent(stored.bytes, stored.data, outputs.graphData)) { + // SPEC 14.10's unit forms, exclusive — one finding either way, never + // both. Recorded state that exists but cannot be read as a record + // (SPEC 14.23) reports under the unreadable-record form alone; otherwise + // `check` reports the graph data stale exactly when the refreshing reads + // would refresh it — the shared predicate (SPEC 13.3). + if (stored.state === "unreadable") { + findings.push(unreadableRecordStaleFinding()); + } else if ( + !graphDataMatchesCurrent(stored.bytes, stored.data, outputs.graphData) + ) { findings.push(staleFinding(GRAPH_DATA_PATH, "does not match")); } // SPEC 14.10's recorded-orphan arm: `outputs.orphans` holds the recorded // derived files the current build no longer generates (byte order, // core/build.ts); one whose path is vacant remains nowhere — no finding. + // While the unreadable-record state holds this form is undetectable + // (SPEC 14.10): it consults no readable record, and an unreadable store + // records nothing (`outputs.orphans` is empty by construction). for (const rel of outputs.orphans) { if ((await classifyOccupant(absoluteOf(root, rel))) !== "absent") { findings.push(orphanFinding(rel)); diff --git a/src/workspace/graph-data.ts b/src/workspace/graph-data.ts index e1db9ba..b828414 100644 --- a/src/workspace/graph-data.ts +++ b/src/workspace/graph-data.ts @@ -13,12 +13,17 @@ // // Serialization, parsing, and the compare-with-current predicate are the // pure core's (src/core/graph-data.ts). Loading classifies the occupant -// with lstat: only a plain file is read (a non-plain occupant loads as -// missing — it cannot match the current sources and configuration, so the -// refreshing reads replace it and `check` reports it stale, SPEC 13.3, -// 14.10); bytes that are not valid UTF-8 or do not parse as the stored -// shape load with a null model (malformed — same consequence, and the -// derived-file record is unrecoverable, SPEC 13.4). +// with lstat and yields one of three states (SPEC 13.3, 14.23): absent +// (nothing recorded — the refreshing reads write build's data whole), +// readable (the parsed model — compared, and refreshed on mismatch with +// the record preserved), or unreadable — recorded state that exists but +// cannot be read as a record: a non-plain occupant, or bytes that are not +// valid UTF-8 or not the stored shape. The unreadable state is neither +// read, repaired, nor replaced by any refreshing read and no finding is +// reported for it (SPEC 13.3); it persists — met by the record-consulting +// surfaces (SPEC 11.6, 6.6 → 14.23) and reported as staleness by `check` +// (SPEC 14.10) — until a successful `build` or a finishing `rename`/`move` +// regeneration replaces the record. import * as fsp from "node:fs/promises"; import * as path from "node:path"; @@ -33,19 +38,40 @@ import { classifyOccupant, writeDerivedFile } from "./writes.js"; const strictUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); -/** The loaded store: raw bytes and, when they parse, the model. */ +/** + * The loaded store's three-way state (SPEC 13.3, 14.23) — the same + * classification `readDerivedFileRecord` makes for the record-consulting + * surfaces: nothing recorded, a readable record, or recorded state that + * exists but cannot be read as a record. + */ +export type GraphDataState = "absent" | "readable" | "unreadable"; + +/** The loaded store: its state, raw bytes and, when they parse, the model. */ export interface LoadedGraphData { /** - * The stored file's exact bytes — null when nothing is loadable: the - * path is absent or occupied by anything other than a plain file + * SPEC 13.3/14.23: "absent" — nothing occupies the store's path (the + * refreshing reads write build's data whole, SPEC 13.3); "readable" — + * a plain file parsing as the stored shape (`data` non-null); + * "unreadable" — recorded state that exists but cannot be read as a + * record: a non-plain occupant, or bytes that are not valid UTF-8, not + * JSON, or not the stored shape. A refresh neither reads, repairs, nor + * replaces the unreadable state and reports no finding for it; only a + * successful `build` or a finishing `rename`/`move` regeneration + * replaces it, and `check` reports it as staleness (SPEC 14.10). + */ + readonly state: GraphDataState; + /** + * The stored file's exact bytes — null when no plain file is readable: + * the path is absent or occupied by anything other than a plain file * (SPEC 13.4: a derived path's occupant is resolved by rebuilding). */ readonly bytes: Uint8Array | null; /** - * The parsed model — null when `bytes` is null or the bytes are - * malformed (not UTF-8, not JSON, or not the stored shape). Feed this + * The parsed model — non-null exactly in the "readable" state. Feed this * with `bytes` to `graphDataMatchesCurrent` (core) for the staleness - * predicate, and to `recordedDerivedFiles` (core) for orphan handling. + * predicate, and to `recordedDerivedFiles` (core) for orphan handling + * (an unreadable record recovers nothing — such orphans are outside + * xspec's knowledge, SPEC 13.4). */ readonly data: GraphData | null; } @@ -57,31 +83,41 @@ function graphDataAbsolutePath(root: string): string { /** * Load the workspace's graph data (SPEC 13.3). Never throws on the - * expected states: an absent file, a non-plain occupant, or malformed - * content all load as "does not match" inputs for the predicate — the - * refresh, failure, and staleness behaviors are the callers' (SPEC 13.3, - * 14.10). + * expected states — each loads as its `GraphDataState`, and the refresh, + * failure, and staleness behaviors are the callers' (SPEC 13.3, 14.10, + * 14.23). The occupant classification mirrors `readDerivedFileRecord`: + * only a plain file is read; anything else at the record's path exists but + * is no readable record, while a path below a non-directory classifies + * absent (writes.ts — nothing occupies it). */ export async function loadGraphData(root: string): Promise<LoadedGraphData> { const absolute = graphDataAbsolutePath(root); - if ((await classifyOccupant(absolute)) !== "file") { - return { bytes: null, data: null }; + const occupant = await classifyOccupant(absolute); + if (occupant === "absent") { + return { state: "absent", bytes: null, data: null }; + } + if (occupant !== "file") { + return { state: "unreadable", bytes: null, data: null }; } let bytes: Uint8Array; try { bytes = await fsp.readFile(absolute); } catch { - // The occupant changed between classification and read (SPEC 13.5: - // concurrent commands, last-write-wins): load as missing. - return { bytes: null, data: null }; + // Vanished between classification and read (SPEC 13.5: concurrent + // commands, last-write-wins): nothing exists to read as a record. + return { state: "absent", bytes: null, data: null }; } let text: string; try { text = strictUtf8Decoder.decode(bytes); } catch { - return { bytes, data: null }; + return { state: "unreadable", bytes, data: null }; + } + const data = parseGraphData(text); + if (data === null) { + return { state: "unreadable", bytes, data: null }; } - return { bytes, data: parseGraphData(text) }; + return { state: "readable", bytes, data }; } /** @@ -119,45 +155,23 @@ export type DerivedFileRecord = * refreshing it (`inventory`, 11.6; preview deltas, 6.6; `check`'s * unreadable-record staleness arm, 14.10). Never repairs, replaces, or * otherwise writes: the state persists until a successful `build` or a - * finishing regeneration replaces the record (SPEC 13.3). The occupant is - * classified by lstat (writes.ts): only a plain file is read — anything - * else at the record's path exists but is no readable record. + * finishing regeneration replaces the record (SPEC 13.3). The three-way + * state is `loadGraphData`'s — one classification rule for the record + * readers and the refreshing reads alike. */ export async function readDerivedFileRecord( root: string, ): Promise<DerivedFileRecord> { - const absolute = graphDataAbsolutePath(root); - const occupant = await classifyOccupant(absolute); - if (occupant === "absent") { - return { state: "absent" }; - } - if (occupant !== "file") { - return { state: "unreadable" }; - } - let bytes: Uint8Array; - try { - bytes = await fsp.readFile(absolute); - } catch { - // Vanished between classification and read (SPEC 13.5: concurrent - // commands, last-write-wins): nothing exists to read as a record. - return { state: "absent" }; - } - let text: string; - try { - text = strictUtf8Decoder.decode(bytes); - } catch { - return { state: "unreadable" }; - } - const data = parseGraphData(text); - if (data === null) { - return { state: "unreadable" }; + const loaded = await loadGraphData(root); + if (loaded.state !== "readable" || loaded.data === null) { + return { state: loaded.state === "absent" ? "absent" : "unreadable" }; } // SPEC 11.6/12.0: the recorded paths as one byte-ordered, duplicate-free // list (the canonical serialization already writes them so; sorting here // keeps the datum canonical whatever bytes parsed). return { state: "readable", - paths: [...new Set(data.derivedFiles)].sort(compareBytes), + paths: [...new Set(loaded.data.derivedFiles)].sort(compareBytes), }; } diff --git a/src/workspace/refresh.ts b/src/workspace/refresh.ts index 4221c2b..6dedff8 100644 --- a/src/workspace/refresh.ts +++ b/src/workspace/refresh.ts @@ -6,7 +6,13 @@ // missing or does not match the current sources and configuration, these // commands refresh it — writing exactly what `xspec build` would write, // except that no TypeScript or Markdown is generated or removed and the -// recorded derived-file paths are left unchanged — before answering. When +// recorded derived-file paths are left unchanged — before answering. The +// record is left unchanged in every state: recorded state that exists but +// cannot be read as a record (SPEC 14.23) is neither read, repaired, nor +// replaced — the read answers from the current analysis, reports no +// finding for it, and leaves the store byte-for-byte, the state persisting +// until a successful `build` or a finishing `rename`/`move` regeneration +// replaces the record (`check` reports it as staleness, SPEC 14.10). When // the current workspace fails the validations of `xspec build` — source // validation errors, journal errors (14.13), and refused writes over // build's complete write set (14.22) alike: the findings a `build` would @@ -47,11 +53,16 @@ export type WorkspacePreparation = /** * The workspace is valid and the stored graph data now matches the * current sources and configuration — refreshed if it did not - * (SPEC 13.3). Answer from `analysis`. + * (SPEC 13.3) — or exists but cannot be read as a record and was + * left untouched (SPEC 13.3, 14.23). Answer from `analysis`. */ readonly kind: "ready"; readonly analysis: WorkspaceAnalysis; - /** The graph data as stored — current snapshot, retained record. */ + /** + * The current snapshot with the retained record — or, over an + * unreadable record, build's data, never written (the answer's + * source is `analysis` either way). + */ readonly graphData: GraphData; } | { @@ -108,7 +119,9 @@ export async function analyzeWorkspaceForRead( * findings a `build` would now report — with nothing modified; * `ready` carries the graph data the read answers beside and a `commit` * that performs the one refresh write (a no-op when the store already - * matches). The caller commits only once every remaining argument check + * matches — and always over recorded state that exists but cannot be read + * as a record, which no refresh reads, repairs, or replaces, SPEC 13.3, + * 14.23). The caller commits only once every remaining argument check * has passed, so a usage-error invocation writes nothing — and the * decision itself never writes, so a report that must precede other * evaluation (the corrupt-session report of 10.1 behind this gate) can be @@ -164,6 +177,20 @@ export async function assessWorkspaceRead( } const stored = await loadGraphData(workspace.root); + if (stored.state === "unreadable") { + // SPEC 13.3: recorded state that exists but cannot be read as a record + // is neither read, repaired, nor replaced by a refresh, and no finding + // is reported for it — the read answers from the current analysis and + // the store stays byte-for-byte. The state persists — met by the + // record-consulting surfaces (SPEC 11.6, 6.6 → 14.23) and reported as + // staleness by `check` (SPEC 14.10) — until a successful `build` or a + // finishing `rename`/`move` regeneration replaces the record. + return { + kind: "ready", + graphData: build.graphData, + commit: async () => {}, + }; + } const graphData = refreshedGraphData(stored.data, build.graphData); if (graphDataMatchesCurrent(stored.bytes, stored.data, build.graphData)) { // Matching data is served as is — no write, nothing to commit. From 4c0a3f87da4af9a8e32067f3afd16279a9816806 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 06:12:32 +0000 Subject: [PATCH 166/226] sdg(phase-10): report both 14.10 unit forms against the graph-data area Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 19 ++++++------------- src/workspace/check.ts | 34 +++++++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 7aa72e3..fc2291d 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -365,19 +365,12 @@ T13.3-2 and section-6.6/11.6/14 files green; T12.2-2's family 4 conforms (verified by hand) but the test aborts earlier, at its family-3 missing arm, on C5's defect.) -### C5. 14.10 unit-form findings concern the graph-data area - -SPEC 14.10, 11.6. The graph-data mismatch/missing staleness finding still -names `.xspec/graph.json`: the `staleFinding(GRAPH_DATA_PATH, "does not -match")` arm of `stalenessFindings` (`src/workspace/check.ts` ~175, beside -C4's unreadable arm). Required: both unit forms' concerned path is the -graph-data area itself — `.xspec`, the 11.6 spelling, no trailing -separator — never any path inside it (the record's layout is unenumerated, -13.3); the finding is one unit-form condition-10 finding instructing -rebuilding, not the per-file message shape. - -Verify: T12.2-2/3 (`section-12.1-12.2.test.ts` — both currently abort at -their missing-arm unit-form assertions on exactly this), T14 arms. +(C5 landed: both 14.10 unit forms concern the graph-data area — +`mismatchedGraphDataStaleFinding()` in `src/workspace/check.ts` replaces +the per-file `staleFinding(GRAPH_DATA_PATH, …)` arm: one condition-10 +finding, concerned path `GRAPH_DATA_AREA` (`.xspec`), locations empty, +covering missing and mismatch as one form beside C4's unreadable arm. +Section-12.1-12.2, section-13.3, and section-14 files all green.) ### C6. Review payloads carry source ranges for every present node diff --git a/src/workspace/check.ts b/src/workspace/check.ts index 8e7f367..4e582be 100644 --- a/src/workspace/check.ts +++ b/src/workspace/check.ts @@ -25,7 +25,6 @@ import type { Finding } from "../core/findings.js"; import { pathFinding } from "../core/findings.js"; import { GRAPH_DATA_AREA, - GRAPH_DATA_PATH, graphDataMatchesCurrent, } from "../core/graph-data.js"; import type { LoadedGraphData } from "./graph-data.js"; @@ -69,6 +68,26 @@ function orphanFinding(rel: string): Finding { ); } +/** + * SPEC 14.10's mismatch/missing unit form: graph data that is missing or + * does not match the current sources and configuration (the comparison of + * 13.3, the recorded derived-file paths excluded) — one condition-10 + * finding instructing rebuilding, concerned path the graph-data area + * itself (the record's layout is deliberately unenumerated, SPEC + * 13.3/11.6, so no path inside it is named), never the per-file message + * shape. + */ +function mismatchedGraphDataStaleFinding(): Finding { + return pathFinding( + 10, + `stale generated output: the graph data under the graph-data area is ` + + `missing or does not match the current sources and configuration; ` + + `run \`xspec build\` to regenerate every derived file (SPEC 14.10, ` + + `13.3)`, + GRAPH_DATA_AREA, + ); +} + /** * SPEC 14.10's unreadable-record unit form: recorded generation state that * exists but cannot be read as a record (14.23) is staleness — one @@ -93,11 +112,12 @@ function unreadableRecordStaleFinding(): Finding { * * - each derived file the current sources and configuration generate whose * path holds different bytes, no plain file, or nothing at all; - * - the graph data, as one unit in two exclusive forms (SPEC 14.10): - * recorded state that exists but cannot be read as a record (SPEC 14.23) - * under the unreadable-record form, concerned path the graph-data area; - * otherwise the shared compare-with-current predicate (SPEC 13.3 — the - * retained derived-file record is never staleness); + * - the graph data, as one unit in two exclusive forms (SPEC 14.10), each + * one finding whose concerned path is the graph-data area itself, no + * path inside it named: recorded state that exists but cannot be read as + * a record (SPEC 14.23) under the unreadable-record form; otherwise the + * missing-or-mismatch form by the shared compare-with-current predicate + * (SPEC 13.3 — the retained derived-file record is never staleness); * - each recorded derived file remaining (anything occupying its path) at * a path the current build no longer generates (`outputs.orphans`) — * undetectable, and so unreported, while the unreadable-record state @@ -160,7 +180,7 @@ export async function stalenessFindings( } else if ( !graphDataMatchesCurrent(stored.bytes, stored.data, outputs.graphData) ) { - findings.push(staleFinding(GRAPH_DATA_PATH, "does not match")); + findings.push(mismatchedGraphDataStaleFinding()); } // SPEC 14.10's recorded-orphan arm: `outputs.orphans` holds the recorded From e43c3d3cad12bc71eb367ce41881a920fcecca20 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 06:19:13 +0000 Subject: [PATCH 167/226] sdg(phase-10): carry source ranges on every present review-payload node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC 10.7/1.7: the self-contained payload of next --json, show, and export presents every present scope, context, and origin node — requirement node and code location alike — with its source range read from the current graph; an absent node carries none. A present code-impact scope now enters as identity + presence + sourceRange (CodeLocationNode.range, no text), and a currently-present origin node's entry carries its current range (the after side is the current graph's). The human origin rendering gains the matching range line. Verified: section-10.7-i/-ii, section-1.6-1.7, section-10.2-10.3 all green (T10.7-7 and T10.7-12 included); typecheck and format clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 17 ++++----- src/cli/commands/review-session.ts | 55 +++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index fc2291d..be8291c 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -372,16 +372,13 @@ finding, concerned path `GRAPH_DATA_AREA` (`.xspec`), locations empty, covering missing and mismatch as one form beside C4's unreadable arm. Section-12.1-12.2, section-13.3, and section-14 files all green.) -### C6. Review payloads carry source ranges for every present node - -SPEC 10.7 (`next --json`, `show`, `export` payload). Prereq B3 (code-location -ranges). `nodeStateJson` (`src/cli/commands/review-session.ts`) returns present -code locations as identity+presence only, and `originEntryJson` carries no -range. Required: every present scope, context, and origin node — requirement -node and code location alike — carries its source range (1.7), read from the -current graph; an absent node carries none. - -Verify: T10.7-7, T10.7-12 (`section-10.7*.test.ts`). +(C6 landed: review payloads carry source ranges for every present node — +`nodeStateJson` (`src/cli/commands/review-session.ts`) presents a present +code-location scope as identity + presence + `sourceRange` +(`CodeLocationNode.range`, B3), and `originEntryJson` puts a +currently-present origin node's `sourceRange` on the entry itself (the +after side is the current graph's; a currently-absent node carries none). +Section-10.7 files, 1.6-1.7, and 10.2-10.3 all green.) ### C7. Full-suite verification sweep diff --git a/src/cli/commands/review-session.ts b/src/cli/commands/review-session.ts index edb6800..e8e3306 100644 --- a/src/cli/commands/review-session.ts +++ b/src/cli/commands/review-session.ts @@ -550,14 +550,16 @@ function absentNodeText( /** * One payload node (SPEC 10.7): identity, presence, the role's text, and — - * for a present requirement node — its source range (1.7). The stored + * for a present graph node, requirement node and code location alike — its + * source range (1.7: review payloads are one of the two range-presenting + * outputs for code locations), read from the current graph. The stored * canonical reference surfaces as its derived current spelling (SPEC 10.4: * every recorded node presented under its current identity), while * presence is judged by canonical resolution (10.4): a dangling reference * — its identity ceased to resolve through the journal — presents absent, * with no source range and the absent-node text rule, even though its * presented spelling matches the distinct node that recaptured it. A code - * location (`selection === "code"`) enters as identity and presence alone. + * location (`selection === "code"`) carries no text value either way. */ function nodeStateJson( view: SessionReadView, @@ -570,11 +572,20 @@ function nodeStateJson( reference, ); if (selection === "code") { - return { - node: spelling, - present: - resolves && view.analysis.graph.codeLocation(spelling) !== undefined, - }; + const location = resolves + ? view.analysis.graph.codeLocation(spelling) + : undefined; + if (location !== undefined) { + // SPEC 10.7/1.7: a present code location enters as identity, + // presence, and its source range — the construct binding the unit's + // name (4.6), the entire file for a whole-file location — no text. + return { + node: spelling, + present: true, + sourceRange: rangeJson(location.range), + }; + } + return { node: spelling, present: false }; } const node = resolves ? view.analysis.graph.requirementNode(spelling) @@ -605,7 +616,10 @@ function nodeStateJson( * the absent side of the pair is presented absent, with no text. The after * side's presence is judged by canonical resolution (SPEC 10.4): a * dangling reference presents absent even though its presented spelling - * matches the distinct node that recaptured it. + * matches the distinct node that recaptured it. Like every payload node, a + * currently-present origin node carries its current source range on the + * entry (SPEC 10.7, 1.7) — the after side is the current graph's, so a + * currently-absent node (absent after side) carries none. */ function originEntryJson( view: SessionReadView, @@ -624,14 +638,18 @@ function originEntryJson( const node = resolves ? view.analysis.graph.requirementNode(spelling) : undefined; - const after: JsonObject = - node === undefined - ? { present: false } - : { - present: true, - text: view.analysis.textModel.ownText(node.document, node.section), - }; - return { node: spelling, before, after }; + if (node === undefined) { + return { node: spelling, before, after: { present: false } }; + } + return { + node: spelling, + before, + after: { + present: true, + text: view.analysis.textModel.ownText(node.document, node.section), + }, + sourceRange: rangeJson(node.section.range), + }; } /** @@ -723,6 +741,11 @@ function renderNodeStateHuman(label: string, state: JsonObject): string { /** One origin before/after pair as human lines (SPEC 10.7). */ function renderOriginHuman(entry: JsonObject): string { let out = ` - ${String(entry["node"])}\n`; + const range = entry["sourceRange"]; + if (range !== undefined) { + const rangeObject = range as JsonObject; + out += ` range: ${String(rangeObject["start"])}-${String(rangeObject["end"])}\n`; + } for (const side of ["before", "after"] as const) { const sideObject = entry[side] as JsonObject; const present = sideObject["present"] === true; From 35acd956d90ba40fae95aa5bf259f98c5989900b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 07:18:20 +0000 Subject: [PATCH 168/226] sdg(phase-10): bound deep-nesting cost across serializer, view, and startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three product fixes from the C7 sweep's two residual reds (P-11, P-12): - src/core/canonical-json.ts: one iterative single-buffer emitter behind canonicalJson and compactJson — linear time in the rendered text (the old per-level join re-copied every subtree, cubic on nesting depth), no native recursion, and pretty indentation bounded at 32 levels. Byte-identical output for every document nested within the bound (verified over view, query, check --json, and .xspec/graph.json on a staged workspace); a depth-4096 fuzz tower's view answer is ~35 MB in ~4 s instead of quadratic-indentation gigabytes past the harness's 64 MB guard. - src/cli/commands/view.ts: the node tree renders iteratively (children before parents over an explicit stack), so a depth-4096 balanced section tower no longer overflows the call stack (SPEC 11.4 answers any parseable nesting). - src/core/ts-module.ts: the TypeScript compiler API loads once through createRequire; the four former `import ts from "typescript"` sites route through it (type positions via `import type * as tst`). Importing the ~8.5 MB CJS file through the ESM loader cost ~0.2 s per invocation in format sniffing and named-export lexing; every configuration-parsing invocation drops ~0.5 s -> ~0.26 s. Verified: typecheck, format:check, build clean; full suite 636/637 — P-12 green at 321 s (was a 600 s timeout, locally and in CI run 33296625151), all previously-green files still green. The one red is P-11, now failing inside the harness: its recursive decodeViewNodeForm overflows on the depth-2048 view tree its own generator draws, after the product answers conformingly — see the FIX_PLAN C7 note; logged separately to TEST-SPEC-PROBLEMS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 2 +- specs/tmp/FIX_PLAN.md | 22 +++++ src/cli/commands/view.ts | 40 +++++++- src/core/canonical-json.ts | 177 ++++++++++++++++++++++++------------ src/core/code-analysis.ts | 107 +++++++++++----------- src/core/config.ts | 29 +++--- src/core/references.ts | 19 ++-- src/core/spec-references.ts | 13 +-- src/core/ts-module.ts | 22 +++++ 9 files changed, 288 insertions(+), 143 deletions(-) create mode 100644 src/core/ts-module.ts diff --git a/AGENTS.md b/AGENTS.md index d35a512..5c8fefe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Requires Node.js >= 22 and npm. Install dependencies: `npm ci`. - One npm package (`xspec`) holding two distinct programs: the product under `src/` and the test harness under `test/`. The harness never imports product code; it drives the built `xspec` executable as a subprocess. - Build the product: `npm run build` — compiles `src/` (TypeScript ESM, `src/tsconfig.json`) to `dist/`; the `xspec` bin is `dist/cli/bin.js`. Run it: `node dist/cli/bin.js`. -- The built product enables Node's on-disk V8 compile cache (`node:module` `enableCompileCache`; default directory under the OS temp dir, e.g. `/tmp/node-compile-cache`). The first invocations after `npm run build` repopulate it, so one-off CLI timings are slower than steady state; the cache affects timing only, never output. Steady-state per-invocation cost is ~0.5s for any command that parses the configuration (the TypeScript compiler module alone takes ~0.3s to load even cache-warm) and ~0.12s for the store-backed fast paths (`query` and `at` on a workspace whose `.xspec/graph.json` verifies against the current bytes) — the number that matters when a test sweeping many CLI invocations nears its timeout. +- The built product enables Node's on-disk V8 compile cache (`node:module` `enableCompileCache`; default directory under the OS temp dir, e.g. `/tmp/node-compile-cache`). The first invocations after `npm run build` repopulate it, so one-off CLI timings are slower than steady state; the cache affects timing only, never output. Steady-state per-invocation cost is ~0.26s for any command that parses the configuration (the TypeScript compiler module is loaded through `createRequire` in `src/core/ts-module.ts` — importing that CJS file through the ESM loader instead costs ~0.2s more per invocation in format sniffing and named-export lexing; keep any new `typescript` use routed through that module) and ~0.12s for the store-backed fast paths (`query` and `at` on a workspace whose `.xspec/graph.json` verifies against the current bytes) — the numbers that matter when a test sweeping many CLI invocations nears its timeout. - Typecheck both programs: `npm run typecheck` (`src/tsconfig.json`, then `test/tsconfig.json`; the harness is not typechecked by Vitest at run time). `test/fixtures/` is excluded from the harness typecheck: fixture projects are data compiled or executed at test run time and may contain deliberate type errors (e.g. the S-4 fixture). - Consumer fixture programs are compiled through the harness's TypeScript tooling driver (`test/helpers/tooling.ts`), which resolves `@types/node` from this repository's own `node_modules` — `npm ci` (dev dependencies included) must have run for consumer compilation to work. - Full test suite (TEST-SPEC sections 1–17, certification included; the Linux CI leg): `npm test`. Build the product first — tests invoke the built executable. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index be8291c..7b0e326 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -391,3 +391,25 @@ artifact; see `AGENTS.md`). Diagnose any residual failure against SPEC.md fix small residues directly, or append precise tasks here for anything larger. Product green + this file emptied ends the phase (delete this file when its last task is removed). + +(2026-08-30 sweep, product side landed: the sweep found two reds, P-11 and +P-12, both rooted in per-invocation cost, and fixed the product half. The +canonical serializer (`src/core/canonical-json.ts`) is now an iterative +single-buffer emitter — linear time, no recursion — with pretty indentation +bounded at 32 levels (`MAX_INDENT_LEVELS`): byte-identical output for every +document nested within the bound (verified over view/query/check/graph-data on +a staged workspace), and a depth-4096 fuzz tower's `view` answer is ~35 MB in +~4 s instead of quadratic-indentation gigabytes; `view`'s node-tree walk +(`src/cli/commands/view.ts` `nodeJson`) builds iteratively, children before +parents, so depth-4096 nesting no longer overflows the call stack. The +TypeScript compiler module loads once through `createRequire` +(`src/core/ts-module.ts`; all four former ESM import sites route through it), +cutting every configuration-parsing invocation ~0.5 s → ~0.26 s — that took +P-12 from a 600 s timeout to 321 s green locally. Remaining red: P-11 alone, +now failing INSIDE THE HARNESS — its recursive `decodeViewNodeForm` +(`test/helpers/adapters/forms.ts`) overflows on the depth-2048 view tree its +own generator draws (CI seed 271828183, trial 1) after the product answers +conformingly; no product change can clear it (SPEC 11.4 mandates the full +tree). Logged 2026-08-30 in `specs/tmp/TEST-SPEC-PROBLEMS.md`. When the +harness-side fix lands, rerun this sweep — expected then: full suite green, +CI legs green, empty this file.) diff --git a/src/cli/commands/view.ts b/src/cli/commands/view.ts index ca50c15..7b3a4ed 100644 --- a/src/cli/commands/view.ts +++ b/src/cli/commands/view.ts @@ -314,12 +314,46 @@ class ViewRenderer { * One node of the positional section tree (SPEC 11.4, 12.7): the * `{"identity", "range", "opening", "closing", "attributes", "tags", * "coverage", "children"}` form plus `"ownText"`/`"subtreeText"` exactly - * when `--text` is given. + * when `--text` is given. The tree is built iteratively — children before + * parents over an explicit stack — so a pathologically deep nesting tower + * cannot exhaust the call stack (the answer covers any parseable file). */ private nodeJson( document: SpecDocument, section: SpecSection, defined: ReadonlySet<SpecSection> | null, + ): JsonObject { + // Pre-order collection (parents before descendants), then a reverse + // build pass so every node's children are built when the node is. + const order: SpecSection[] = []; + const pending: SpecSection[] = [section]; + while (pending.length > 0) { + const current = pending.pop() as SpecSection; + order.push(current); + for (const child of current.children) { + pending.push(child); + } + } + const built = new Map<SpecSection, JsonObject>(); + for (let index = order.length - 1; index >= 0; index -= 1) { + const current = order[index]; + const children = current.children.map( + (child) => built.get(child) as JsonObject, + ); + built.set( + current, + this.sectionJson(document, current, defined, children), + ); + } + return built.get(section) as JsonObject; + } + + /** The one-node body of `nodeJson`, its children already rendered. */ + private sectionJson( + document: SpecDocument, + section: SpecSection, + defined: ReadonlySet<SpecSection> | null, + children: readonly JsonObject[], ): JsonObject { const isRoot = section.parent === null; // SPEC 11.2: the identity datum — the root's is defined exactly when @@ -381,9 +415,7 @@ class ViewRenderer { : section.coverageDefined ? section.coverage : this.unavailable(), - children: section.children.map((child) => - this.nodeJson(document, child, defined), - ), + children, ownText, subtreeText, }; diff --git a/src/core/canonical-json.ts b/src/core/canonical-json.ts index ab7369d..e6a6f6f 100644 --- a/src/core/canonical-json.ts +++ b/src/core/canonical-json.ts @@ -5,6 +5,16 @@ // shared by graph data, sessions, and --json output. SPEC 12.0: all output, // generated files, and stored data are byte-deterministic for identical // input. +// +// The serializer is iterative (an explicit work stack) and appends chunks to +// one output buffer, so time and memory are linear in the rendered text and +// no input nesting depth can exhaust the call stack. Pretty indentation +// deepens two spaces per level up to a fixed bound and stays at that width +// below it: the spelling remains a deterministic function of the value alone +// (SPEC 12.0), every document nested within the bound renders exactly as +// unbounded indentation would, and a pathologically deep value — thousands +// of levels — cannot inflate the document quadratically with indentation +// bytes. import { compareBytes } from "./bytes.js"; @@ -20,14 +30,35 @@ export interface JsonObject { readonly [key: string]: JsonValue | undefined; } +/** + * The bound on indentation depth: nesting levels beyond it keep the + * bound's indentation width. Deeper than any document the surfaces produce + * over realistic sources (a `view` node tree reaches it only past ~13 + * levels of section nesting); the bound exists so adversarially deep + * values (SPEC 12.0 still demands termination with bounded output) render + * in linear size rather than growing quadratically in indentation bytes. + */ +const MAX_INDENT_LEVELS = 32; + +/** Memoized indent strings: INDENTS[k] is min(k, MAX_INDENT_LEVELS) * " ". */ +const INDENTS: string[] = [""]; +function indentAt(level: number): string { + const capped = level < MAX_INDENT_LEVELS ? level : MAX_INDENT_LEVELS; + for (let next = INDENTS.length; next <= capped; next += 1) { + INDENTS[next] = INDENTS[next - 1] + " "; + } + return INDENTS[capped]; +} + /** * Serializes `value` to canonical JSON text: object keys sorted byte-wise * (SPEC 12.0 comparison), array elements in given order, two-space - * indentation, and a trailing newline terminating the document. The output - * is a deterministic function of `value` alone. + * indentation (bounded at MAX_INDENT_LEVELS), and a trailing newline + * terminating the document. The output is a deterministic function of + * `value` alone. */ export function canonicalJson(value: JsonValue): string { - return render(value, "") + "\n"; + return render(value, true) + "\n"; } /** @@ -39,29 +70,7 @@ export function canonicalJson(value: JsonValue): string { * a single line whatever characters it contains. */ export function compactJson(value: JsonValue): string { - const primitive = renderPrimitive(value); - if (primitive !== null) { - return primitive; - } - const composite = value as readonly JsonValue[] | JsonObject; - if (isJsonArray(composite)) { - const items = composite.map((element) => { - if (element === undefined) { - throw new TypeError("undefined array element in canonical JSON"); - } - return compactJson(element); - }); - return "[" + items.join(",") + "]"; - } - const entries: string[] = []; - for (const key of Object.keys(composite).sort(compareBytes)) { - const propertyValue = composite[key]; - if (propertyValue === undefined) { - continue; - } - entries.push(JSON.stringify(key) + ":" + compactJson(propertyValue)); - } - return "{" + entries.join(",") + "}"; + return render(value, false); } /** The rendering of a primitive value, or null for arrays and objects. */ @@ -87,41 +96,97 @@ function renderPrimitive(value: JsonValue): string | null { return null; } -function render(value: JsonValue, indent: string): string { - const primitive = renderPrimitive(value); - if (primitive !== null) { - return primitive; - } - // renderPrimitive returned null, so `value` is an array or an object. - const composite = value as readonly JsonValue[] | JsonObject; - const inner = indent + " "; - if (isJsonArray(composite)) { - if (composite.length === 0) { - return "[]"; +/** + * One pending unit of rendering work: a value to open (with, for pretty + * object entries, its `"key": ` prefix already emitted by the parent), or a + * literal chunk (separators, closers) to append verbatim. + */ +type WorkItem = + | { + readonly kind: "value"; + readonly value: JsonValue; + readonly level: number; } - const items = composite.map((element) => { - if (element === undefined) { - throw new TypeError("undefined array element in canonical JSON"); + | { readonly kind: "chunk"; readonly text: string }; + +function render(root: JsonValue, pretty: boolean): string { + const out: string[] = []; + // A LIFO work stack: items are pushed in reverse so they emit in order. + const stack: WorkItem[] = [{ kind: "value", value: root, level: 0 }]; + while (stack.length > 0) { + const item = stack.pop() as WorkItem; + if (item.kind === "chunk") { + out.push(item.text); + continue; + } + const { value, level } = item; + const primitive = renderPrimitive(value); + if (primitive !== null) { + out.push(primitive); + continue; + } + // renderPrimitive returned null, so `value` is an array or an object. + const composite = value as readonly JsonValue[] | JsonObject; + const inner = level + 1; + if (isJsonArray(composite)) { + if (composite.length === 0) { + out.push("[]"); + continue; + } + for (const element of composite) { + if (element === undefined) { + throw new TypeError("undefined array element in canonical JSON"); + } + } + out.push(pretty ? "[\n" : "["); + const closer = pretty ? "\n" + indentAt(level) + "]" : "]"; + stack.push({ kind: "chunk", text: closer }); + for (let index = composite.length - 1; index >= 0; index -= 1) { + if (index < composite.length - 1) { + stack.push({ kind: "chunk", text: pretty ? ",\n" : "," }); + } + stack.push({ + kind: "value", + value: composite[index] as JsonValue, + level: inner, + }); + if (pretty) { + stack.push({ kind: "chunk", text: indentAt(inner) }); + } } - return inner + render(element, inner); - }); - return "[\n" + items.join(",\n") + "\n" + indent + "]"; - } - const object: JsonObject = composite; - const entries: string[] = []; - for (const key of Object.keys(object).sort(compareBytes)) { - const propertyValue = object[key]; - if (propertyValue === undefined) { continue; } - entries.push( - inner + JSON.stringify(key) + ": " + render(propertyValue, inner), - ); - } - if (entries.length === 0) { - return "{}"; + const object: JsonObject = composite; + const keys: string[] = []; + for (const key of Object.keys(object).sort(compareBytes)) { + if (object[key] !== undefined) { + keys.push(key); + } + } + if (keys.length === 0) { + out.push("{}"); + continue; + } + out.push(pretty ? "{\n" : "{"); + const closer = pretty ? "\n" + indentAt(level) + "}" : "}"; + stack.push({ kind: "chunk", text: closer }); + for (let index = keys.length - 1; index >= 0; index -= 1) { + const key = keys[index]; + if (index < keys.length - 1) { + stack.push({ kind: "chunk", text: pretty ? ",\n" : "," }); + } + stack.push({ + kind: "value", + value: object[key] as JsonValue, + level: inner, + }); + const prefix = pretty + ? indentAt(inner) + JSON.stringify(key) + ": " + : JSON.stringify(key) + ":"; + stack.push({ kind: "chunk", text: prefix }); + } } - return "{\n" + entries.join(",\n") + "\n" + indent + "}"; + return out.join(""); } function isJsonArray( diff --git a/src/core/code-analysis.ts b/src/core/code-analysis.ts index 7d4fa6b..5d7949e 100644 --- a/src/core/code-analysis.ts +++ b/src/core/code-analysis.ts @@ -30,7 +30,8 @@ // and falls under no condition (SPEC 4.5); its value-level misuse is the // consumer's TypeScript error, outside xspec's validations. -import ts from "typescript"; +import ts from "./ts-module.js"; +import type * as tst from "typescript"; import type { ByteRange } from "./bytes.js"; import { Utf8Offsets } from "./bytes.js"; import type { DerivedPathKind } from "./discovery.js"; @@ -299,10 +300,10 @@ function stackOverflowFinding(file: PathText, grammar: string): Finding { * serves only identifier-to-declaration resolution (SPEC 4.5 scoping). */ function createSingleFileProgram( - sourceFile: ts.SourceFile, + sourceFile: tst.SourceFile, tsx: boolean, -): ts.Program { - const options: ts.CompilerOptions = { +): tst.Program { + const options: tst.CompilerOptions = { noLib: true, noResolve: true, // SPEC 14.20/7: grammar selection is by file name alone — `.tsx` as @@ -314,7 +315,7 @@ function createSingleFileProgram( target: ts.ScriptTarget.Latest, ...(tsx ? { jsx: ts.JsxEmit.Preserve } : {}), }; - const host: ts.CompilerHost = { + const host: tst.CompilerHost = { getSourceFile: (name) => name === sourceFile.fileName ? sourceFile : undefined, getDefaultLibFileName: () => "lib.d.ts", @@ -332,10 +333,10 @@ function createSingleFileProgram( /** The 14.20 finding for a parse failure, locating it (SPEC 14.20). */ function parseFailureFinding( file: PathText, - sourceFile: ts.SourceFile, + sourceFile: tst.SourceFile, offsets: Utf8Offsets, tsx: boolean, - diagnostic: ts.DiagnosticWithLocation, + diagnostic: tst.DiagnosticWithLocation, ): Finding { const reason = ts.flattenDiagnosticMessageText(diagnostic.messageText, " "); const grammar = tsx ? "TSX" : "plain TypeScript"; @@ -427,10 +428,10 @@ type TrackedBinding = interface BoundName { readonly name: string; /** The binding's declaration node (the checker resolves uses to it). */ - readonly declaration: ts.Node; + readonly declaration: tst.Node; /** Whether the binding's import is a spec module import. */ readonly spec: boolean; - readonly statement: ts.Statement; + readonly statement: tst.Statement; } class CodeAnalyzer { @@ -439,16 +440,16 @@ class CodeAnalyzer { private readonly imports: CodeImport[] = []; private readonly units: CodeUnit[] = []; /** Declaration node → what a use resolving to it means (SPEC 4.5). */ - private readonly declarations = new Map<ts.Node, TrackedBinding>(); + private readonly declarations = new Map<tst.Node, TrackedBinding>(); /** Named-unit construct → its unit (attribution, SPEC 4.6). */ - private readonly unitByNode = new Map<ts.Node, CodeUnit>(); + private readonly unitByNode = new Map<tst.Node, CodeUnit>(); constructor( private readonly path: string, private readonly file: PathText, - private readonly sourceFile: ts.SourceFile, + private readonly sourceFile: tst.SourceFile, private readonly offsets: Utf8Offsets, - private readonly checker: ts.TypeChecker, + private readonly checker: tst.TypeChecker, private readonly context: CodeAnalysisContext, ) {} @@ -472,7 +473,7 @@ class CodeAnalyzer { // -- shared helpers ------------------------------------------------------- /** The node's own characters as a byte range (SPEC 1.7 offsets). */ - private rangeOf(node: ts.Node): ByteRange { + private rangeOf(node: tst.Node): ByteRange { return { start: this.offsets.byteOffset(node.getStart(this.sourceFile)), end: this.offsets.byteOffset(node.getEnd()), @@ -481,7 +482,7 @@ class CodeAnalyzer { private addFinding( condition: 7 | 8 | 11 | 15 | 18, - node: ts.Node, + node: tst.Node, message: string, identities: readonly string[] = [], ): void { @@ -497,7 +498,7 @@ class CodeAnalyzer { /** The tracked binding a resolved symbol belongs to, if any. */ private bindingOfSymbol( - symbol: ts.Symbol | undefined, + symbol: tst.Symbol | undefined, ): TrackedBinding | undefined { for (const declaration of symbol?.declarations ?? []) { const binding = this.declarations.get(declaration); @@ -508,7 +509,7 @@ class CodeAnalyzer { /** Resolve one use-site identifier through TypeScript scoping (SPEC 4.5). */ private bindingOfIdentifier( - identifier: ts.Identifier, + identifier: tst.Identifier, ): TrackedBinding | undefined { const parent = identifier.parent; const symbol = @@ -522,9 +523,9 @@ class CodeAnalyzer { * SPEC 4.6: the innermost enclosing named code unit's identity, or the * file when none encloses the node. */ - private attributionOf(node: ts.Node): string { + private attributionOf(node: tst.Node): string { for ( - let current: ts.Node | undefined = node.parent; + let current: tst.Node | undefined = node.parent; current !== undefined; current = current.parent ) { @@ -614,7 +615,7 @@ class CodeAnalyzer { else entries.push(entry); } for (const [name, entries] of byName) { - const statements: ts.Statement[] = []; + const statements: tst.Statement[] = []; for (const entry of entries) { if (!statements.includes(entry.statement)) { statements.push(entry.statement); @@ -653,7 +654,7 @@ class CodeAnalyzer { * other import declaration is checked against the derived-path rule. */ private scanImportDeclaration( - statement: ts.ImportDeclaration, + statement: tst.ImportDeclaration, bound: BoundName[], ): void { const literal = statement.moduleSpecifier; @@ -747,7 +748,7 @@ class CodeAnalyzer { const textBindings: CodeImportBinding[] = []; /** Registered once validity is known: declaration → role. */ const roles: { - declaration: ts.Node; + declaration: tst.Node; binding: CodeImportBinding; role: "node" | "text"; }[] = []; @@ -842,7 +843,7 @@ class CodeAnalyzer { * module's nodes or `text` past 4.5. Other module specifiers are * checked against the derived-path rule. */ - private scanExportDeclaration(statement: ts.ExportDeclaration): void { + private scanExportDeclaration(statement: tst.ExportDeclaration): void { const literal = statement.moduleSpecifier; if (literal === undefined || !ts.isStringLiteral(literal)) return; if (literal.text.endsWith(XSPEC_SUFFIX)) { @@ -870,7 +871,7 @@ class CodeAnalyzer { * (`import X = A.B`) is a use of `A`, handled in the use walk. */ private scanImportEquals( - statement: ts.ImportEqualsDeclaration, + statement: tst.ImportEqualsDeclaration, bound: BoundName[], ): void { const reference = statement.moduleReference; @@ -912,7 +913,7 @@ class CodeAnalyzer { */ private checkDerivedSpecifier( specifier: string, - at: ts.Node, + at: tst.Node, formLabel: string, ): void { if (!specifier.startsWith("./") && !specifier.startsWith("../")) return; @@ -954,9 +955,9 @@ class CodeAnalyzer { * on repeated chains (SPEC 4.6). */ private collectUnits(): void { - const records: { node: ts.Node; chain: string; start: number }[] = []; + const records: { node: tst.Node; chain: string; start: number }[] = []; const visit = ( - node: ts.Node, + node: tst.Node, enclosing: readonly string[], ambient: boolean, ): void => { @@ -1017,12 +1018,12 @@ class CodeAnalyzer { * `path#unit@N` simply carries its own occurrence's construct, which is * the node recorded for it. */ - private unitRange(node: ts.Node): ByteRange { + private unitRange(node: tst.Node): ByteRange { if (ts.isModuleDeclaration(node)) { // A dotted name (`namespace A.B`) nests declarations: an inner one // is its parent declaration's body. Climb to the chain's outermost // declaration — the single construct binding every derived unit. - let outer: ts.ModuleDeclaration = node; + let outer: tst.ModuleDeclaration = node; while ( ts.isModuleDeclaration(outer.parent) && outer.parent.body === outer @@ -1069,7 +1070,7 @@ class CodeAnalyzer { // -- value-level use analysis (SPEC 4.3, 4.5 → 14.8, 14.11, 14.18) -------- - private walk(node: ts.Node): void { + private walk(node: tst.Node): void { // Module-linking constructs were validated in scanModuleLinks; their // identifiers are bindings or foreign-module names, never local uses. if (ts.isImportDeclaration(node)) return; @@ -1120,7 +1121,7 @@ class CodeAnalyzer { * derived-file path (13.4); a dynamic `import()` whose specifier is not * static is not analyzed and records nothing. */ - private visitImportCall(call: ts.CallExpression): void { + private visitImportCall(call: tst.CallExpression): void { const argument = call.arguments[0]; if (argument === undefined || !ts.isStringLiteral(argument)) return; if (argument.text.endsWith(XSPEC_SUFFIX)) { @@ -1141,7 +1142,7 @@ class CodeAnalyzer { * re-exporting a spec module binding is an unsanctioned value-level use * (SPEC 4.5 → 14.18); type-only forms are type-level and unrestricted. */ - private visitExportSpecifiers(declaration: ts.ExportDeclaration): void { + private visitExportSpecifiers(declaration: tst.ExportDeclaration): void { if (declaration.isTypeOnly) return; const clause = declaration.exportClause; if (clause === undefined || !ts.isNamedExports(clause)) return; @@ -1174,9 +1175,9 @@ class CodeAnalyzer { * value-level use of a spec module binding (SPEC 4.5 → 14.18). The * require form was handled by scanModuleLinks. */ - private visitImportEqualsUse(declaration: ts.ImportEqualsDeclaration): void { + private visitImportEqualsUse(declaration: tst.ImportEqualsDeclaration): void { if (ts.isExternalModuleReference(declaration.moduleReference)) return; - let name: ts.EntityName = declaration.moduleReference; + let name: tst.EntityName = declaration.moduleReference; while (ts.isQualifiedName(name)) name = name.left; const binding = this.bindingOfSymbol( this.checker.getSymbolAtLocation(name), @@ -1197,7 +1198,7 @@ class CodeAnalyzer { } /** One identifier: a spec binding use, or nothing (SPEC 4.5). */ - private visitIdentifier(identifier: ts.Identifier): void { + private visitIdentifier(identifier: tst.Identifier): void { if (!isValueUseSite(identifier)) return; const binding = this.bindingOfIdentifier(identifier); if (binding === undefined) return; // not a spec module reference @@ -1221,7 +1222,7 @@ class CodeAnalyzer { * position). */ private visitNodeBindingUse( - identifier: ts.Identifier, + identifier: tst.Identifier, binding: { readonly kind: "node"; readonly target: SpecModuleTarget }, ): void { const use = climbUseExpression(identifier); @@ -1291,13 +1292,13 @@ class CodeAnalyzer { } /** Whether `use` sits in argument position of a `text`-callee call. */ - private isTextCallArgument(use: ts.Expression): boolean { - let argument: ts.Node = use; + private isTextCallArgument(use: tst.Expression): boolean { + let argument: tst.Node = use; if (ts.isSpreadElement(argument.parent)) argument = argument.parent; const call = argument.parent; if ( !ts.isCallExpression(call) || - (call.expression as ts.Node) === argument + (call.expression as tst.Node) === argument ) { return false; } @@ -1320,7 +1321,7 @@ class CodeAnalyzer { * position too, recording its `embeds` edge (4.3) — never a marker. */ private visitTextBindingUse( - identifier: ts.Identifier, + identifier: tst.Identifier, binding: { readonly kind: "text"; readonly target: SpecModuleTarget }, ): void { const parent = identifier.parent; @@ -1343,7 +1344,7 @@ class CodeAnalyzer { * form is MDX-only (4.3 → 14.8); a cross-module node is 14.11 (4.4). */ private analyzeTextCall( - call: ts.CallExpression, + call: tst.CallExpression, calleeBinding: { readonly kind: "text"; readonly target: SpecModuleTarget }, ): void { if (call.questionDotToken !== undefined) { @@ -1509,7 +1510,7 @@ class CodeAnalyzer { // --------------------------------------------------------------------------- /** Whether the node carries the given modifier keyword. */ -function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean { +function hasModifier(node: tst.Node, kind: tst.SyntaxKind): boolean { return ( ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === kind) @@ -1517,7 +1518,7 @@ function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean { } /** Whether the expression is a function, arrow, or class expression. */ -function isFunctionOrClassExpression(expression: ts.Expression): boolean { +function isFunctionOrClassExpression(expression: tst.Expression): boolean { return ( ts.isFunctionExpression(expression) || ts.isArrowFunction(expression) || @@ -1525,7 +1526,7 @@ function isFunctionOrClassExpression(expression: ts.Expression): boolean { ); } -function stripParentheses(expression: ts.Expression): ts.Expression { +function stripParentheses(expression: tst.Expression): tst.Expression { let current = expression; while (ts.isParenthesizedExpression(current)) current = current.expression; return current; @@ -1540,9 +1541,9 @@ function stripParentheses(expression: ts.Expression): ts.Expression { * sibling token would overshoot it. */ function firstTokenStartAfter( - node: ts.Node, + node: tst.Node, boundary: number, - sourceFile: ts.SourceFile, + sourceFile: tst.SourceFile, ): number { for (const child of node.getChildren(sourceFile)) { if (child.getEnd() <= boundary) continue; @@ -1570,7 +1571,7 @@ function firstTokenStartAfter( * `default` when the exported construct is anonymous. Signature-only * declarations (overloads, abstract members) bind no executable code. */ -function unitName(node: ts.Node): string | null { +function unitName(node: tst.Node): string | null { if (ts.isFunctionDeclaration(node)) { if (node.body === undefined) return null; if (node.name !== undefined) return node.name.text; @@ -1624,7 +1625,7 @@ function unitName(node: ts.Node): string | null { * name, declaration name, label, or other non-reference role. Type * positions never reach this test: the walk does not descend into them. */ -function isValueUseSite(identifier: ts.Identifier): boolean { +function isValueUseSite(identifier: tst.Identifier): boolean { const parent = identifier.parent; if (ts.isPropertyAccessExpression(parent) && parent.name === identifier) { return false; @@ -1690,10 +1691,10 @@ function isValueUseSite(identifier: ts.Identifier): boolean { * reference dynamic). The result is the expression classified as marker * (SPEC 4.5), `text` argument, or unsanctioned use. */ -function climbUseExpression(identifier: ts.Identifier): ts.Expression { - let use: ts.Expression = identifier; +function climbUseExpression(identifier: tst.Identifier): tst.Expression { + let use: tst.Expression = identifier; for (;;) { - const parent: ts.Node = use.parent; + const parent: tst.Node = use.parent; if ( (ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) && @@ -1717,8 +1718,8 @@ function climbUseExpression(identifier: ts.Identifier): ts.Expression { } /** The leftmost root identifier of a chain-shaped expression, if any. */ -function leftmostIdentifier(expression: ts.Expression): ts.Identifier | null { - let node: ts.Expression = expression; +function leftmostIdentifier(expression: tst.Expression): tst.Identifier | null { + let node: tst.Expression = expression; for (;;) { if (ts.isIdentifier(node)) return node; if ( diff --git a/src/core/config.ts b/src/core/config.ts index 0c158df..0d03ee3 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -18,7 +18,8 @@ // `Configuration` or to condition-14 findings. Locating and reading the // file is the workspace layer's (src/workspace/config.ts). -import ts from "typescript"; +import ts from "./ts-module.js"; +import type * as tst from "typescript"; import type { Finding } from "./findings.js"; import { pathFinding } from "./findings.js"; import type { CompiledGlob } from "./glob.js"; @@ -158,7 +159,7 @@ class ConfigFindings { // --------------------------------------------------------------------------- /** 1-based line of a node's start, for actionable findings (SPEC 14). */ -function lineOf(node: ts.Node, sourceFile: ts.SourceFile): number { +function lineOf(node: tst.Node, sourceFile: tst.SourceFile): number { return ( sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 ); @@ -211,11 +212,11 @@ const FORM_EXPECTATION = * argument expression, or null after reporting the deviations. */ function checkForm( - sourceFile: ts.SourceFile, + sourceFile: tst.SourceFile, findings: ConfigFindings, -): ts.Expression | null { - let importDecl: ts.ImportDeclaration | undefined; - let exportAssign: ts.ExportAssignment | undefined; +): tst.Expression | null { + let importDecl: tst.ImportDeclaration | undefined; + let exportAssign: tst.ExportAssignment | undefined; let ok = true; for (const statement of sourceFile.statements) { if (importDecl === undefined && ts.isImportDeclaration(statement)) { @@ -253,8 +254,8 @@ function checkForm( * "xspec", optionally aliased. Returns the local binding name, or null. */ function checkImport( - decl: ts.ImportDeclaration, - sourceFile: ts.SourceFile, + decl: tst.ImportDeclaration, + sourceFile: tst.SourceFile, findings: ConfigFindings, ): string | null { let ok = true; @@ -343,11 +344,11 @@ function checkImport( * exactly one (sole) argument. Returns the argument expression, or null. */ function checkExport( - decl: ts.ExportAssignment, + decl: tst.ExportAssignment, binding: string | null, - sourceFile: ts.SourceFile, + sourceFile: tst.SourceFile, findings: ConfigFindings, -): ts.Expression | null { +): tst.Expression | null { if (decl.isExportEquals === true) { findings.add( `\`export =\` is not the declarative form — use ` + @@ -432,7 +433,7 @@ interface ObjectNode { } /** Names the rejected expression form in "not statically literal" findings. */ -function describeExpression(expr: ts.Expression): string { +function describeExpression(expr: tst.Expression): string { if (ts.isNumericLiteral(expr) || ts.isBigIntLiteral(expr)) { return "a number literal"; } @@ -462,8 +463,8 @@ const LITERAL_EXPECTATION = * part failed. */ function reduceLiteral( - expr: ts.Expression, - sourceFile: ts.SourceFile, + expr: tst.Expression, + sourceFile: tst.SourceFile, findings: ConfigFindings, ): ConfigNode | null { const line = lineOf(expr, sourceFile); diff --git a/src/core/references.ts b/src/core/references.ts index 3d8878a..c9da773 100644 --- a/src/core/references.ts +++ b/src/core/references.ts @@ -17,7 +17,8 @@ // (the text of the `sourceFile` handed in); callers translate them into // document byte ranges (SPEC 1.7). -import ts from "typescript"; +import ts from "./ts-module.js"; +import type * as tst from "typescript"; /** * A half-open span of UTF-16 code-unit offsets into the analyzed source @@ -108,14 +109,14 @@ export type ClassifiedReference = ClassifiedString | ClassifiedChain | ClassifiedDynamic; /** The span of a node's own characters (leading trivia excluded). */ -function spanOf(node: ts.Node, sourceFile: ts.SourceFile): TextSpan { +function spanOf(node: tst.Node, sourceFile: tst.SourceFile): TextSpan { return { start: node.getStart(sourceFile), end: node.getEnd() }; } /** The quote character a string literal was written with. */ function quoteOf( - literal: ts.StringLiteral, - sourceFile: ts.SourceFile, + literal: tst.StringLiteral, + sourceFile: tst.SourceFile, ): '"' | "'" { const quote = sourceFile.text[literal.getStart(sourceFile)]; if (quote !== '"' && quote !== "'") { @@ -134,8 +135,8 @@ function quoteOf( * parentheses, and any other index or expression form. */ export function classifyReference( - expression: ts.Expression, - sourceFile: ts.SourceFile, + expression: tst.Expression, + sourceFile: tst.SourceFile, ): ClassifiedReference { const whole = spanOf(expression, sourceFile); const dynamic = (reason: string): ClassifiedDynamic => ({ @@ -167,7 +168,7 @@ export function classifyReference( // Walk a candidate property chain from the outermost access inward // (SPEC 2.4); segments are collected outermost-first and reversed. const collected: ClassifiedSegment[] = []; - let node: ts.Expression = expression; + let node: tst.Expression = expression; for (;;) { if (ts.isIdentifier(node)) { return { @@ -255,8 +256,8 @@ export function classifyReference( * into `text`. */ export function parseExpressionText(text: string): { - readonly sourceFile: ts.SourceFile; - readonly expression: ts.Expression | null; + readonly sourceFile: tst.SourceFile; + readonly expression: tst.Expression | null; } { const sourceFile = ts.createSourceFile( "xspec-expression.ts", diff --git a/src/core/spec-references.ts b/src/core/spec-references.ts index 3d12c44..4143ad7 100644 --- a/src/core/spec-references.ts +++ b/src/core/spec-references.ts @@ -24,7 +24,8 @@ // every identity of such a file is undefined (SPEC 11.2) — a condition // decidable per file, so their 14.5/14.6 is reported here directly. -import ts from "typescript"; +import ts from "./ts-module.js"; +import type * as tst from "typescript"; import type { ByteRange } from "./bytes.js"; import { Utf8Offsets } from "./bytes.js"; import type { Finding } from "./findings.js"; @@ -370,7 +371,7 @@ class SpanTranslator { } /** Every identifier an import clause binds, in written order. */ -function boundIdentifiers(clause: ts.ImportClause | undefined): string[] { +function boundIdentifiers(clause: tst.ImportClause | undefined): string[] { if (clause === undefined) { return []; } @@ -610,10 +611,10 @@ export function analyzeSpecImports( /** The parsed shape of one recorded import statement's exact text. */ interface ParsedImport { - readonly sourceFile: ts.SourceFile; - readonly importClause: ts.ImportClause | undefined; - readonly moduleSpecifier: ts.Expression; - readonly attributes: ts.ImportAttributes | undefined; + readonly sourceFile: tst.SourceFile; + readonly importClause: tst.ImportClause | undefined; + readonly moduleSpecifier: tst.Expression; + readonly attributes: tst.ImportAttributes | undefined; } /** Re-parse one import declaration's exact text (positions are local). */ diff --git a/src/core/ts-module.ts b/src/core/ts-module.ts new file mode 100644 index 0000000..3e3e83e --- /dev/null +++ b/src/core/ts-module.ts @@ -0,0 +1,22 @@ +// The one load of the TypeScript compiler API (IMPLEMENTATION: TypeScript +// parsing, analysis, and emission go through the `typescript` package). +// +// The package ships as a single ~8.5 MB CommonJS file. Importing it through +// the ESM loader makes Node format-sniff and CJS-lex the whole file on every +// process start to synthesize named exports — ~200ms per invocation on top +// of the require itself. Loading it through `createRequire` skips that +// interop entirely (the module is CJS; requiring it is the direct path) and +// roughly halves the cost of every configuration-parsing invocation, which +// matters for surfaces answered once per CLI run (SPEC 11: `at` sweeps run +// the whole path per offset). Same module instance, same API, loaded once +// per process either way. + +import { createRequire } from "node:module"; +import type TsModule from "typescript"; + +const require = createRequire(import.meta.url); + +/** The TypeScript compiler API namespace (the package's CJS export). */ +const ts: typeof TsModule = require("typescript") as typeof TsModule; + +export default ts; From e239f0c7a702a4460b35dac99a71e3cbb73cb192 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 07:18:21 +0000 Subject: [PATCH 169/226] sdg(phase-10): log P-11 harness-decoder blockage to TEST-SPEC problems No product change can clear P-11: the harness's recursive view decoder overflows its call stack on the conforming depth-2048 answer its own generator provokes (CI seed 271828183, trial 1), a harness-implementation defect by the property machinery's own H-8 classification. Details, exact reproduction, and evidence in the entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/TEST-SPEC-PROBLEMS.md | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 specs/tmp/TEST-SPEC-PROBLEMS.md diff --git a/specs/tmp/TEST-SPEC-PROBLEMS.md b/specs/tmp/TEST-SPEC-PROBLEMS.md new file mode 100644 index 0000000..af4aa2a --- /dev/null +++ b/specs/tmp/TEST-SPEC-PROBLEMS.md @@ -0,0 +1,45 @@ +# TEST-SPEC problems + +## 2026-08-30 — P-11: the harness's recursive view decoder cannot decode the conforming answers P-11's own generator provokes + +**Where:** TEST-SPEC.md §16 P-11 ("Availability robustness", reusing P-8's +generators, whose mutation menu TEST-SPEC describes as including "giant +nesting"); harness implementation `test/helpers/adapters/forms.ts` +(`decodeViewNodeForm`) as exercised by `test/suite/registry/section-16-p11.ts`. + +**What happens:** P-8's shared mutation menu draws balanced section towers of +depth 512, 2048, or 4096 (`test/suite/registry/section-16-p8.ts`, +`NESTING_DEPTHS`). SPEC 11.4 requires `view` to answer with "the root node and +the full section tree", so a conforming product's answer for a depth-2048 +tower is a JSON node tree nested 2048 `children` levels deep. The harness's +form decoder `decodeViewNodeForm` recurses natively per level and overflows +the call stack on that answer (`RangeError: Maximum call stack size +exceeded`), which the property machinery classifies — by its own H-8 wording — +as "a defect in the harness, not a diagnosed assertion failure", failing the +test. CI seed 271828183 hits this deterministically at trial 1 (mutations: +`specs/B.mdx: append a depth-2048 balanced section tower`, `src/app.ts: insert +UTF-16LE BOM at 0`; first arm `view`). + +**Why no product change can clear it:** the product now terminates promptly +with one complete, bounded JSON document at every generator depth (verified at +512/2048/4096 and with three stacked towers: worst observed answer ~35 MB in +~4 s against the harness's 64 MB / 10 s guards). The overflow happens inside +the harness *after* a conforming answer is produced; SPEC 11.4 forbids the +product any shallower answer form, and the harness's form-exact decoder pins +the nested `children` encoding. Every conforming product fails P-11 until the +harness decodes deep trees without native recursion (or otherwise handles the +depth its own generator draws). + +**Note on classification:** TEST-SPEC.md's P-11 text itself is satisfiable and +consistent; the blocking defect is in the harness implementation of the form +decoders (Phase 9 scope, which Phase 10 must not touch). Logged here because +this file is Phase 10's channel for problems in the test system. The harness's +other recursive walkers over answer documents (e.g. P-11's +`documentCarriesUnavailability`) have the same exposure once decoding +survives. + +**Evidence:** local run of `npx vitest run --config test/vitest.config.ts +--project suite test/suite/section-16-p11.test.ts` at product commit +(post-fix): fails with the harness-error message above. Product-side +reproduction of the previously-hanging invocation (depth-2048 tower + BOM +file, bare `view`) now completes in ~0.9 s with a 17.9 MB document, exit 1. From 7cf2f050c0690e1d0a1a02da51d4c8962da9da08 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 07:40:42 +0000 Subject: [PATCH 170/226] sdg(phase-10): record CI leg verdicts and the artifact-upload skip in C7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 33298889697 (e239f0c): harness-self green, suite-linux 636/637 (P-11's harness error alone), Windows E-6 8/9 — its byte-identity arm starved because the suite-linux upload step skips on any test failure (no `if: always()`), which self-heals once suite-linux is green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 7b0e326..4ac171f 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -410,6 +410,12 @@ now failing INSIDE THE HARNESS — its recursive `decodeViewNodeForm` (`test/helpers/adapters/forms.ts`) overflows on the depth-2048 view tree its own generator draws (CI seed 271828183, trial 1) after the product answers conformingly; no product change can clear it (SPEC 11.4 mandates the full -tree). Logged 2026-08-30 in `specs/tmp/TEST-SPEC-PROBLEMS.md`. When the -harness-side fix lands, rerun this sweep — expected then: full suite green, -CI legs green, empty this file.) +tree). Logged 2026-08-30 in `specs/tmp/TEST-SPEC-PROBLEMS.md`. CI at e239f0c (run +33298889697) confirms: harness-self green; suite-linux 636/637 with P-11's +harness error the sole red; Windows E-6 leg 8/9, its byte-identity arm +starved of the exchange artifact — the suite-linux upload step has no +`if: always()`, so any Linux test failure skips the upload (0 artifacts on +the run); that wiring self-heals the moment suite-linux is green, so the +Windows byte-identity signal arrives only then. When the harness-side fix +lands, rerun this sweep — expected then: full suite green, all three CI +legs green, empty this file.) From 22e5eadcd7f1ef9dfff0915e52698dee8101914f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 07:53:11 +0000 Subject: [PATCH 171/226] sdg(phase-6): mandate harness answer-scale capacity (H-11, S-8); pin giant-nesting floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied REVIEW.md items: - C1 (Critical): added H-11 to section 0 — the harness must decode and evaluate every conforming answer at the scale its own staged inputs provoke (decoding and all per-datum traversals alike), with harness-side failures reported as harness defects, never product diagnoses or passes; added S-8 to section 17 gating that capacity against synthetic conforming-form documents at maximum staged scale before any product exists. No section 16 test content or SPEC-facing assertion touched. - I1 (Important): pinned a test-strength floor on P-8's giant-nesting mutation class (staged draws include nesting at least 2048 levels deep), inherited by P-11 through P-8's generators. Rejected: none. Resolved specs/tmp/TEST-SPEC-PROBLEMS.md (2026-08-30 P-11 entry): the document now states the obligation whose absence admitted the harness decoder defect and gates it red-green; the P-11/P-8 test text stands unweakened. Problems file deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 4 ++- specs/tmp/TEST-SPEC-PROBLEMS.md | 45 --------------------------------- 2 files changed, 3 insertions(+), 46 deletions(-) delete mode 100644 specs/tmp/TEST-SPEC-PROBLEMS.md diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index a4bd4cc..0fa005b 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -26,6 +26,7 @@ These requirements bind the harness implementation regardless of test framework * **H-8 Red-green compatibility.** The full suite MUST be runnable when no product is installed (or against a deliberately empty stub): every product-facing test fails with a diagnosed assertion failure — never a harness crash, hang, or false pass. Self-tests (17) and certification MUST pass before the product exists. * **H-9 No skips.** A test that cannot run in GitHub CI is implemented as a local-only test, executed by the local suite; it is never marked skipped. The local-only set is currently empty (18). * **H-10 Time and randomness.** The harness introduces no wall-clock or randomness dependence into assertions; fuzz/property tests (16) use seeded, reproducible generators and report the seed on failure. +* **H-11 Answer-scale capacity.** For every input the suite stages — deterministic fixtures and generator draws (16) alike — the harness MUST decode and evaluate every answer SPEC.md permits a conforming product to give at that input's scale, nesting depth and document size included: the H-3/12.7 decoding and every subsequent per-datum traversal of an answer document (16's property walks included) succeed, with harness-internal capacity limits dimensioned to the scales the suite itself stages. A harness-side failure while evaluating an answer — a crash, hang, or exhausted internal limit — is reported as a defect in the harness, never as a diagnosed product failure and never as a pass: H-8's rule generalized beyond the missing-product run, H-3's fail-loudly rule beyond absent information. S-8 (17) gates this capacity before any product exists. ## 1. Core Concepts @@ -568,7 +569,7 @@ Property tests generate inputs from seeded, reproducible generators (H-10), asse * **P-5 Rename/move purity; section-move categories.** Random valid workspaces, random journaled rename/file-move sequences: all hashes byte-stable, impact against any prior commit in the sequence reports no categories, and all references still resolve. Random section moves: impact against a baseline committed immediately before the move equals an oracle of 6.2/5.6, anchored by T6.2-3/T6.2-4. The oracle's `changed` set is drawn from exactly the origin parent, the target parent, and the moved subtree's nodes — each `changed` iff its own content sequence (1.6) differs across the move: distinct parents necessarily (one loses a child reference, one gains one; a created target file's root, present on no baseline side, is instead `changed` as an added node — by addition, not comparison — and per 5.6 carries no other category), a coincident parent iff the re-insertion fails to reproduce its sequence (a final child re-inserted at its own former position is pure in effect, 6.2), a moved-subtree node iff the straddling-line drops of 6.2 change its runs, computed by the line-drop rules of 3 (P-2's oracle) — with `metadata-changed` on no node (6.2: every moved node keeps its metadataHash, and canonical identities preserve every other node's), `descendant-changed` and `upstream-changed` exactly per 5.6's cascades from the changed nodes, attributions included, and no node carrying any category the oracle does not predict. * **P-6 Baseline replay.** Random edit/rename/move/commit interleavings: impact categories against each historical baseline equal an oracle diff of the two graphs with identities mapped through the journal suffix. * **P-7 Glob and capture matching.** Random patterns and paths over the 7/7.5 grammar, with generators including the glob metacharacters of common dialects (`[` `]` `{` `}` `!` `+` `(` `)`), which the 7 grammar treats as literals, and `$` forms at the capture boundary (`$0`, `$` before a non-digit, trailing `$` — literals in 7.5 patterns; T7.5-5): match decisions and capture values equal a spec oracle; every match is unique under the left-to-right shortest-match rule; captures never span `/` and never match empty. -* **P-8 Parser robustness.** Fuzzed byte inputs (mutated MDX/TS/config, invalid UTF-8, BOMs, giant nesting, pathological line terminators): every command terminates, never emits a partial JSON document on `--json`, and always exits 0, 1, or 2 per the 12.0 partition; `build` failures modify nothing. +* **P-8 Parser robustness.** Fuzzed byte inputs (mutated MDX/TS/config, invalid UTF-8, BOMs, giant nesting, pathological line terminators): every command terminates, never emits a partial JSON document on `--json`, and always exits 0, 1, or 2 per the 12.0 partition; `build` failures modify nothing. The giant-nesting mutation class carries a test-strength floor: its staged draws MUST include section nesting at least 2048 levels deep — a floor on staged inputs, not a product bound (SPEC.md bounds no nesting depth; H-11 dimensions the harness to the staged scale). * **P-9 Review session invariants.** Random sequences of valid review operations (create/next/resolve/split/re-derive triggers) interleaved with workspace edits: at most one item per kind and scope node; `blockedBy` acyclic; retired `id`s never reused; `next` always returns an unblocked needing-review item or reports fully resolved; reads never change session bytes; stored sessions always re-read as non-corrupt. * **P-10 Concurrency.** Randomized schedules of concurrent readers and one mutating command (via `--test-hold` and process kills): readers observe only prior-or-complete file states (T13.5-5); mutual exclusion never loses a journal append or a resolution (post-hoc: journal lines = successful `rename`/`move` operations — the journal's only writers, 6.1; session statuses = successful resolves). * **P-11 Availability robustness.** Fuzzed and mutated spec and code sources (P-8's generators — the availability contract is precisely an imperfect-input surface) driven through `occurrences`, `view` (with and without `--text`), and `at` at random offsets: every invocation terminates; stdout is one complete JSON document, never partial; the exit is 0 or 1 per 11.2 (2 only for staged argument errors); every datum is exactly one of plain value, `null`, or `{"unavailable": true}` (11.4, 12.7); any finding or unavailable datum implies exit 1 with the full document emitted, and exit 0 implies a finding-free document carrying none. @@ -588,6 +589,7 @@ Confidence that the harness itself is correct comes primarily from certification * **S-5 Output adapters.** Each adapter (H-3) rejects documents missing required information (fed synthetic wrong-shape documents) rather than defaulting. * **S-6 Oracles.** The Markdown oracle (P-2), the glob/capture oracle (P-7), the coverage-reachability oracle (P-13), the section-move category oracle (P-5), and the baseline graph-diff oracle (P-6) pass their own fixed vector suites before being trusted by property tests, each derived from SPEC.md's worked material: 3, 7.5, and 15 respectively for the first three; 6.2's worked straddling-line case plus the clean-boundary and final-position cases of T6.2-3/T6.2-4 for the section-move oracle; 5.6's three worked examples plus the added/deleted convention of T5.6-6 for the graph-diff oracle. * **S-7 Red-green sweep.** Against an empty stub product (every command exits with an unexpected code and no output), every product-facing test fails with a diagnosed assertion and the suite completes without harness errors (H-8). +* **S-8 Answer-scale capacity.** The H-3/12.7 decoders and every answer-document walk the suite performs succeed, every datum evaluated without harness error, on synthetic conforming-form documents at the maximum scale the suite's own generators stage (16) — among them a `view` document nested at least as deep as P-8's giant-nesting floor — so the capacity H-11 requires is gated and regression-guarded before any product exists (H-8's ordering). Certification cannot exercise this class: a harness-side failure against a conforming answer is a spurious fail, not a vacuous pass or a missed deviation, so no CERTIFICATIONS.md fixture reaches it. ## 18. Execution and CI diff --git a/specs/tmp/TEST-SPEC-PROBLEMS.md b/specs/tmp/TEST-SPEC-PROBLEMS.md deleted file mode 100644 index af4aa2a..0000000 --- a/specs/tmp/TEST-SPEC-PROBLEMS.md +++ /dev/null @@ -1,45 +0,0 @@ -# TEST-SPEC problems - -## 2026-08-30 — P-11: the harness's recursive view decoder cannot decode the conforming answers P-11's own generator provokes - -**Where:** TEST-SPEC.md §16 P-11 ("Availability robustness", reusing P-8's -generators, whose mutation menu TEST-SPEC describes as including "giant -nesting"); harness implementation `test/helpers/adapters/forms.ts` -(`decodeViewNodeForm`) as exercised by `test/suite/registry/section-16-p11.ts`. - -**What happens:** P-8's shared mutation menu draws balanced section towers of -depth 512, 2048, or 4096 (`test/suite/registry/section-16-p8.ts`, -`NESTING_DEPTHS`). SPEC 11.4 requires `view` to answer with "the root node and -the full section tree", so a conforming product's answer for a depth-2048 -tower is a JSON node tree nested 2048 `children` levels deep. The harness's -form decoder `decodeViewNodeForm` recurses natively per level and overflows -the call stack on that answer (`RangeError: Maximum call stack size -exceeded`), which the property machinery classifies — by its own H-8 wording — -as "a defect in the harness, not a diagnosed assertion failure", failing the -test. CI seed 271828183 hits this deterministically at trial 1 (mutations: -`specs/B.mdx: append a depth-2048 balanced section tower`, `src/app.ts: insert -UTF-16LE BOM at 0`; first arm `view`). - -**Why no product change can clear it:** the product now terminates promptly -with one complete, bounded JSON document at every generator depth (verified at -512/2048/4096 and with three stacked towers: worst observed answer ~35 MB in -~4 s against the harness's 64 MB / 10 s guards). The overflow happens inside -the harness *after* a conforming answer is produced; SPEC 11.4 forbids the -product any shallower answer form, and the harness's form-exact decoder pins -the nested `children` encoding. Every conforming product fails P-11 until the -harness decodes deep trees without native recursion (or otherwise handles the -depth its own generator draws). - -**Note on classification:** TEST-SPEC.md's P-11 text itself is satisfiable and -consistent; the blocking defect is in the harness implementation of the form -decoders (Phase 9 scope, which Phase 10 must not touch). Logged here because -this file is Phase 10's channel for problems in the test system. The harness's -other recursive walkers over answer documents (e.g. P-11's -`documentCarriesUnavailability`) have the same exposure once decoding -survives. - -**Evidence:** local run of `npx vitest run --config test/vitest.config.ts ---project suite test/suite/section-16-p11.test.ts` at product commit -(post-fix): fails with the harness-error message above. Product-side -reproduction of the previously-hanging invocation (depth-2048 tower + BOM -file, bare `view`) now completes in ~0.9 s with a 17.9 MB document, exit 1. From 15b6c6fdb44be033258ea7b9bc7c52756ecb7f35 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 08:05:37 +0000 Subject: [PATCH 172/226] sdg(phase-6): extend answer-scale capacity to stream capture (H-11, S-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied I1 (Important): H-11's operative clause now covers the capture of H-2's observed output streams — capacity limits dimensioned to staged scales, exhausted capture limits surfacing loudly (silent truncation is indistinguishable from the partial-document violation P-8/P-11 assert), classification extended to failures while capturing — and S-8 gains a capture arm at maximum staged scale via S-3's stand-in mechanism, making obligation and pre-product gate coextensive with the whole pipeline. Applied O1 (Optional): S-8's scale dimension rephrased as H-11's ("the maximum scale the suite itself stages"), keeping the gate coextensive with the obligation if a large deterministic fixture is ever added. Rejected: none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index 0fa005b..c91032c 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -26,7 +26,7 @@ These requirements bind the harness implementation regardless of test framework * **H-8 Red-green compatibility.** The full suite MUST be runnable when no product is installed (or against a deliberately empty stub): every product-facing test fails with a diagnosed assertion failure — never a harness crash, hang, or false pass. Self-tests (17) and certification MUST pass before the product exists. * **H-9 No skips.** A test that cannot run in GitHub CI is implemented as a local-only test, executed by the local suite; it is never marked skipped. The local-only set is currently empty (18). * **H-10 Time and randomness.** The harness introduces no wall-clock or randomness dependence into assertions; fuzz/property tests (16) use seeded, reproducible generators and report the seed on failure. -* **H-11 Answer-scale capacity.** For every input the suite stages — deterministic fixtures and generator draws (16) alike — the harness MUST decode and evaluate every answer SPEC.md permits a conforming product to give at that input's scale, nesting depth and document size included: the H-3/12.7 decoding and every subsequent per-datum traversal of an answer document (16's property walks included) succeed, with harness-internal capacity limits dimensioned to the scales the suite itself stages. A harness-side failure while evaluating an answer — a crash, hang, or exhausted internal limit — is reported as a defect in the harness, never as a diagnosed product failure and never as a pass: H-8's rule generalized beyond the missing-product run, H-3's fail-loudly rule beyond absent information. S-8 (17) gates this capacity before any product exists. +* **H-11 Answer-scale capacity.** For every input the suite stages — deterministic fixtures and generator draws (16) alike — the harness MUST capture, decode, and evaluate every answer SPEC.md permits a conforming product to give at that input's scale, nesting depth and document size included: the capture of H-2's observed output streams, the H-3/12.7 decoding, and every subsequent per-datum traversal of an answer document (16's property walks included) succeed, with harness-internal capacity limits — capture included — dimensioned to the scales the suite itself stages; an exhausted capture limit MUST surface as a loud harness error, never as silent truncation, since a truncated capture is observationally indistinguishable from a product emitting a partial document (P-8, P-11). A harness-side failure while capturing or evaluating an answer — a crash, hang, or exhausted internal limit — is reported as a defect in the harness, never as a diagnosed product failure and never as a pass: H-8's rule generalized beyond the missing-product run, H-3's fail-loudly rule beyond absent information. S-8 (17) gates this capacity, capture through evaluation, before any product exists. ## 1. Core Concepts @@ -589,7 +589,7 @@ Confidence that the harness itself is correct comes primarily from certification * **S-5 Output adapters.** Each adapter (H-3) rejects documents missing required information (fed synthetic wrong-shape documents) rather than defaulting. * **S-6 Oracles.** The Markdown oracle (P-2), the glob/capture oracle (P-7), the coverage-reachability oracle (P-13), the section-move category oracle (P-5), and the baseline graph-diff oracle (P-6) pass their own fixed vector suites before being trusted by property tests, each derived from SPEC.md's worked material: 3, 7.5, and 15 respectively for the first three; 6.2's worked straddling-line case plus the clean-boundary and final-position cases of T6.2-3/T6.2-4 for the section-move oracle; 5.6's three worked examples plus the added/deleted convention of T5.6-6 for the graph-diff oracle. * **S-7 Red-green sweep.** Against an empty stub product (every command exits with an unexpected code and no output), every product-facing test fails with a diagnosed assertion and the suite completes without harness errors (H-8). -* **S-8 Answer-scale capacity.** The H-3/12.7 decoders and every answer-document walk the suite performs succeed, every datum evaluated without harness error, on synthetic conforming-form documents at the maximum scale the suite's own generators stage (16) — among them a `view` document nested at least as deep as P-8's giant-nesting floor — so the capacity H-11 requires is gated and regression-guarded before any product exists (H-8's ordering). Certification cannot exercise this class: a harness-side failure against a conforming answer is a spurious fail, not a vacuous pass or a missed deviation, so no CERTIFICATIONS.md fixture reaches it. +* **S-8 Answer-scale capacity.** The H-3/12.7 decoders and every answer-document walk the suite performs succeed, every datum evaluated without harness error, on synthetic conforming-form documents at the maximum scale the suite itself stages (deterministic fixtures and generator draws, 16, alike) — among them a `view` document nested at least as deep as P-8's giant-nesting floor. Capture is gated at the same scale through S-3's stand-in mechanism: a stand-in command emitting the largest of these synthetic documents on standard output is driven through the H-2 capture path product invocations use, and the captured bytes MUST be complete and identical to what the stand-in emitted. So the capacity H-11 requires is gated and regression-guarded, capture through evaluation, before any product exists (H-8's ordering). Certification cannot exercise this class: a harness-side failure against a conforming answer is a spurious fail, not a vacuous pass or a missed deviation, so no CERTIFICATIONS.md fixture reaches it. ## 18. Execution and CI From f762919b7e17adcc14dd06958dbb9d279222faa3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 08:26:42 +0000 Subject: [PATCH 173/226] sdg(phase-6): pin added-import insertion bytes; gate staged-scale fixtures - C1 (Critical, applied): new T6.5-8 asserts 6.5's added-import line discipline (trailing U+000A; preceding U+000A at a mid-line offset) in TS and MDX arms, value-blind on identifier and insertion offset; T6.5-2/T6.5-7 cross-references updated. - I1 (Important, applied): S-2 gains round-trip scale vectors at P-8's giant-nesting floor and the largest staged document size. - O1 (Optional, applied): S-8 dimensioned to the answer scale H-11 obliges (expansion blowup included), not staged-input size. - O2 (Optional, applied): new T1.3-7 deterministically anchors P-8's 2048-depth floor outside the generator machinery; P-8 points to it. No items rejected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index c91032c..41817a0 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -50,6 +50,7 @@ These requirements bind the harness implementation regardless of test framework * **T1.3-4 Top-level segment count.** A top-level section with a multi-segment ID fails; a one-segment top-level ID passes (checked against the empty prefix, 14.2). * **T1.3-5 Duplicate IDs.** Two sections with the same ID in one file fail with 14.3; the same ID in two different files is valid (uniqueness is per file, identities differ by path, 1.5). * **T1.3-6 No-identity masking.** A section lacking `id` with children: the immediate children report no 14.2 (masked by 14.1), while their other conditions and the grandchildren's structural checks still report (14.2 masking note). Invalid-form arms (14.1: a repeated `id` attribute or a non-quoted-static value is condition 17, never condition 1, and masks condition 2 for the immediate children the same way): a repeated-`id` section and a braced-`id` (`id={"x"}`) section, each with an immediate child whose ID the structural rule would otherwise judge — each bearer reports 14.17 and no 14.1, its immediate children report no 14.2, and their own children's structural checks still report (14.2). +* **T1.3-7 Depth.** SPEC.md bounds no nesting depth — 1.3's structural rule holds at every level: a valid workspace whose one file nests sections at least 2048 levels deep (P-8's giant-nesting floor, 16) builds with exit 0; `query subtree` on the root returns the root plus every section, in document order, the count asserted; `view` serves the full positional tree. The deterministic anchor of P-8's floor outside the generator machinery, and a deterministic exercise of the harness's answer-scale capacity (H-11, S-8). ### 1.4 ID segments and tags @@ -265,13 +266,15 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ### 6.5 Move * **T6.5-1 File form.** IDs unchanged; identities change file part only; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; journal appended; finishing regeneration as T6.4-7; the applied-mapping report as T6.4-1 (6.5: both forms report as rename does). -* **T6.5-2 Section form text edits.** Byte-exact fixtures: moved text spans opening tag's first character through closing tag's last; origin deletion drops lines left empty/whitespace-only (rule of 3); insertion immediately before the target parent's closing tag (or end of file for top-level `new-id`), followed by U+000A and preceded by one when not at line start; target file created when absent; no other byte changes beyond these edits, the identity and reference rewrites (their bytes: T6.5-7), and the finishing regeneration (6.5). Self-closing arms (1.1, 6.5), byte-exact: moving a self-closing section moves exactly the self-closing tag's own characters; a self-closing target parent is first rewritten to paired form — its `/` and any whitespace immediately before or after the `/` deleted, the closing tag matching the opening tag's name appended immediately after the tag's terminating `>` — and the insertion rule then applies before that closing tag: a `<Spec id="p" />` parent becomes `<Spec id="p">` + U+000A + the moved text + U+000A + `</Spec>`. +* **T6.5-2 Section form text edits.** Byte-exact fixtures: moved text spans opening tag's first character through closing tag's last; origin deletion drops lines left empty/whitespace-only (rule of 3); insertion immediately before the target parent's closing tag (or end of file for top-level `new-id`), followed by U+000A and preceded by one when not at line start; target file created when absent; no other byte changes beyond these edits, the identity and reference rewrites (their bytes: T6.5-7, T6.5-8), and the finishing regeneration (6.5). Self-closing arms (1.1, 6.5), byte-exact: moving a self-closing section moves exactly the self-closing tag's own characters; a self-closing target parent is first rewritten to paired form — its `/` and any whitespace immediately before or after the `/` deleted, the closing tag matching the opening tag's name appended immediately after the tag's terminating `>` — and the insertion rule then applies before that closing tag: a `<Spec id="p" />` parent becomes `<Spec id="p">` + U+000A + the moved text + U+000A + `</Spec>`. * **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. * **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link (target absent), one arm each (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose target path is occupied by anything other than a discovered spec source — neither an insertion target nor an absent path to create, refused `refused-destination-exists` (6.5, T14-7) — one arm each: a directory; a symbolic link resolving to a discovered spec source (the link-following discriminator: discovery never yields a symlink, 7 — a product resolving the target path through the filesystem finds a spec source there and inserts through the link); and an existing `.mdx` file outside every configured spec group (present, right extension, still no discovered spec source) — an arm asserting two findings, one per reason (14, T14-7): `refused-invalid-destination` (a path in no spec group) is applicable alongside `refused-destination-exists` in every staging of a plain-`.mdx` occupant, since an in-group, non-derived-excluded `.mdx` plain file is always discovered (7, 13.4) — the insertion target, no occupant refusal — and an unparseable discovered occupant is the invalid-workspace precondition's case; the directory and symlink arms, occupant-blocked at otherwise-valid in-group paths, stage singly; section form whose `<new-id>` is not in intrinsic ID form (the mirrored "new ID is valid" check, 6.5; 14: one or more segments joined by `.`, each satisfying 1.4) — a forbidden name (`then`), a whitespace-bearing segment, and the empty `<new-id>` (destination operand `b.mdx#`: one `#`, so the 12.0 split is well-formed and the id part has zero segments), one arm each, each refused `refused-invalid-id` — the empty-id arm discriminating a product that generalizes 11.3's `--to` spelling rule (where the same spelling exits 2 as malformed, T11.3-3) to move operands; section form whose `<new-id>` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; lacking `.mdx`; and the derived-path arm of `refused-invalid-destination` (6.5: a workspace-relative directory component of a derived path the destination would generate — 13.1, 13.2, 7.3 — occupied by a non-directory): with emission enabled under `markdown.outDir`, a file-form move whose destination `new/b.mdx` is otherwise valid, its own directory components unobstructed (`new/` absent — a nonexistent component is never a refusal cause, 13.4, T13.4-8), but whose emit destination `<outDir>/new/b.md` has its component `<outDir>/new` occupied by a plain file lying under no current source's write path (so the workspace passes `build`'s validations) — refused `refused-invalid-destination`, never 14.22 (14, T14-7), discriminating a product that vets only the destination path's own components; the module and companion paths share the destination's directory (13.1), so the `outDir` emit destination is the separable derived-path fixture. The 6.5 destination clauses "containing `#`" and "not valid UTF-8" admit no staging (dead letters, recorded here as T6.5-6 records its unstageable clauses): a destination path exists only as an operand spelling, and every spelling that would present either is a usage error before any refusal is evaluated — a non-UTF-8 argument value exits 2 (12.0, T12.0-5), so a valid operand never denotes a non-UTF-8 path; a `#`-containing file-form destination classifies as a `<file>#<id>` pair, a mixed-synopsis exit 2 (T6.5-5); and a `#` in the section form's target-file part makes a two-`#` operand, the malformed value T12.0-13 pins as exit 2 on `move`. Plus the valid-workspace precondition as T6.4-6. * **T6.5-5 Usage errors (exit 2).** Nonexistent origin file or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y`, `a.mdx#x b.mdx`, and `a.mdx b#c.mdx` — the last a `#`-containing file-form destination, classified as a pair — match neither form, exit 2, and an operand containing `#` is always a `<file>#<id>` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). A non-UTF-8 destination operand (raw bytes in the OS argument vector, Linux leg) is a usage-error argument value, exit 2 (12.0, T12.0-5). These exit-2 invocations are the stagings T6.5-4's dead-letter note sets aside. * **T6.5-6 Identity terms.** The new-identity checks read in identity terms (6.5): a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid; the exact self-move — `<target-file>#<new-id>` equal to `<file>#<id>` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `<new-id>` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `<new-id>` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. So is the mirrored "structural parent rules remain satisfied" check, in both forms: the file form changes no ID and no within-file nesting; in the section form the target parent is located as the target file's section bearing `<new-id>` minus its final segment — absent, or lying within the moved subtree, it is `refused-missing-target-parent` (T6.5-4) — prefix replacement preserves the subtree's relative nesting, a single-segment `<new-id>` inserts at top level (exactly one segment, 1.3), and the origin's removal disturbs no remaining ID; on a workspace passing the valid-workspace precondition, 1.3 therefore holds by construction after every non-refused move, the check is exercised as the always-passing side of every successful move (T6.5-1/2/3), and `refused-structural-parent` is staged through rename alone (T6.4-3, T14-7). -* **T6.5-7 Operation-side rewrite bytes.** The real move's import-edit extents and reference-conversion spellings, byte-asserted against independently composed expected files — staged so no import is added, the one rewrite direction free of implementation latitude (6.5: identifier choice and insertion offset attach to added imports alone). Fixture: the origin file imports the target file under two bindings (valid, 2.1), one declaration alone on its line, the other following a retained, still-referenced third-module import on a shared line; every reference through the two bindings — a `d` chain and a `text(...)` embedding — lies inside the moved subtree, which also holds a single-quoted local string reference to a moved descendant; no reference to a moved node lies outside the subtree. After the section move into the target file: both target-file imports are left unreferenced and removed with 6.5's exact extent — the own-line declaration's line dropped with its terminator (3), the shared-line declaration's own characters alone deleted, the retained import kept byte-for-byte on its kept line; the moved references convert to local form in 6.4's pinned spelling for converted references, double-quoted string literals; the local reference is re-identified by prefix replacement with its single-quote spelling preserved (6.4: minimal in-place edits); and the rewritten origin and target files are each asserted byte-equal to expected bytes composed from the rules of 6.5 and 3. This is the operation-side assertion T6.5-2 sets aside (its no-other-byte-changes check excludes the identity and reference rewrites) and T6.6-4 makes only of the preview's report — failing a product that emits a spec-perfect preview while the real edit leaves an emptied import line behind, normalizes whitespace around a removed declaration, or spells a converted reference single-quoted. +* **T6.5-7 Operation-side rewrite bytes.** The real move's import-edit extents and reference-conversion spellings, byte-asserted against independently composed expected files — staged so no import is added, the one rewrite direction free of implementation latitude (6.5: identifier choice and insertion offset attach to added imports alone; the addition side's line discipline: T6.5-8). Fixture: the origin file imports the target file under two bindings (valid, 2.1), one declaration alone on its line, the other following a retained, still-referenced third-module import on a shared line; every reference through the two bindings — a `d` chain and a `text(...)` embedding — lies inside the moved subtree, which also holds a single-quoted local string reference to a moved descendant; no reference to a moved node lies outside the subtree. After the section move into the target file: both target-file imports are left unreferenced and removed with 6.5's exact extent — the own-line declaration's line dropped with its terminator (3), the shared-line declaration's own characters alone deleted, the retained import kept byte-for-byte on its kept line; the moved references convert to local form in 6.4's pinned spelling for converted references, double-quoted string literals; the local reference is re-identified by prefix replacement with its single-quote spelling preserved (6.4: minimal in-place edits); and the rewritten origin and target files are each asserted byte-equal to expected bytes composed from the rules of 6.5 and 3. This is the operation-side assertion T6.5-2 sets aside (its no-other-byte-changes check excludes the identity and reference rewrites) and T6.6-4 makes only of the preview's report — failing a product that emits a spec-perfect preview while the real edit leaves an emptied import line behind, normalizes whitespace around a removed declaration, or spells a converted reference single-quoted. + +* **T6.5-8 Added-import insertion discipline.** The addition-side byte contract of 6.5 — an added import is inserted as a line of its own, the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — asserted with the identifier choice and insertion offset left free (6.5's latitude), shape-aware and value-blind (the ethos T6.5-7 applies to removals). Two section-move arms, each staged so the receiving file's expected post-move bytes are composable from the rules of 6.4/6.5 and 3 up to exactly two unknowns, the fresh identifier and the insertion offset: a TS arm, the grammar-freest case — a code file imports the origin module and references one moved and one unmoved node, so the rewrite needs a target-module binding the file lacks while the origin import keeps its remaining reference and stays — and an MDX arm — the origin file, already holding a retained third-module import (grammar-permitted offsets exist, and freshness is live against its binding), keeps a local string reference to a moved descendant, whose conversion to imported form (6.4's pinned spellings) makes the origin file itself gain the target module's import. In each arm the harness isolates the single added byte run by diff against the composed bytes and asserts it is exactly the declaration followed by U+000A at an insertion offset lying at the start of a line, and exactly U+000A, then the declaration, then U+000A at one that does not — the declaration one import of the target module's specifier binding one fresh identifier (value unpinned) that the rewritten references use, no other byte inserted. This fails a product that joins the added declaration to a neighbor with `;` — still parsing, resolving, and byte-deterministic in either grammar, the shared line still dropping whole from Markdown output in the MDX arm (3), hence passing every other test — omits the preceding terminator at a mid-line offset, adds a spurious blank line, or writes any terminator but U+000A, wherever it chose to insert: a line-start offset is always grammar-available, so the mid-line branch is reachable by implementation latitude alone, and the discipline is asserted over whatever offset the product makes observable (as T6.6-4 asserts its ordering tie-break). ### 6.6 Previews @@ -569,7 +572,7 @@ Property tests generate inputs from seeded, reproducible generators (H-10), asse * **P-5 Rename/move purity; section-move categories.** Random valid workspaces, random journaled rename/file-move sequences: all hashes byte-stable, impact against any prior commit in the sequence reports no categories, and all references still resolve. Random section moves: impact against a baseline committed immediately before the move equals an oracle of 6.2/5.6, anchored by T6.2-3/T6.2-4. The oracle's `changed` set is drawn from exactly the origin parent, the target parent, and the moved subtree's nodes — each `changed` iff its own content sequence (1.6) differs across the move: distinct parents necessarily (one loses a child reference, one gains one; a created target file's root, present on no baseline side, is instead `changed` as an added node — by addition, not comparison — and per 5.6 carries no other category), a coincident parent iff the re-insertion fails to reproduce its sequence (a final child re-inserted at its own former position is pure in effect, 6.2), a moved-subtree node iff the straddling-line drops of 6.2 change its runs, computed by the line-drop rules of 3 (P-2's oracle) — with `metadata-changed` on no node (6.2: every moved node keeps its metadataHash, and canonical identities preserve every other node's), `descendant-changed` and `upstream-changed` exactly per 5.6's cascades from the changed nodes, attributions included, and no node carrying any category the oracle does not predict. * **P-6 Baseline replay.** Random edit/rename/move/commit interleavings: impact categories against each historical baseline equal an oracle diff of the two graphs with identities mapped through the journal suffix. * **P-7 Glob and capture matching.** Random patterns and paths over the 7/7.5 grammar, with generators including the glob metacharacters of common dialects (`[` `]` `{` `}` `!` `+` `(` `)`), which the 7 grammar treats as literals, and `$` forms at the capture boundary (`$0`, `$` before a non-digit, trailing `$` — literals in 7.5 patterns; T7.5-5): match decisions and capture values equal a spec oracle; every match is unique under the left-to-right shortest-match rule; captures never span `/` and never match empty. -* **P-8 Parser robustness.** Fuzzed byte inputs (mutated MDX/TS/config, invalid UTF-8, BOMs, giant nesting, pathological line terminators): every command terminates, never emits a partial JSON document on `--json`, and always exits 0, 1, or 2 per the 12.0 partition; `build` failures modify nothing. The giant-nesting mutation class carries a test-strength floor: its staged draws MUST include section nesting at least 2048 levels deep — a floor on staged inputs, not a product bound (SPEC.md bounds no nesting depth; H-11 dimensions the harness to the staged scale). +* **P-8 Parser robustness.** Fuzzed byte inputs (mutated MDX/TS/config, invalid UTF-8, BOMs, giant nesting, pathological line terminators): every command terminates, never emits a partial JSON document on `--json`, and always exits 0, 1, or 2 per the 12.0 partition; `build` failures modify nothing. The giant-nesting mutation class carries a test-strength floor: its staged draws MUST include section nesting at least 2048 levels deep — a floor on staged inputs, not a product bound (SPEC.md bounds no nesting depth; H-11 dimensions the harness to the staged scale); T1.3-7 anchors the floor deterministically. * **P-9 Review session invariants.** Random sequences of valid review operations (create/next/resolve/split/re-derive triggers) interleaved with workspace edits: at most one item per kind and scope node; `blockedBy` acyclic; retired `id`s never reused; `next` always returns an unblocked needing-review item or reports fully resolved; reads never change session bytes; stored sessions always re-read as non-corrupt. * **P-10 Concurrency.** Randomized schedules of concurrent readers and one mutating command (via `--test-hold` and process kills): readers observe only prior-or-complete file states (T13.5-5); mutual exclusion never loses a journal append or a resolution (post-hoc: journal lines = successful `rename`/`move` operations — the journal's only writers, 6.1; session statuses = successful resolves). * **P-11 Availability robustness.** Fuzzed and mutated spec and code sources (P-8's generators — the availability contract is precisely an imperfect-input surface) driven through `occurrences`, `view` (with and without `--text`), and `at` at random offsets: every invocation terminates; stdout is one complete JSON document, never partial; the exit is 0 or 1 per 11.2 (2 only for staged argument errors); every datum is exactly one of plain value, `null`, or `{"unavailable": true}` (11.4, 12.7); any finding or unavailable datum implies exit 1 with the full document emitted, and exit 0 implies a finding-free document carrying none. @@ -583,13 +586,13 @@ Confidence that the harness itself is correct comes primarily from certification * **C-1 Certification protocol.** For each fixture in `specs/CERTIFICATIONS.md`: every in-scope test passes against the conformer; for each violator, exactly the tests it certifies fail against it and all other in-scope tests pass. A certified test's certification MUST run green before the product is implemented (red-green gate). Certification results are part of the harness's CI output. * **C-2 Fixture interface.** Fixtures are driven through the identical blackbox surfaces as the product (H-2): the runner takes an executable/workspace binding and nothing else, so certifying and testing use one code path. * **S-1 Traceability self-check.** The H-7 map is complete and well-formed (fails on unmapped H-7 keys or dangling references). -* **S-2 Workspace builder.** The fixture builder writes exactly the declared bytes (round-trip check including CRLF/CR content, invalid-UTF-8 blobs, BOMs, symlinks, and git fixtures with scripted commits) — certification cannot exercise builder bugs that make fixtures diverge from their declarations. +* **S-2 Workspace builder.** The fixture builder writes exactly the declared bytes (round-trip check including CRLF/CR content, invalid-UTF-8 blobs, BOMs, symlinks, and git fixtures with scripted commits), with scale vectors at the suite's staged maxima — a document nested at least at P-8's giant-nesting floor and one at the largest document size the suite stages (deterministic fixtures and generator draws alike, 16), each read back byte-complete — so a truncating writer or recursion-limited serializer cannot silently stage shallower or smaller inputs than declared, P-8's floor going unmet while deterministic tests still pass against their equally-shrunken expectations (the input-side counterpart of S-8's answer-side capacity gate) — certification cannot exercise builder bugs that make fixtures diverge from their declarations. * **S-3 Subprocess driver.** Captures exit codes and keeps stdout/stderr separated (verified against a known-behavior stand-in command); enforces per-test working directories; detects hangs via timeout and reports them as failures, not skips. * **S-4 TypeScript tooling driver.** Detects a known type error, a known definition location, and a known hover text in a hand-written non-xspec fixture project, so section 4's consumer assertions cannot pass vacuously. * **S-5 Output adapters.** Each adapter (H-3) rejects documents missing required information (fed synthetic wrong-shape documents) rather than defaulting. * **S-6 Oracles.** The Markdown oracle (P-2), the glob/capture oracle (P-7), the coverage-reachability oracle (P-13), the section-move category oracle (P-5), and the baseline graph-diff oracle (P-6) pass their own fixed vector suites before being trusted by property tests, each derived from SPEC.md's worked material: 3, 7.5, and 15 respectively for the first three; 6.2's worked straddling-line case plus the clean-boundary and final-position cases of T6.2-3/T6.2-4 for the section-move oracle; 5.6's three worked examples plus the added/deleted convention of T5.6-6 for the graph-diff oracle. * **S-7 Red-green sweep.** Against an empty stub product (every command exits with an unexpected code and no output), every product-facing test fails with a diagnosed assertion and the suite completes without harness errors (H-8). -* **S-8 Answer-scale capacity.** The H-3/12.7 decoders and every answer-document walk the suite performs succeed, every datum evaluated without harness error, on synthetic conforming-form documents at the maximum scale the suite itself stages (deterministic fixtures and generator draws, 16, alike) — among them a `view` document nested at least as deep as P-8's giant-nesting floor. Capture is gated at the same scale through S-3's stand-in mechanism: a stand-in command emitting the largest of these synthetic documents on standard output is driven through the H-2 capture path product invocations use, and the captured bytes MUST be complete and identical to what the stand-in emitted. So the capacity H-11 requires is gated and regression-guarded, capture through evaluation, before any product exists (H-8's ordering). Certification cannot exercise this class: a harness-side failure against a conforming answer is a spurious fail, not a vacuous pass or a missed deviation, so no CERTIFICATIONS.md fixture reaches it. +* **S-8 Answer-scale capacity.** The H-3/12.7 decoders and every answer-document walk the suite performs succeed, every datum evaluated without harness error, on synthetic conforming-form documents at the maximum answer scale H-11 obliges: the scale of the largest answers SPEC.md permits a conforming product over the inputs the suite stages (deterministic fixtures and generator draws, 16, alike) — expansion blowup included, a `view --text` answer multiplying embedded subtree text through each expansion level past its staged input's own size — never merely the staged inputs' size; among them a `view` document nested at least as deep as P-8's giant-nesting floor. Capture is gated at the same scale through S-3's stand-in mechanism: a stand-in command emitting the largest of these synthetic documents on standard output is driven through the H-2 capture path product invocations use, and the captured bytes MUST be complete and identical to what the stand-in emitted. So the capacity H-11 requires is gated and regression-guarded, capture through evaluation, before any product exists (H-8's ordering). Certification cannot exercise this class: a harness-side failure against a conforming answer is a spurious fail, not a vacuous pass or a missed deviation, so no CERTIFICATIONS.md fixture reaches it. ## 18. Execution and CI From 9f8d09b049e8716e48a98ae02994d5f4c71edaf8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 30 Aug 2026 08:48:26 +0000 Subject: [PATCH 174/226] =?UTF-8?q?sdg(phase-6):=20TEST-SPEC=20iteration?= =?UTF-8?q?=204=20=E2=80=94=20universal=2012.7=20value=20forms;=20T12.0-1?= =?UTF-8?q?=20and=20P-1=20precision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied C1: SPEC.md 12.7 fixes "the value forms every JSON output uses" universally, pinning document forms only for the enumerated surfaces (12.6 versions the whole contract; 1.7 lists 10.7/11/12.4 among the range-carrying outputs). Rewrote H-3 as a two-tier contract — pinned document forms plus universal value forms that adapters may never re-map (latitude confined to the surrounding unpinned shape); updated the §11 preamble's query routing; extended T12.7-1 with literal {"start","end"} arms on query node/row ranges, show --json, and review payload nodes, and widened the unavailable-member exclusivity walk to every JSON document the suite captures. Applied O1: reworded T12.0-1's flag-less/--json clause to information-strength — byte-identity is not asserted (the two invocations differ in argv; 12.0 determinism binds per identical input), aligning with the §11 preamble. Applied O2: P-1's segment property now judges the staged spelling's resulting split (a .-containing draw stages as multiple segments, nested beneath the chain its prefixes spell), mirroring the tag half; structural-rule outcomes stay with T1.3-2..4. Rejected: none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index 41817a0..49c2ecf 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -18,7 +18,7 @@ These requirements bind the harness implementation regardless of test framework * **H-1 Workspace isolation.** Every test constructs a fresh, self-contained workspace in a unique temporary directory: `xspec.config.ts`, source files, and (when needed) a local git repository with scripted commits. Tests share no mutable state. Two harness instances MUST be able to run concurrently on the same machine (unique temporary roots), satisfying SPEC.md 13.5 isolation from the observer side. * **H-2 Blackbox drive.** Tests invoke the `xspec` executable as a subprocess with controlled working directory, arguments, and environment, and observe: exit code, standard output, standard error, and workspace file state. Consumer-side contracts (generated modules, type errors, runtime behavior, hover/go-to-definition) are exercised by compiling and running small consumer TypeScript programs under standard TypeScript tooling with no xspec runtime dependency (SPEC.md 13.1). No other channel into the product exists — in particular, invoking the product in-process (importing product code or calling a product-internal entry function) is not a permitted channel for any test, fast paths included: SPEC.md's complete interface is the executable, an in-process entry is an implementation detail outside that interface, and subprocess invocation carries the process-level contract the suite asserts (exit codes, stream separation, working directory, environment, termination; 12.0, 13.5). -* **H-3 Output adapters and form-exact surfaces.** SPEC.md 12.7 fixes the concrete JSON shape — member names, `null`-vs-omission, `[]`-vs-`null`, the range, path, byte-form, unavailability-marker, and finding value forms, and findings order — of every findings array and findings-only report, the exit-2 error document, and the document forms of 6.6, 11.3–11.6, and 12.6. Assertions on those surfaces are form-exact: tests assert the 12.7 member names and forms literally, and no adapter may re-map, rename, or coerce them — output differing from 12.7 in shape is a conformance failure, never an adapter fixture (T12.7-1..3). Everywhere else — human-readable reports, and command JSON that carries its defining section's information (12.0: `query`, `ids`, `show`, `coverage`, `impact`, the `review` payloads, the applied-mapping reports of 6.4/6.5) — SPEC.md fixes information content, not concrete shape: those assertions go through a thin decoding adapter that maps the product's actual output onto the information model this document asserts against (nodes, categories, counts, paths, …). Adapters are the only place aware of that unpinned shape; they may be adjusted to shape, never to values, and they MUST fail loudly (test error, not pass) when required information is absent. Human-readable reports are asserted only for required information (via robust matching), never exact wording. Where a test stages a corrupt or tampered product-written file whose concrete shape SPEC.md leaves opaque (T10.1-4, T6.6-6), the staging transformation lives in this same adapter layer — shape-aware, value-blind, applied to a file the product itself wrote, and failing loudly when the shape does not match — never fabricating such a file from an assumed layout. +* **H-3 Output adapters, form-exact documents, and universal value forms.** SPEC.md 12.7 fixes two tiers of concrete JSON shape. Document forms — the whole document's member set, `null`-vs-omission, `[]`-vs-`null`, and orderings — are pinned for every findings-only report, the exit-2 error document, and the surfaces of 6.6, 11.3–11.6, and 12.6: assertions on those documents are form-exact end to end, and no adapter may re-map, rename, or coerce them — output differing from 12.7 in shape is a conformance failure, never an adapter fixture (T12.7-1..3). Value forms are universal: 12.7 fixes "the value forms every JSON output uses" — a source range is `{"start", "end"}`, a path a plain string or the marked byte form, an identity a string, unavailability exactly `{"unavailable": true}`, a finding the 12.7 finding form (findings arrays the member `"findings"`, in 12.7 order), an occurrence record the 12.7 record form — and 12.6 versions that whole contract, so these forms bind wherever their data appear in any JSON output, pinned document or not. Where SPEC.md leaves a document's shape unpinned, fixing its information content instead (12.0) — the command JSON of `query`, `ids`, `show`, `coverage`, `impact`, the `review` payloads, and the applied-mapping reports of 6.4/6.5 — assertions go through a thin decoding adapter that maps the product's actual output onto the information model this document asserts against (nodes, categories, counts, paths, …). The adapter's latitude is exactly the surrounding unpinned shape — which members hold which data, nesting, grouping — never the value forms: a located value-form datum is asserted literally (T12.7-1's unpinned-surface arms), and a product carrying it in any other shape — a range as `[start, end]` or `{"from", "to"}` in a `query` row or a review payload — fails, the adapter never mapping it back. Adapters are the only place aware of unpinned shape; they may be adjusted to shape, never to values, and they MUST fail loudly (test error, not pass) when required information is absent. Human-readable reports are asserted only for required information (via robust matching), never exact wording. Where a test stages a corrupt or tampered product-written file whose concrete shape SPEC.md leaves opaque (T10.1-4, T6.6-6), the staging transformation lives in this same adapter layer — shape-aware, value-blind, applied to a file the product itself wrote, and failing loudly when the shape does not match — never fabricating such a file from an assumed layout. * **H-4 Byte assertions.** Where SPEC.md requires byte determinism or exact bytes (12.0 determinism, 3 Markdown output, 6.5 move edits, 13.4 stable ordering), tests assert byte equality. Where SPEC.md declares content opaque (journal entry content 6.1, graph data content 13.3), tests assert only the stated observable contract (location, line-orientation, append-only effect, refresh/staleness behavior) and MUST NOT pin opaque bytes across product versions — except for determinism checks comparing the product to itself. * **H-5 Exit codes and streams.** Every test asserts the exact exit code and, where relevant, the stdout/stderr separation of 12.0: reports and findings are stdout content; usage/configuration error messages and all other diagnostics are stderr content; when JSON output is in effect — `--json` among the invocation's arguments, even when the arguments are themselves the error, or a JSON-only surface (10.7, 11, 12.6) — stdout is exactly one JSON document, on exit 2 the error document of 12.7; when it is not in effect, exit-2 stdout is empty. * **H-6 Determinism protocol.** Tests marked *determinism* run the same command twice (or rebuild the same workspace in two separate directories) and assert byte-identical outputs and written files, after normalizing nothing. Workspace-relative path rules (1.5) make this well-defined across directories. @@ -394,7 +394,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ## 11. Query Surfaces -SPEC.md 11's five commands are JSON-only: for `query`, `occurrences`, `view`, `at`, and `inventory`, a single JSON document is the only output form, with or without `--json` — `occurrences`, `view`, `at`, and `inventory` in the form-exact document forms of 12.7 (H-3, T12.7-2), `query` carrying its defining section's information through H-3 adapters. Each surface's flag-less and `--json` invocations are asserted to carry the same information — byte-identity between the two forms is not asserted (SPEC.md does not require it) — and an exit-2 error of any of them arrives as the 12.7 error document on stdout (12.0; T12.0-2, T12.7-3). +SPEC.md 11's five commands are JSON-only: for `query`, `occurrences`, `view`, `at`, and `inventory`, a single JSON document is the only output form, with or without `--json` — `occurrences`, `view`, `at`, and `inventory` in the form-exact document forms of 12.7 (H-3, T12.7-2), `query` carrying its defining section's information through H-3 adapters — its document shape unpinned, but its value-form data (source ranges above all) form-exact per 12.7's universal value forms (H-3, T12.7-1). Each surface's flag-less and `--json` invocations are asserted to carry the same information — byte-identity between the two forms is not asserted (SPEC.md does not require it) — and an exit-2 error of any of them arrives as the 12.7 error document on stdout (12.0; T12.0-2, T12.7-3). ### 11.1 `xspec query` @@ -450,7 +450,7 @@ Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are ### 12.0 Global conventions -* **T12.0-1 --json everywhere.** For every command and subcommand this specification covers, `--json` emits exactly one JSON document as the entire standard output, carrying the same information as the human report (adapter-verified per command in the sections above; this test sweeps that every command accepts the flag; the JSON-only surfaces of 10.7, 11, and 12.6 emit the same single document with the flag as without). +* **T12.0-1 --json everywhere.** For every command and subcommand this specification covers, `--json` emits exactly one JSON document as the entire standard output, carrying the same information as the human report (adapter-verified per command in the sections above; this test sweeps that every command accepts the flag; the JSON-only surfaces of 10.7, 11, and 12.6 emit a single JSON document with the flag as without, the two invocations carrying the same information — byte-identity between them is not asserted, as the §11 preamble states: SPEC.md does not require it). * **T12.0-2 Streams.** A failing `build`'s validation errors and `check`'s findings are standard-output content (exit 1); usage and configuration error messages are standard-error content. With JSON output in effect — `--json` among the arguments, even when the arguments are themselves the error (an unknown command; an unknown flag), or a JSON-only surface (10.7, 11, 12.6) — an exit-2 invocation emits the 12.7 error document as its entire stdout (T12.7-3); without JSON in effect, exit-2 stdout is empty. Non-JSON diagnostics never contaminate a JSON stdout, and the output form never changes an exit code or standard-error content: a representative exit-2 usage error and a failing `build` (exit 1), each run with and without `--json`, exit identically with standard error byte-identical across the two forms (12.0; a product-to-itself comparison, H-4) — failing a product that appends or substitutes stderr diagnostics when JSON output is in effect. * **T12.0-3 --config.** Every command accepts `--config <path>`; a relative path resolves against the working directory, not the workspace root. * **T12.0-4 Flag repetition.** Repeating a flag on any command → exit 2; list-valued flags take one comma-separated value (`--kinds depends,embeds`). @@ -499,7 +499,7 @@ Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are Assertions here — and wherever these forms appear across the suite — are form-exact (H-3): member names, `null`-vs-omission, `[]`-vs-`null`, and orderings are asserted literally, never adapted. -* **T12.7-1 Value forms.** A source range is `{"start", "end"}`, non-negative integers, everywhere the 12.7 surfaces carry one. Paths: valid-UTF-8 paths are plain strings; a non-UTF-8 path (Linux leg) is `{"bytes": "…"}` — its exact bytes as lowercase hexadecimal, two digits per byte — asserted at each output the 12.0 rule names: an inventory source path, an occurrence's referencing file, a view's file and an import's resolved target, and a finding's location file and concerned path; a valid-UTF-8 path never takes the byte form. Unavailability is exactly `{"unavailable": true}`, and no object of any other form carries a member named `unavailable` (a structural walk over every 12.7 document the suite captures; S-5 guards the walk). A finding is `{"code", "message", "locations", "path", "identities"}`: `code` the stable token string or `null` where 14 assigns none (a review-refusal finding); `locations` one `{"file", "range"}` per offending construct, ordered by file bytes, then start, then end, `[]` for unlocated conditions; `path` `null` for located conditions, the concerned path otherwise; `identities` contractual where 14 states them — a policy finding carries the rule name, source identity, kind token, and target identity in that order with `locations` `[]` and `path` `null` (14.12), a cross-module call names the foreign module (14.11), a refusal reason its concerned identity (T14-7). +* **T12.7-1 Value forms.** A source range is `{"start", "end"}`, non-negative integers, wherever any JSON output carries one (12.7's value forms bind every JSON output, H-3): asserted literally on the pinned document forms and, through the H-3 decode, on each unpinned surface that carries ranges — a `query node` range and a `query nodes`/`subtree`/`ancestors` row's (11.1), `show --json`'s (12.4), and a review payload's (10.7): a present scope node's and a present `code-impact` location's, every range the payload carries in this one form. Paths: valid-UTF-8 paths are plain strings; a non-UTF-8 path (Linux leg) is `{"bytes": "…"}` — its exact bytes as lowercase hexadecimal, two digits per byte — asserted at each output the 12.0 rule names: an inventory source path, an occurrence's referencing file, a view's file and an import's resolved target, and a finding's location file and concerned path; a valid-UTF-8 path never takes the byte form. Unavailability is exactly `{"unavailable": true}`, and no object of any other form carries a member named `unavailable` (a structural walk over every JSON document the suite captures — the unpinned-shape surfaces of H-3 included, the exclusivity being universal like the value forms; S-5 guards the walk). A finding is `{"code", "message", "locations", "path", "identities"}`: `code` the stable token string or `null` where 14 assigns none (a review-refusal finding); `locations` one `{"file", "range"}` per offending construct, ordered by file bytes, then start, then end, `[]` for unlocated conditions; `path` `null` for located conditions, the concerned path otherwise; `identities` contractual where 14 states them — a policy finding carries the rule name, source identity, kind token, and target identity in that order with `locations` `[]` and `path` `null` (14.12), a cross-module call names the foreign module (14.11), a refusal reason its concerned identity (T14-7). * **T12.7-2 Findings arrays and document forms.** A workspace staging several conditions, and a multi-reason refusal (T14-7): every findings array is ordered by code — numbered conditions in numeric order, then refusal reasons in 14's listed order, then code-less findings — then by locations element-wise (a proper prefix sorting first), then by concerned path (`null` first; byte-form and plain paths in one byte order), then by identities, then by message; identically-staged duplicate findings collapse to one. Document forms: `build`/`check`/gated-read/refused-operation reports are `{"findings": […]}`; a refused preview keeps `{"findings", "mapping", "files", "delta"}` with the three `null` (T6.6-3); `occurrences` is `{"findings", "occurrences"}`; `view` `{"findings", "views"}`, each node `{"identity", "range", "opening", "closing", "attributes", "tags", "coverage", "children"}` plus `ownText`/`subtreeText` exactly when `--text` is given (the stated conditional presence — absent without the flag), `attributes` entries `{"name", "range", "text"}`, imports `{"range", "name", "target"}`; `at` `{"findings", "resolution"}`, `resolution` `{"section", "occurrence"}` with `occurrence` `null` when the offset lies in none; `inventory` and previews per T11.6-* and T6.6-4/5; `version` `{"product", "interface"}`. Member presence: `null` is never omission (a refused preview still carries all four members; an unset `outDir` is `null`); empty lists are `[]`, never `null` (a finding-free `findings`, a root's `attributes`, an empty delta direction); stated `null`s vs structural absence per surface (a root's `tags`/`coverage`, T11.4-3; an absent `targetTags`, T11.6-2). * **T12.7-3 Error document.** Exit-2 invocations with JSON in effect emit `{"error": …}` holding one finding form as the entire stdout: a configuration error → stable code `configuration-error` and concerned path in the anchoring form (the configuration file; for a failed upward search with no `--config`, `.`); a plain usage error → `code` and `path` `null`; one finding however many defects — a configuration file with several distinct defects yields a single condition-14 finding. JSON is in effect for a JSON-only surface without `--json` (`inventory` with an unknown flag) and whenever `--json` appears among the arguments, the arguments themselves erroneous included (an unknown command beside `--json`) — each the error document on stdout, diagnostics on stderr (T12.0-2). @@ -565,7 +565,7 @@ Sections 1–13 exercise each numbered condition in its home context; this secti Property tests generate inputs from seeded, reproducible generators (H-10), assert spec-derived invariants, and shrink failures. Each property is also anchored by the deterministic fixtures of sections 1–15; properties exist to search the input space, not to replace them. -* **P-1 Segment/tag validity.** Generator over code points (weighted toward boundaries: whitespace/control classes of 1.4, U+00A0/U+0085/U+2028, `.`/`#`, forbidden names, and glob metacharacters of common dialects — `[` `]` `{` `}` `!` `+` `(` `)` — which are ordinary valid segment characters): a generated segment is accepted by `build` iff it satisfies 1.4; likewise tags, with `.` allowed and whitespace never reaching tag validation — a generated value containing whitespace stages as multiple tags (2.6 splits on runs of whitespace), so the tag property asserts acceptance iff every resulting token satisfies 1.4 (zero tokens: accepted as an omitted prop, T2.6-2). +* **P-1 Segment/tag validity.** Generator over code points (weighted toward boundaries: whitespace/control classes of 1.4, U+00A0/U+0085/U+2028, `.`/`#`, forbidden names, and glob metacharacters of common dialects — `[` `]` `{` `}` `!` `+` `(` `)` — which are ordinary valid segment characters): the segment property judges the staged spelling's resulting split — a draw containing `.` can be spelled as no single segment (1.4: `.` is the ID separator) and stages as that many segments, its bearer nested beneath the ancestor chain the split's prefixes spell, so the structural rule holds whenever the segments are valid (a `.`-free draw stages as one top-level segment; structural-rule outcomes are T1.3-2..4's, never this oracle's) — asserting acceptance by `build` iff every resulting segment satisfies 1.4; likewise tags, with `.` allowed and whitespace never reaching tag validation — a generated value containing whitespace stages as multiple tags (2.6 splits on runs of whitespace), so the tag property asserts acceptance iff every resulting token satisfies 1.4 (zero tokens: accepted as an omitted prop, T2.6-2). * **P-2 Markdown compilation.** Random documents composed of prose blocks — fenced code blocks and inline code spans spelling tag-, import-, and expression-like bytes included (T3-1's grammar boundary: such bytes are content) — nested sections, imports, comments (single- and multi-line), and embeddings, over mixed line terminators, with content weighted toward the whitespace/non-whitespace boundary code points of 1.4 (U+00A0, U+0085, and U+2028 included): compiled output equals an independent oracle implementing the removal/replacement/line-drop rules of 3 (the oracle lives in the harness); compilation is deterministic; content bytes outside removed constructs are preserved. * **P-3 Text algebra.** For random documents: root subtree text equals compiled Markdown output; a node's subtree text equals its own-text runs interleaved with its children's subtree texts in document order (1.6); N children yield N+1 runs. * **P-4 Hash laws.** For random workspaces and random single edits: subtreeHash changed iff the 5.5 condition holds; metadataHash changed iff `d`/`coverage`/`tags` changed; ownHash insensitive to embedded-target edits; effectiveHash monotone over the dependency closure (any dependency-target effectiveHash change propagates); identical workspaces hash identically. From 46a6aed1977291617e6aa66b83d642b0bb4b268c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 1 Sep 2026 22:05:44 +0000 Subject: [PATCH 175/226] =?UTF-8?q?sdg(phase-6):=20TEST-SPEC=20iteration?= =?UTF-8?q?=205=20=E2=80=94=20impact/journal=20gate=20contradiction;=20sym?= =?UTF-8?q?link=20destination=20components;=20byte=20contracts=20in=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied (round 5 review): - C1: T13.3-3 — `impact` dropped from the journal-error gate arm (baseline replay meets a garbage line as exit 2, T6.3-4) and kept in the obstructed-write arm against a pre-obstruction commit; `review status` pinned to an audit session. - C2: T6.5-4 — symbolic-link directory-component arms (file form, created target, `outDir` emit path; link targets inside and outside the workspace), refused `refused-invalid-destination`, never 14.22; T14-7 and T13.4-6 cross-references. - I1: T6.6-5 — deleted-record delta pinned form-exactly (`generated` = full post-operation derived set from the inventory's record, `removed` = []); lagging-record counterpart added. - I2: P-1 — quote-kind spelling discipline; both-quote draws excluded as unspellable. - I3: T6.5-7 — fully composed `.ts` import-removal arm (own-line and shared-line); T6.4-2 — whole-file byte contract for rewritten `.ts`/`.mdx` files; T6.5-1 — specifier-rewrite byte contract bounded to the literals (2.1 pins form, not spelling). - I4: T6.4-2 — keepable-form arms for the rewritten segment (computed stays computed, single quotes kept, dot kept for identifier-valid names), MDX and TS. - O1: T11.4-1 view list orders. O2: T5.5-2 in-line fixture geometry. O3: root-node reason as adapter-located information (T1.2-3, T8.2-1). O4: section 16 — draws valid by construction (not conditioned on exit 0). O5: T13.4-3 unreadable-record half. O6: T13.4-1 byte-order pin noted non-discriminating over ASCII keys. O7: T6.1-1 full never-modifies list. Rejected: none. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index 49c2ecf..515bee9 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -40,7 +40,7 @@ These requirements bind the harness implementation regardless of test framework * **T1.2-1** The root node is queryable by bare path (`query node specs/A.mdx`), has no `id`, and `query subtree specs/A.mdx` returns the root first, then every section of the file in document order. * **T1.2-2** The generated module's default export is the root node: a consumer passes the default export to `text()` and receives the entire compiled Markdown output of the file (subtree text of the root, 1.6/3). -* **T1.2-3** Roots are never coverage targets: a profile with `targets: "all"` over a group never lists any root in required/covered/uncovered; a root is reported ignored with reason `root node` when in the target group (8.1, 8.2); `query nodes --coverage required` and `--coverage none` match no root; `query node` on a root reports the coverage attribute absent (11). +* **T1.2-3** Roots are never coverage targets: a profile with `targets: "all"` over a group never lists any root in required/covered/uncovered; a root is reported ignored with the root-node exclusion reason when in the target group (8.1, 8.2; the reason is adapter-located information, H-3 — 8.2 names it in prose and pins no wording); `query nodes --coverage required` and `--coverage none` match no root; `query node` on a root reports the coverage attribute absent (11). ### 1.3 Requirement IDs @@ -205,7 +205,7 @@ Consumer programs in this section are compiled and run under standard TypeScript ### 5.5 Hashes * **T5.5-1 Reporting and determinism.** `query node` reports all four hashes; rebuilding the identical workspace in a fresh directory yields identical hashes (H-6). -* **T5.5-2 ownHash.** Changes when: an own-text run is edited; a child is added; removed; two byte-identical children are reordered; an embedded reference is added; removed; retargeted; repositioned between runs. Unchanged when: a child's text is edited (only child's hashes change); an embedded target's text is edited — the fixture MUST include an own-line embedding whose target's edit toggles its subtree text between empty and non-empty, so the Markdown line-drop outcome flips (3) while the embedder's ownHash is byte-identical across the toggle: for own content the excised expression counts as remaining line content and the target's text is no part of it (1.6). Kind distinction (1.6/5.5: child and embedding references distinguished): across a baseline, a child construct is replaced at its exact position by a `text(...)` embedding of the same canonical identity with identical surrounding bytes — journaled-move the child section to another file, then embed the moved node (imported form) at its former position — and the parent's ownHash differs from baseline, the parent `changed`: the two own-content sequences are equal runs around one reference differing only in kind. +* **T5.5-2 ownHash.** Changes when: an own-text run is edited; a child is added; removed; two byte-identical children are reordered; an embedded reference is added; removed; retargeted; repositioned between runs. Unchanged when: a child's text is edited (only child's hashes change); an embedded target's text is edited — the fixture MUST include an own-line embedding whose target's edit toggles its subtree text between empty and non-empty, so the Markdown line-drop outcome flips (3) while the embedder's ownHash is byte-identical across the toggle: for own content the excised expression counts as remaining line content and the target's text is no part of it (1.6). Kind distinction (1.6/5.5: child and embedding references distinguished): across a baseline, a child construct is replaced at its exact position by a `text(...)` embedding of the same canonical identity with identical surrounding bytes — journaled-move the child section to another file, then embed the moved node (imported form) at its former position — and the parent's ownHash differs from baseline, the parent `changed`: the two own-content sequences are equal runs around one reference differing only in kind. Fixture geometry: the child construct and its replacement are in-line — within one line, flanked by content on that line (`foo <S id="c">…</S> baz` at baseline, `foo {text(X.c)} baz` after) — because on its own line a construct's straddling lines drop with their terminators (3) while an own-line `{text(...)}` keeps its line in own content (1.6): the runs would then differ too and the arm would pass vacuously; in-line, the runs are byte-identical and only the reference kind differs. * **T5.5-3 subtreeHash.** Changes exactly under the 5.5 conditions: any descendant added/removed/reordered or any in-subtree own-content change; unchanged for sibling-subtree edits and for embedded-target edits outside the subtree. * **T5.5-4 effectiveHash.** Changes when a dependency edge (either kind a requirement node can bear: `depends` or `embeds` — `references` edges originate only at code locations, 4.5) is added, removed, or retargeted anywhere in the subtree; when a dependency target's effectiveHash changes (transitively); and on retarget between two targets that have equal effectiveHash (byte-identical twin targets fixture). Unchanged when an unrelated node changes. Per-edge pairs (5.5: one pair enters per dependency edge, not per distinct target): a node bearing both `d={T}` and `{text(T)}` to one target contributes two identical pairs — removing the `d` reference alone changes effectiveHash while ownHash stays unchanged (the discriminating arm: a product deduplicating pairs per distinct target reports it unchanged), and removing the embedding alone changes it too. * **T5.5-5 metadataHash.** Changes iff `d` target set, `coverage`, or tags change; embedded `text(...)` references do not affect it; root nodes have a metadataHash (computed from empty inputs) reported by `query node`. Order-insensitivity: reordering the references within one multi-element `d` array and reordering a multi-tag `tags` list changes no hash — metadataHash and effectiveHash included (5.5: target sets enter sorted by canonical identity, tags sorted) — and yields no change categories against a baseline. @@ -235,7 +235,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ### 6.1 The journal -* **T6.1-1 Lifecycle.** No journal file exists after `build` in a fresh workspace; the file appears at `.xspec/journal` with the first `rename`/`move`; each subsequent operation appends exactly one line-oriented entry and rewrites nothing above it (byte-prefix asserted); `build`, `check`, `coverage`, `impact`, `review`, `query` never modify it (byte-compare around each). +* **T6.1-1 Lifecycle.** No journal file exists after `build` in a fresh workspace; the file appears at `.xspec/journal` with the first `rename`/`move`; each subsequent operation appends exactly one line-oriented entry and rewrites nothing above it (byte-prefix asserted); no other command modifies it (6.1): `build`, `check`, `ids`, `show`, `coverage`, `impact`, `review` (the reads and `create`/`resolve`/`split` alike), `query`, `occurrences`, `view`, `at`, `inventory`, `version`, and `rename`/`move --preview` are each byte-compared around their invocation on a journal-bearing workspace — the H-7 mapping of 6.1's never-modified clause, which T13.4-5, T6.6-2, and T11.6-4 cover from their sides. * **T6.1-2 Determinism.** The same operation on the same workspace state (two identical directories) appends byte-identical entries. * **T6.1-3 Integrity.** `check` reports a malformed journal (garbage line), naming the line, with 14.13; a journal path occupied by a directory or symbolic link is a journal error (14.13). The conflicting and well-formed-yet-unreplayable arms of 12.2/14.13 admit no discriminating fixture at `check`: entry content is opaque (6.1, H-4), so such an entry cannot be authored directly, and tampering with product-written lines (duplicating one, recombining entries) has no product-independent expected outcome — manual restructuring is never journaled (6.7), so under some conforming entry contents the tampered bytes are exactly what a legitimate history of journaled operations interleaved with manual edits would have appended (entries are byte-deterministic for a given operation and workspace state, 6.1), indistinguishable and accepted, while under others they are detectably impossible and rejected. Replay failure where outcomes are pinned is tested at baseline resolution: T6.3-4's unresolvable-mapping and prefix-violation arms (6.3). @@ -256,7 +256,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ### 6.4 Rename * **T6.4-1 Rewrites.** Renaming a mid-tree ID rewrites: its `id`, all descendant `id`s by prefix replacement, local string references, external chain references in other files, `text(...)` targets in MDX and TS, and TS markers — workspace builds and all edges retarget (query-asserted); mapping appended to journal. The command's own report is the applied mapping — every identity pair the operation journaled, the information of the preview's `mapping` (6.4) — carried in JSON per 12.0 (H-3 adapter; the successful operation's report shape is unpinned). -* **T6.4-2 Minimal edits.** Quote style (single vs double) and access form (dot vs computed) of untouched reference parts are preserved byte-wise; only the affected parts change. Where the form cannot be kept: a new segment that is not a TS identifier is written as double-quoted computed access; a valid-identifier segment as dot access; string literals double-quoted. +* **T6.4-2 Minimal edits.** Quote style (single vs double) and access form (dot vs computed) of untouched reference parts are preserved byte-wise; only the affected parts change. The rewritten segment itself keeps every keepable form (6.4: the fallback spellings apply only where a form cannot be kept), byte-asserted in MDX and TS spellings alike: a double-quoted computed segment renamed to an identifier-valid name stays computed and double-quoted (`BASE["login-v2"]` → `BASE["login2"]`, never `BASE.login2`); a single-quoted computed segment keeps its single quotes whether its new name is identifier-valid (`BASE['login-v2']` → `BASE['login2']`) or not (→ `BASE['login-v3']`); a dot-access segment renamed to an identifier-valid name stays dot access; a single-quoted local string reference whose own segment is rewritten — `{text('login-v2')}` and a `d` array's `'login-v2'` entry — keeps its single quotes (T6.5-7 pins the same for a move's prefix re-identification). Where the form cannot be kept: a dot-access segment whose new name is not a TS identifier is written as double-quoted computed access; a reference converted between local and imported form (6.5) uses dot access for identifier-valid segments, double-quoted computed access for the others, and double-quoted string literals. A product normalizing every touched segment to dot access or double quotes fails the keepable-form arms. Whole-file byte contract: each rewritten file, `.mdx` and `.ts` alike — T6.4-1's marker and `text(...)`-call rewrites in code included — is asserted byte-equal to an expected file differing from the original in the rewritten segments alone (6.4: minimal in-place edits bind code sources as they bind MDX), failing a product that reprints a code file through a printer on rename. * **T6.4-3 Validation refusals (exit 1).** New ID invalid (1.4); equal to old; colliding with an existing ID; violating structural parent rules. Each refusal modifies nothing (workspace byte-compare). The remaining 6.4 clause — all rewritten references resolve — admits no discriminating fixture: rename rewrites only valid workspaces (T6.4-6) and retargets every affected reference to identities that exist after the operation, so a non-resolving rewritten reference is unconstructible; the clause is exercised as the always-passing side of every successful rename (T6.4-1). * **T6.4-4 Usage errors (exit 2).** Nonexistent `<file>`; nonexistent old ID; a discovered code source as `<file>` — a wrong-kind operand, judged like existence before any content question (6.4). Checked before source validation: same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering); but an old ID inside an unparseable origin file is masked — validation findings reported, exit 1. Old-ID existence is parse-local over spelled identities (6.4, 11.2): renaming an ID two sections both spell is no usage error — the bearers establish existence, their undefined node identities notwithstanding, and the duplicate-ID finding refuses instead (exit 1, the invalid-workspace refusal, T14-7); so is renaming an ID whose sole bearer spells it beneath an ancestor spelling no identity (an undefined ancestor chain — 6.4's second parenthesized mechanism, 11.2): the bearer establishes existence and the ancestor's finding refuses (exit 1, never exit 2); an old ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent — exit 2 even beside that file's findings. * **T6.4-5 Type-level references.** A `typeof`-level reference to the old identity is not rewritten; the workspace stays xspec-valid (the consumer type error is outside xspec's validations — `build` and `check` report no finding for it). @@ -265,14 +265,14 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ### 6.5 Move -* **T6.5-1 File form.** IDs unchanged; identities change file part only; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; journal appended; finishing regeneration as T6.4-7; the applied-mapping report as T6.4-1 (6.5: both forms report as rename does). +* **T6.5-1 File form.** IDs unchanged; identities change file part only; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; journal appended; finishing regeneration as T6.4-7; the applied-mapping report as T6.4-1 (6.5: both forms report as rename does). Byte contract of the specifier rewrite: the importing `.ts` file (T6.2-2's fixture — a marker and a `text(...)` call through one `.xspec` import) and the moved file are each byte-identical to their pre-move bytes outside the specifier literals' characters — the ranges 6.6 classes `import-specifier-rewrite`, read by running the preview on a copy — and within each such range hold a specifier of 2.1's form designating the moved file's module, its relative spelling the product's (2.1 pins the form, not a canonical spelling; byte-determinism, T6.1-2/H-6, pins it product-to-itself), so a file-form move rewrites nothing beyond the literals. * **T6.5-2 Section form text edits.** Byte-exact fixtures: moved text spans opening tag's first character through closing tag's last; origin deletion drops lines left empty/whitespace-only (rule of 3); insertion immediately before the target parent's closing tag (or end of file for top-level `new-id`), followed by U+000A and preceded by one when not at line start; target file created when absent; no other byte changes beyond these edits, the identity and reference rewrites (their bytes: T6.5-7, T6.5-8), and the finishing regeneration (6.5). Self-closing arms (1.1, 6.5), byte-exact: moving a self-closing section moves exactly the self-closing tag's own characters; a self-closing target parent is first rewritten to paired form — its `/` and any whitespace immediately before or after the `/` deleted, the closing tag matching the opening tag's name appended immediately after the tag's terminating `>` — and the insertion rule then applies before that closing tag: a `<Spec id="p" />` parent becomes `<Spec id="p">` + U+000A + the moved text + U+000A + `</Spec>`. * **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. -* **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link (target absent), one arm each (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose target path is occupied by anything other than a discovered spec source — neither an insertion target nor an absent path to create, refused `refused-destination-exists` (6.5, T14-7) — one arm each: a directory; a symbolic link resolving to a discovered spec source (the link-following discriminator: discovery never yields a symlink, 7 — a product resolving the target path through the filesystem finds a spec source there and inserts through the link); and an existing `.mdx` file outside every configured spec group (present, right extension, still no discovered spec source) — an arm asserting two findings, one per reason (14, T14-7): `refused-invalid-destination` (a path in no spec group) is applicable alongside `refused-destination-exists` in every staging of a plain-`.mdx` occupant, since an in-group, non-derived-excluded `.mdx` plain file is always discovered (7, 13.4) — the insertion target, no occupant refusal — and an unparseable discovered occupant is the invalid-workspace precondition's case; the directory and symlink arms, occupant-blocked at otherwise-valid in-group paths, stage singly; section form whose `<new-id>` is not in intrinsic ID form (the mirrored "new ID is valid" check, 6.5; 14: one or more segments joined by `.`, each satisfying 1.4) — a forbidden name (`then`), a whitespace-bearing segment, and the empty `<new-id>` (destination operand `b.mdx#`: one `#`, so the 12.0 split is well-formed and the id part has zero segments), one arm each, each refused `refused-invalid-id` — the empty-id arm discriminating a product that generalizes 11.3's `--to` spelling rule (where the same spelling exits 2 as malformed, T11.3-3) to move operands; section form whose `<new-id>` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; lacking `.mdx`; and the derived-path arm of `refused-invalid-destination` (6.5: a workspace-relative directory component of a derived path the destination would generate — 13.1, 13.2, 7.3 — occupied by a non-directory): with emission enabled under `markdown.outDir`, a file-form move whose destination `new/b.mdx` is otherwise valid, its own directory components unobstructed (`new/` absent — a nonexistent component is never a refusal cause, 13.4, T13.4-8), but whose emit destination `<outDir>/new/b.md` has its component `<outDir>/new` occupied by a plain file lying under no current source's write path (so the workspace passes `build`'s validations) — refused `refused-invalid-destination`, never 14.22 (14, T14-7), discriminating a product that vets only the destination path's own components; the module and companion paths share the destination's directory (13.1), so the `outDir` emit destination is the separable derived-path fixture. The 6.5 destination clauses "containing `#`" and "not valid UTF-8" admit no staging (dead letters, recorded here as T6.5-6 records its unstageable clauses): a destination path exists only as an operand spelling, and every spelling that would present either is a usage error before any refusal is evaluated — a non-UTF-8 argument value exits 2 (12.0, T12.0-5), so a valid operand never denotes a non-UTF-8 path; a `#`-containing file-form destination classifies as a `<file>#<id>` pair, a mixed-synopsis exit 2 (T6.5-5); and a `#` in the section form's target-file part makes a two-`#` operand, the malformed value T12.0-13 pins as exit 2 on `move`. Plus the valid-workspace precondition as T6.4-6. +* **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link (target absent), one arm each (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose target path is occupied by anything other than a discovered spec source — neither an insertion target nor an absent path to create, refused `refused-destination-exists` (6.5, T14-7) — one arm each: a directory; a symbolic link resolving to a discovered spec source (the link-following discriminator: discovery never yields a symlink, 7 — a product resolving the target path through the filesystem finds a spec source there and inserts through the link); and an existing `.mdx` file outside every configured spec group (present, right extension, still no discovered spec source) — an arm asserting two findings, one per reason (14, T14-7): `refused-invalid-destination` (a path in no spec group) is applicable alongside `refused-destination-exists` in every staging of a plain-`.mdx` occupant, since an in-group, non-derived-excluded `.mdx` plain file is always discovered (7, 13.4) — the insertion target, no occupant refusal — and an unparseable discovered occupant is the invalid-workspace precondition's case; the directory and symlink arms, occupant-blocked at otherwise-valid in-group paths, stage singly; section form whose `<new-id>` is not in intrinsic ID form (the mirrored "new ID is valid" check, 6.5; 14: one or more segments joined by `.`, each satisfying 1.4) — a forbidden name (`then`), a whitespace-bearing segment, and the empty `<new-id>` (destination operand `b.mdx#`: one `#`, so the 12.0 split is well-formed and the id part has zero segments), one arm each, each refused `refused-invalid-id` — the empty-id arm discriminating a product that generalizes 11.3's `--to` spelling rule (where the same spelling exits 2 as malformed, T11.3-3) to move operands; section form whose `<new-id>` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; lacking `.mdx`; and the derived-path arm of `refused-invalid-destination` (6.5: a workspace-relative directory component of a derived path the destination would generate — 13.1, 13.2, 7.3 — occupied by a non-directory): with emission enabled under `markdown.outDir`, a file-form move whose destination `new/b.mdx` is otherwise valid, its own directory components unobstructed (`new/` absent — a nonexistent component is never a refusal cause, 13.4, T13.4-8), but whose emit destination `<outDir>/new/b.md` has its component `<outDir>/new` occupied by a plain file lying under no current source's write path (so the workspace passes `build`'s validations) — refused `refused-invalid-destination`, never 14.22 (14, T14-7), discriminating a product that vets only the destination path's own components; the module and companion paths share the destination's directory (13.1), so the `outDir` emit destination is the separable derived-path fixture. The symbolic-link arms of the same clause (6.5: a component occupied by a symbolic link, whatever it targets — discovery never traverses one, 7, and writes never traverse or replace one, 13.4): a file-form move to `specs/sub/b.mdx` and a section-form move creating the target file `specs/sub/new.mdx`, where `specs/sub` is a symbolic link to a real, empty directory — each form staged with the link targeting a directory inside the workspace and, separately, one outside the workspace root — are refused `refused-invalid-destination`, never 14.22 (14, T14-7), exit 1, nothing modified: the link itself and its target directory are byte-identical afterward, no file written through the link inside or outside the workspace; and a sibling of the derived-path arm stages `<outDir>/new` as such a link instead of a plain file, refused identically. These are the discriminating cases for a product vetting components through link-following `stat`, which sees a directory at the link, proceeds, and writes the moved file — and its regenerated derived files — through the link, possibly outside the workspace; T13.4-6's symlink arm covers only 14.22 on `build` writes, never this refusal. The link lies under no current source's write path and is never a source (7), so the workspace passes `build`'s validations. The 6.5 destination clauses "containing `#`" and "not valid UTF-8" admit no staging (dead letters, recorded here as T6.5-6 records its unstageable clauses): a destination path exists only as an operand spelling, and every spelling that would present either is a usage error before any refusal is evaluated — a non-UTF-8 argument value exits 2 (12.0, T12.0-5), so a valid operand never denotes a non-UTF-8 path; a `#`-containing file-form destination classifies as a `<file>#<id>` pair, a mixed-synopsis exit 2 (T6.5-5); and a `#` in the section form's target-file part makes a two-`#` operand, the malformed value T12.0-13 pins as exit 2 on `move`. Plus the valid-workspace precondition as T6.4-6. * **T6.5-5 Usage errors (exit 2).** Nonexistent origin file or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y`, `a.mdx#x b.mdx`, and `a.mdx b#c.mdx` — the last a `#`-containing file-form destination, classified as a pair — match neither form, exit 2, and an operand containing `#` is always a `<file>#<id>` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). A non-UTF-8 destination operand (raw bytes in the OS argument vector, Linux leg) is a usage-error argument value, exit 2 (12.0, T12.0-5). These exit-2 invocations are the stagings T6.5-4's dead-letter note sets aside. * **T6.5-6 Identity terms.** The new-identity checks read in identity terms (6.5): a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid; the exact self-move — `<target-file>#<new-id>` equal to `<file>#<id>` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `<new-id>` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `<new-id>` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. So is the mirrored "structural parent rules remain satisfied" check, in both forms: the file form changes no ID and no within-file nesting; in the section form the target parent is located as the target file's section bearing `<new-id>` minus its final segment — absent, or lying within the moved subtree, it is `refused-missing-target-parent` (T6.5-4) — prefix replacement preserves the subtree's relative nesting, a single-segment `<new-id>` inserts at top level (exactly one segment, 1.3), and the origin's removal disturbs no remaining ID; on a workspace passing the valid-workspace precondition, 1.3 therefore holds by construction after every non-refused move, the check is exercised as the always-passing side of every successful move (T6.5-1/2/3), and `refused-structural-parent` is staged through rename alone (T6.4-3, T14-7). -* **T6.5-7 Operation-side rewrite bytes.** The real move's import-edit extents and reference-conversion spellings, byte-asserted against independently composed expected files — staged so no import is added, the one rewrite direction free of implementation latitude (6.5: identifier choice and insertion offset attach to added imports alone; the addition side's line discipline: T6.5-8). Fixture: the origin file imports the target file under two bindings (valid, 2.1), one declaration alone on its line, the other following a retained, still-referenced third-module import on a shared line; every reference through the two bindings — a `d` chain and a `text(...)` embedding — lies inside the moved subtree, which also holds a single-quoted local string reference to a moved descendant; no reference to a moved node lies outside the subtree. After the section move into the target file: both target-file imports are left unreferenced and removed with 6.5's exact extent — the own-line declaration's line dropped with its terminator (3), the shared-line declaration's own characters alone deleted, the retained import kept byte-for-byte on its kept line; the moved references convert to local form in 6.4's pinned spelling for converted references, double-quoted string literals; the local reference is re-identified by prefix replacement with its single-quote spelling preserved (6.4: minimal in-place edits); and the rewritten origin and target files are each asserted byte-equal to expected bytes composed from the rules of 6.5 and 3. This is the operation-side assertion T6.5-2 sets aside (its no-other-byte-changes check excludes the identity and reference rewrites) and T6.6-4 makes only of the preview's report — failing a product that emits a spec-perfect preview while the real edit leaves an emptied import line behind, normalizes whitespace around a removed declaration, or spells a converted reference single-quoted. +* **T6.5-7 Operation-side rewrite bytes.** The real move's import-edit extents and reference-conversion spellings, byte-asserted against independently composed expected files — staged so no import is added, the one rewrite direction free of implementation latitude (6.5: identifier choice and insertion offset attach to added imports alone; the addition side's line discipline: T6.5-8). Fixture: the origin file imports the target file under two bindings (valid, 2.1), one declaration alone on its line, the other following a retained, still-referenced third-module import on a shared line; every reference through the two bindings — a `d` chain and a `text(...)` embedding — lies inside the moved subtree, which also holds a single-quoted local string reference to a moved descendant; no reference to a moved node lies outside the subtree. After the section move into the target file: both target-file imports are left unreferenced and removed with 6.5's exact extent — the own-line declaration's line dropped with its terminator (3), the shared-line declaration's own characters alone deleted, the retained import kept byte-for-byte on its kept line; the moved references convert to local form in 6.4's pinned spelling for converted references, double-quoted string literals; the local reference is re-identified by prefix replacement with its single-quote spelling preserved (6.4: minimal in-place edits); and the rewritten origin and target files are each asserted byte-equal to expected bytes composed from the rules of 6.5 and 3. This is the operation-side assertion T6.5-2 sets aside (its no-other-byte-changes check excludes the identity and reference rewrites) and T6.6-4 makes only of the preview's report — failing a product that emits a spec-perfect preview while the real edit leaves an emptied import line behind, normalizes whitespace around a removed declaration, or spells a converted reference single-quoted. The code-source counterpart, fully composed (no import added, so no latitude): a `.ts` file importing the origin module, the target module, and a retained third module — the third module's declaration and the origin's sharing one line in a second variant, the origin's following it — whose only references through the origin binding are markers on nodes of the moved subtree, beside a marker through the target binding and one through the third; after the section move, the origin-module import — its binding left without references — is removed with 6.5's exact extent (the own-line declaration's line dropped with its terminator; the shared-line declaration's own characters alone deleted, the retained declaration kept byte-for-byte on its kept line), the moved markers are rewritten through the existing target binding (6.5: an import is added only where the file lacks the binding), and the file is asserted byte-equal to expected bytes composed from the rules of 6.4/6.5 and 3 — the import-removal rule binds code sources as it binds MDX, and a product that reprints a code file on removal fails here while passing every resolution-only assertion (T6.5-1, T6.2-2). The addition side in code is T6.5-8's TS arm. * **T6.5-8 Added-import insertion discipline.** The addition-side byte contract of 6.5 — an added import is inserted as a line of its own, the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — asserted with the identifier choice and insertion offset left free (6.5's latitude), shape-aware and value-blind (the ethos T6.5-7 applies to removals). Two section-move arms, each staged so the receiving file's expected post-move bytes are composable from the rules of 6.4/6.5 and 3 up to exactly two unknowns, the fresh identifier and the insertion offset: a TS arm, the grammar-freest case — a code file imports the origin module and references one moved and one unmoved node, so the rewrite needs a target-module binding the file lacks while the origin import keeps its remaining reference and stays — and an MDX arm — the origin file, already holding a retained third-module import (grammar-permitted offsets exist, and freshness is live against its binding), keeps a local string reference to a moved descendant, whose conversion to imported form (6.4's pinned spellings) makes the origin file itself gain the target module's import. In each arm the harness isolates the single added byte run by diff against the composed bytes and asserts it is exactly the declaration followed by U+000A at an insertion offset lying at the start of a line, and exactly U+000A, then the declaration, then U+000A at one that does not — the declaration one import of the target module's specifier binding one fresh identifier (value unpinned) that the rewritten references use, no other byte inserted. This fails a product that joins the added declaration to a neighbor with `;` — still parsing, resolving, and byte-deterministic in either grammar, the shared line still dropping whole from Markdown output in the MDX arm (3), hence passing every other test — omits the preceding terminator at a mid-line offset, adds a spurious blank line, or writes any terminator but U+000A, wherever it chose to insert: a line-start offset is always grammar-available, so the mid-line branch is reachable by implementation latitude alone, and the discipline is asserted over whatever offset the product makes observable (as T6.6-4 asserts its ordering tie-break). @@ -283,7 +283,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T6.6-2 Modifies nothing.** A rename `--preview` and a section-form move `--preview` on workspaces where the real operation would proceed: exit 0, findings `[]`, and every byte of the workspace identical afterward — sources, journal (an absent journal stays absent), derived files, and graph data untouched; a subsequent real run on the same state performs the previewed plan, its applied mapping (T6.4-1) equal to the preview's `mapping`. Preview output is byte-deterministic across repeated runs (H-6) and, under `--json`, the form-exact 12.7 preview document (H-3). * **T6.6-3 Refusal and scheduling equivalence.** For each refusal of T6.4-3 and T6.5-4 — the invalid-workspace precondition included — staged identically, the `--preview` invocation reports the same findings (same stable codes, locations, identities; 14) and exits 1, its `mapping`, `files`, and `delta` `null` (12.7), modifying nothing; for the usage errors of T6.4-4/T6.5-5 the preview exits 2 identically (argument checks precede either way). The equivalence is over workspace state, never scheduling (6.6): while another mutating command is held (`--test-hold`, 13.5), a `--preview` invocation runs to completion — it takes no exclusivity and never meets the mutual-exclusion refusal of T13.5-2 — and `--test-hold` combined with `--preview` is a usage error, exit 2. * **T6.6-4 Report content.** Byte-precise fixtures asserted against precomputed pre-operation offsets, form-exact per 12.7: (a) a rename preview reports the complete identity mapping — the renamed ID and every descendant — and, per rewritten file, `id-rewrite` edits spanning each rewritten `id` attribute's own characters and `reference-rewrite` edits spanning each affected occurrence's span (5.7) across MDX and TS; (b) a section-move preview into an existing target file reports the `origin-deletion` as one range spanning the construct's own characters extended over the leftover whitespace and line terminator of each additionally dropped line (contiguous bytes: the adjunct drop lies inside this range, no class of its own), the re-identification's `id-rewrite` edits nested inside that deletion range in the same pre-operation coordinates (containment is geometry, each edit under its own class), `target-insertion` as a zero-length range at the insertion offset, `target-parent-rewrite` spanning a self-closing target parent's tag, `import-addition` as a zero-length range at the exact offset the real operation then uses (6.5; byte-asserted by running the operation on a copy), and `import-removal` spanning the declaration plus its adjunct drops; (c) a file-form move preview reports `import-specifier-rewrite` edits spanning the specifier literals and `file-relocation` spanning the entire moved file, the relocated file's entry under its current, pre-operation path; (d) a section-move preview whose target file does not exist reports, under the path the creation would occupy, exactly one `file-creation` edit at the start of the new file — the only location without pre-operation coordinates; the insertion and any import additions there are subsumed, while the moved text's own rewrites are reported inside the origin file's deletion range. Every edit is class-plus-range only — no replacement text (12.7) — and every reported class is one of the ten 12.7 class names. The edit ordering's final tie-break — class-name bytes after range start and range end (12.7) — admits no product-independent discriminating fixture: distinct nonzero-range edits rewrite or remove distinct constructs and never share both endpoints, so an identical-range pair arises only between zero-length insertion points — an import addition whose implementation-chosen offset (6.5) coincides with the target insertion, possible only at end of file for a top-level `<new-id>`, an import being grammar-permitted nowhere inside a section construct — a coincidence implementation latitude alone decides; the harness asserts the full 12.7 comparator over whatever edits are emitted (`import-addition` ordering before `target-insertion` on coincidence), exercising the tie-break exactly where a product makes it observable. -* **T6.6-5 Delta.** After a build, a file-form move preview reports the derived-file delta both directions: under `generated` the destination's module, companion, and (emission enabled) Markdown paths — nothing recorded there — and under `removed` the recorded pre-move module, companions, and Markdown the operation would leave no longer generated (6.6); a rename preview on the same workspace reports `[]` in both directions (regeneration rewrites recorded paths in place), and the created-target move of T6.6-4(d) reports the new file's derived paths under `generated`. Record-based, not presence-based: with graph data deleted (T13.3-2's operational definition) the same move preview's `generated` approaches the full regeneration set — nothing being recorded — and the preview still writes nothing: no refresh, graph data still absent afterward. +* **T6.6-5 Delta.** After a build, a file-form move preview reports the derived-file delta both directions: under `generated` the destination's module, companion, and (emission enabled) Markdown paths — nothing recorded there — and under `removed` the recorded pre-move module, companions, and Markdown the operation would leave no longer generated (6.6); a rename preview on the same workspace reports `[]` in both directions (regeneration rewrites recorded paths in place), and the created-target move of T6.6-4(d) reports the new file's derived paths under `generated`. Record-based, not presence-based, with exact expectations in both directions (12.7: form-exact, paths in byte order): with graph data deleted (T13.3-2's operational definition) — the record then missing, read as empty (11.6: empty before any generation has run), never as unreadable (14.23, T6.6-6) — the same move preview's `generated` is exactly every derived path the post-operation workspace generates, nothing being recorded: the module and companions of every discovered spec source, the moved file's under its destination path, plus each source's Markdown emit destination with emission enabled — composed from the `recorded` set the inventory reported after the build (11.6; the companion suffixes are the product's own, H-4), the moved file's entries re-based to its destination — and its `removed` is exactly `[]`; and the preview still writes nothing: no refresh, graph data still absent afterward. Lagging-record counterpart: with emission enabled in the configuration after the build and no rebuild, the same preview's `generated` is exactly the destination's module, companions, and Markdown together with every other discovered spec source's Markdown emit destination — the paths the current configuration generates that the stale record lacks — and its `removed` exactly the recorded pre-move module and companions. * **T6.6-6 Unreadable record.** Corrupt the product-written graph data shape-blind (truncation or garbage over T13.3-2's operational path set; H-3/H-4 staging discipline, as T10.1-4 stages sessions): a move `--preview` whose plan is otherwise valid emits the full preview — `mapping` and `files` complete — with `delta` explicitly unavailable as one datum, never read as an empty record, the condition-23 finding (`unreadable-record`, concerned path the graph-data area, no path inside it named) in `findings`, exit 1 (14.23). The real operation on the same state is not refused — it proceeds, its finishing regeneration replacing the corrupt record (`check` clean afterward, T12.2-2) — and a refused preview staged on the same corrupt-record state (an identity-unchanged rename) reports the refusal findings alone, `mapping`/`files`/`delta` `null`, never a condition-23 finding (6.6: a refused preview consults no record). ### 6.7 Manual restructuring @@ -317,7 +317,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T8-3 Boundaries.** A spec-group boundary (spec→spec edges) and a code-group boundary (marker/`text` edges from code) each grant coverage (`boundaryKind` both inferred and explicit). * **T8-4 Boundary∩target overlap.** One file belongs to both the target and the boundary spec group (T7.1-1). A required node of that file with no incoming dependency edge is itself a boundary node yet MUST be reported uncovered, in `direct` and in `transitive` mode: coverage needs a path of one or more edges from a boundary node to the target (8), and boundary membership alone is no such path. A sibling required node of the same file with a single incoming `depends` edge from another node of the file (itself a boundary node) is covered in both modes. * **T8-5 Root path exclusion.** Spec groups `base` (file A) and `derived` (file B); A holds a top-level `{text(B.b1)}` outside any section — a root-sourced `embeds` edge A-root → `b1` (2.3) — and a section `a1` with `d={B}` — a root-targeted `depends` edge `a1` → B-root (2.2); B holds a top-level `{text("b2")}` — B-root → `b2`. Profiles target `derived` with boundary `base`, one `direct` and one `transitive`. Assertions (8): `b1` is uncovered in both modes — a spec-group boundary contributes only its non-root nodes as boundary nodes, and a root-sourced edge never extends a covering path; `b2` is uncovered in `transitive` mode although `a1` → B-root → `b2` is a chain of dependency edges — a root is never an intermediate, and neither the root-targeted nor the root-sourced edge extends a covering path. Coverage-scoped exclusion (8): the same edges remain ordinary dependency edges — a `forbidden` rule from `base` to `derived` reports both the A-root → `b1` and the `a1` → B-root edge (7.5); editing `b1`'s text changes A-root's effectiveHash through the root-sourced dependency pair — `b1` is no child of A-root, so containment cannot explain it — and changes B-root's effectiveHash through containment, hence `a1`'s through the root-targeted pair: A-root and `a1` are both `upstream-changed` (5.5); `query edges` reports both edges (11; with T2.2-2). One workspace asserting: group restriction; `targetTags` restriction; `"leaves"` vs `"all"`; `coverage="none"` exclusion; root exclusion. -* **T8.2-1 Report.** All profiles run by default; `coverage <name>` runs one; counts of required/covered/uncovered/ignored; identity of every covered, uncovered, and ignored node; one shortest covering path per covered node with the 12.0 tie-break (fixture with two equal-length paths asserts the byte-least one); ignored nodes report all applicable reasons in the fixed order (a node that is simultaneously `coverage="none"`, non-leaf, and untagged; and a root with children in a profile with `targets: "leaves"` and `targetTags` — reasons `root node`, non-leaf, and lacking every tag, pinning the `root node` reason's position against those two; the fixed order's remaining pair, `root node` relative to `coverage="none"`, is unobservable on any node — reasons are reported per node, and no node bears both, a root carrying no coverage attribute (5.5) — recorded here as T6.5-6 records its unstageable clauses); `--check` exits 1 iff any required node is uncovered (0 otherwise); `--json` carries the same information (adapter-asserted equality of information). +* **T8.2-1 Report.** All profiles run by default; `coverage <name>` runs one; counts of required/covered/uncovered/ignored; identity of every covered, uncovered, and ignored node; one shortest covering path per covered node with the 12.0 tie-break (fixture with two equal-length paths asserts the byte-least one); ignored nodes report all applicable reasons in the fixed order (a node that is simultaneously `coverage="none"`, non-leaf, and untagged; and a root with children in a profile with `targets: "leaves"` and `targetTags` — the root-node reason, non-leaf, and lacking every tag — each reason adapter-located information, never exact wording (H-3: 8.2 names the reasons in prose; the fixed order is the contract) — pinning the root-node reason's position against those two; the fixed order's remaining pair, the root-node reason relative to `coverage="none"`, is unobservable on any node — reasons are reported per node, and no node bears both, a root carrying no coverage attribute (5.5) — recorded here as T6.5-6 records its unstageable clauses); `--check` exits 1 iff any required node is uncovered (0 otherwise); `--json` carries the same information (adapter-asserted equality of information). ## 9. Impact Analysis @@ -426,7 +426,7 @@ Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are ### 11.4 `xspec view` -* **T11.4-1 Views and tree.** With neither operands nor `--file`, every discovered spec source is viewed, multi-file order by path bytes, one JSON document; per parseable file: the root and the full positional section tree in document order — a section nested inside an invalid non-section element parents to the innermost enclosing section construct (the enclosure 11.2's chain conditions read), the root when none encloses it; per node, its construct range and the decomposition: opening and closing tag ranges for paired sections, opening only for self-closing, neither for the root — byte-asserted against precomputed offsets (1.7). +* **T11.4-1 Views and tree.** With neither operands nor `--file`, every discovered spec source is viewed, multi-file order by path bytes, one JSON document; per parseable file: the root and the full positional section tree in document order — a section nested inside an invalid non-section element parents to the innermost enclosing section construct (the enclosure 11.2's chain conditions read), the root when none encloses it; per node, its construct range and the decomposition: opening and closing tag ranges for paired sections, opening only for self-closing, neither for the root — byte-asserted against precomputed offsets (1.7). List orders (12.7): on a file holding several import declarations, several MDX comments, and several reference occurrences, `imports`, `comments`, and `occurrences` are each in document order, form-exact (H-3) — asserted here because P-12 sorts the view's occurrences itself and cannot see a misordered list. * **T11.4-2 Operands vs restriction.** `<file>` operands assert membership: an undiscovered file → exit 2 (unknown); a discovered code source → exit 2 (wrong-kind operand, 12.0); `--file` restricts the domain: a glob matching nothing, or only code sources, admits the empty set — empty, finding-free answer, exit 0; combining `<file>` operands with `--file` → exit 2; the requested files form a set (a file named twice yields one view). * **T11.4-3 Attributes and per-node data.** Raw attribute spellings as parsed, one entry per spelled attribute in tag order — a repeated `id` (both entries), an unknown prop, a spread attribute (`name` structurally absent, its text the whole braced construct), a valueless prop (bare name) — each with range and source text; inclusion is by form, the invalidity a located finding beside the view, never an omission (14.17). Per-node `identity`, `tags`, `coverage` each plain or explicitly unavailable per T11.2-2; a root's `tags` and `coverage` are structurally absent — the stated `null`, never the unavailability marker, no finding and no exit-1 consequence: a finding-free file's view exits 0 with them `null` (11.4, 12.7). * **T11.4-4 Imports.** Every import declaration, valid and invalid, with its range; its binding name — the default binding's identifier; structurally absent for the side-effect-only, named-only, and namespace-only forms (never "unavailable"; a named-clause identifier is not this datum) — and its resolved target where specifier form and discovery define one, explicitly unavailable otherwise (`./typo.xspec`; a bare specifier), the invalidity a located 14.15 finding beside it (11.4). @@ -518,17 +518,17 @@ Assertions here — and wherever these forms appear across the suite — are for * **T13.3-1 Serving reads.** After `build`, the read commands (`check`, `ids`, `show`, `coverage`, `impact`, `review`, `query`, `occurrences`, `view`, `at`) answer without error; graph data lives under `.xspec/`. * **T13.3-2 Refresh.** Delete the graph data — operationally, here and in T13.4-3: every path under `.xspec/` except the durable `.xspec/journal` and `.xspec/reviews/` (13.4); graph-data content is opaque (H-4), so tests only ever remove or compare it whole — (or edit a source) and run each of `ids`, `show`, `coverage`, `impact`, `review status`, `query`, `occurrences`, `view`, `at`: the answer reflects current sources (never stale data), graph data is rewritten as `build` would write it, but no TypeScript or Markdown is generated or removed and recorded derived-file paths are unchanged (a stale generated module stays stale — `check` still reports 14.10 afterwards). Record discipline (13.3): with the record corrupted shape-blind instead (T6.6-6's staging), each refreshing read answers finding-free — exit 0 on the otherwise clean workspace — reporting nothing for the record and leaving the corrupt state neither read, repaired, nor replaced: `inventory` afterwards still reports `recorded` unavailable (T11.6-4), until a successful `build` or a finishing regeneration (6.4) replaces the state. -* **T13.3-3 Failed refresh.** With invalid sources, each gated read command (`ids`, `show`, `coverage`, `impact`, `review`, `query`) reports the validation errors, exits 1, answers nothing, and modifies nothing (derived files and graph data byte-identical). The mutating `review` subcommands observe the same rule — 13.3 binds `review` whole, and `create`, `resolve`, and `split` all consult the current graph: on a workspace holding a session, a configured coverage profile, and a resolvable commit from when its sources were valid, a source is then edited to fail validation, and `review create` under each of `--base` (the resolvable ref — baseline resolution precedes source validation, 12.0, so the refresh failure is the operative error), `--strategy audit`, and `--coverage`, plus `resolve` and `split` naming the pre-existing session and an unblocked item, each reports the validation errors, exits 1, and modifies nothing: no session file is created, and the existing session file, journal, derived files, and graph data are byte-identical (no status recorded, no decomposition). The gate is over every finding a `build` would report, source validity or not (13.3: source validation errors, journal errors, and refused writes alike): on an otherwise-valid workspace with a garbage journal line staged, and separately with an obstructed write path staged (T11.2-6's two fixtures), each of `ids`, `show`, `coverage`, `impact`, `review status`, and `query` reports that finding — the journal error (14.13) naming the line, the refused write (14.22) its offending component — exits 1, answers nothing, and modifies nothing (journal, sessions, derived files, and graph data byte-identical), discriminating a product that gates on source validity alone and answers `query` from a broken journal with exit 0 (refresh consumes the journal for canonical identities, 5.4). The never-gated contrast: on the same failing workspaces `occurrences`, `view`, and `at` answer per file under 11.2 (T11.2-1, T11.2-6) and `inventory` answers whatever the sources' validity (T11.6-4) — none of them modifying anything. +* **T13.3-3 Failed refresh.** With invalid sources, each gated read command (`ids`, `show`, `coverage`, `impact`, `review`, `query`) reports the validation errors, exits 1, answers nothing, and modifies nothing (derived files and graph data byte-identical). The mutating `review` subcommands observe the same rule — 13.3 binds `review` whole, and `create`, `resolve`, and `split` all consult the current graph: on a workspace holding a session, a configured coverage profile, and a resolvable commit from when its sources were valid, a source is then edited to fail validation, and `review create` under each of `--base` (the resolvable ref — baseline resolution precedes source validation, 12.0, so the refresh failure is the operative error), `--strategy audit`, and `--coverage`, plus `resolve` and `split` naming the pre-existing session and an unblocked item, each reports the validation errors, exits 1, and modifies nothing: no session file is created, and the existing session file, journal, derived files, and graph data are byte-identical (no status recorded, no decomposition). The gate is over every finding a `build` would report, source validity or not (13.3: source validation errors, journal errors, and refused writes alike): on an otherwise-valid workspace with a garbage journal line staged, and separately with an obstructed write path staged (T11.2-6's two fixtures, each holding an `audit` session created while the workspace was valid — no baseline to resolve, so the gate alone stands between the invocation and an answer; a baseline session yields the same exit 1 only by 13.3's rule that no session is read on a failing workspace, T10.1-5), each of `ids`, `show`, `coverage`, `review status`, and `query` — and, in the obstructed-write staging alone, `impact --base` against a commit taken before the obstruction was staged, its baseline resolving and 14.22 the operative gate finding — reports that finding — the journal error (14.13) naming the line, the refused write (14.22) its offending component — exits 1, answers nothing, and modifies nothing (journal, sessions, derived files, and graph data byte-identical), discriminating a product that gates on source validity alone and answers `query` from a broken journal with exit 0 (refresh consumes the journal for canonical identities, 5.4). `impact` is absent from the journal-error staging by necessity, not oversight: `impact` always takes `--base` (9), baseline resolution precedes source validation (12.0), and the suffix replay of 6.3 meets a garbage line appended after the baseline commit as an unresolvable mapping — exit 2, the usage error T6.3-4 pins — while a garbage line already committed at the baseline ref makes a baseline that cannot be validated as a workspace, exit 2 again (6.3); no journal-error staging reaches this gate at `impact`. The never-gated contrast: on the same failing workspaces `occurrences`, `view`, and `at` answer per file under 11.2 (T11.2-1, T11.2-6) and `inventory` answers whatever the sources' validity (T11.6-4) — none of them modifying anything. * **T13.3-4 Determinism.** Graph data files are byte-deterministic across rebuilds of an identical workspace (content otherwise unasserted, H-4). ### 13.4 Derived and durable files -* **T13.4-1 Plain committable files.** Every file xspec writes is a plain file; writing the workspace into git and back (commit, clean checkout) round-trips builds and reads. Sorted keys (13.4): every JSON object in a product-written session file — read after `create` and again after a `resolve` rewrites it (10.4) — has its keys in byte-sorted order — 13.4 leaves "sorted" unqualified; the assertion pins it, recorded as an interpretive pin, to byte order as the sole string order SPEC.md defines (12.0), so a product sorting keys under any other deterministic collation fails by intent — asserted shape- and value-blind over whatever objects and keys are present (H-3: no shape or values pinned). The session file is the one written class where the clause is discriminable: journal entry content (6.1) and graph data (13.3) are opaque, and generated TypeScript and Markdown carry no keyed serialization; the sibling stable-ordering clause is covered by the determinism protocol (H-6, T10.1-1, T12.0-7, T13.3-4). +* **T13.4-1 Plain committable files.** Every file xspec writes is a plain file; writing the workspace into git and back (commit, clean checkout) round-trips builds and reads. Sorted keys (13.4): every JSON object in a product-written session file — read after `create` and again after a `resolve` rewrites it (10.4) — has its keys in byte-sorted order — 13.4 leaves "sorted" unqualified; the assertion pins it, recorded as an interpretive pin, to byte order as the sole string order SPEC.md defines (12.0), so a product sorting keys under any other deterministic collation fails by intent (a pin non-discriminating over ASCII keys, where byte, UTF-16 code-unit, and code-point orders coincide: it adds no requirement in practice unless a product keys an object by non-ASCII strings — recorded so the interpretive label is not read as a new requirement) — asserted shape- and value-blind over whatever objects and keys are present (H-3: no shape or values pinned). The session file is the one written class where the clause is discriminable: journal entry content (6.1) and graph data (13.3) are opaque, and generated TypeScript and Markdown carry no keyed serialization; the sibling stable-ordering clause is covered by the determinism protocol (H-6, T10.1-1, T12.0-7, T13.3-4). * **T13.4-2 Derived reproducibility.** Delete, truncate, and garbage-overwrite each class of derived file (module, companion, Markdown, graph data): `build` restores all byte-exactly. -* **T13.4-3 Orphan knowledge boundary.** Build so a derived file exists and is recorded; delete all graph data (T13.3-2's operational definition), taking the recorded derived-file paths with it; change the configuration so that file is no longer generated; `build`: the orphaned file is outside xspec's knowledge and is not removed; it may be deleted manually (asserted: subsequent builds leave the stray file alone). +* **T13.4-3 Orphan knowledge boundary.** Build so a derived file exists and is recorded; delete all graph data (T13.3-2's operational definition), taking the recorded derived-file paths with it; change the configuration so that file is no longer generated; `build`: the orphaned file is outside xspec's knowledge and is not removed; it may be deleted manually (asserted: subsequent builds leave the stray file alone). The unreadable half of the same boundary (13.4: a record missing or unreadable, 14.23): with the record instead corrupted shape-blind (T6.6-6's staging) before the configuration change, `build` replaces the record (14.23, 13.3) and leaves the now-orphaned file in place, subsequent builds leaving it alone likewise. * **T13.4-4 Derived paths belong to xspec.** A user-created file at a derived path is replaced by `build`; a symbolic link at a derived file's own path is replaced as the occupant — nothing is written through it (link target byte-identical after build, link gone, plain file present; not an error). * **T13.4-5 Durable protection.** `build` and read commands never modify or delete the journal or session files (byte-compare); durable files are never regenerated (deleting a session file: xspec does not recreate it; review naming it → exit 2 unknown session). -* **T13.4-6 Obstructed write paths.** A write whose path has a symbolic link at a workspace-relative directory component is refused before anything is modified (14.22; command exits 1 with the report, workspace byte-identical); `check` reports the same without writing; occupant kinds (14.22: a plain file, a symbolic link whatever it targets, or any other non-directory occupant) — a plain file occupying a directory component of a `build` write path, a first emission's `outDir` component with no move operand involved (a plain-file component under a move's destination or its derived paths reports `refused-invalid-destination` instead, T6.5-4, T14-7), is refused identically: `build` exits 1 with the condition-22 finding, concerned path that component, modifying nothing, and `check` reports it without writing; finding cardinality (14.22: one finding per distinct offending component, whatever write paths it refuses) — one non-directory occupant at a component under which two derived files would be written yields one finding, concerned path that component, and two distinct offending components yield two findings (asserted via `check`); a durable path occupied by a symlink or non-plain file → journal error (14.13) / corrupt session (14.21) — never read, appended, or replaced (link and target byte-identical after the attempt). Positive counterpart: path components above the workspace root are unrestricted (13.4) — a workspace whose absolute path traverses a symbolic link above its root builds, mutates, and `check`s normally. +* **T13.4-6 Obstructed write paths.** A write whose path has a symbolic link at a workspace-relative directory component is refused before anything is modified (14.22; command exits 1 with the report, workspace byte-identical); `check` reports the same without writing; occupant kinds (14.22: a plain file, a symbolic link whatever it targets, or any other non-directory occupant) — a plain file occupying a directory component of a `build` write path, a first emission's `outDir` component with no move operand involved (a plain-file or symbolic-link component under a move's destination or its derived paths reports `refused-invalid-destination` instead, T6.5-4, T14-7), is refused identically: `build` exits 1 with the condition-22 finding, concerned path that component, modifying nothing, and `check` reports it without writing; finding cardinality (14.22: one finding per distinct offending component, whatever write paths it refuses) — one non-directory occupant at a component under which two derived files would be written yields one finding, concerned path that component, and two distinct offending components yield two findings (asserted via `check`); a durable path occupied by a symlink or non-plain file → journal error (14.13) / corrupt session (14.21) — never read, appended, or replaced (link and target byte-identical after the attempt). Positive counterpart: path components above the workspace root are unrestricted (13.4) — a workspace whose absolute path traverses a symbolic link above its root builds, mutates, and `check`s normally. * **T13.4-7 Source exclusion.** T7-6 covers `.xspec.`/`.xspec/`/emit-destination exclusion from groups. * **T13.4-8 Writes create missing directories.** A missing intermediate directory never refuses or fails a write: the nonexistent workspace-relative directory components of a written path come into existence as directories (13.4), each named case staged with its directories absent beforehand and asserted present as real directories afterward — a file-form move to `new/deep/b.mdx` (destination in a configured spec group, `new/` absent) succeeds, the moved file and its regenerated derived files under the fresh directories; a section-form move whose created target file (6.5) lies under an absent directory succeeds likewise; a first emission under a nested nonexistent `markdown.outDir` (7.3) writes every destination, creating the chain. @@ -554,7 +554,7 @@ Sections 1–13 exercise each numbered condition in its home context; this secti * **T14-4 Reporter matrix.** 14.10 and 14.12 reported by `check` only (a stale workspace `build`s successfully by regenerating; a policy-violating workspace `build`s successfully); 14.21 reported by `check`, by `review` subcommands naming the session, and by `review list` — not by `build`, and on a workspace failing `build`'s validations by `check` alone, beside the gate's findings (T10.1-5); 14.23 reported by `inventory` and `rename`/`move` previews only — `check` reports the state as 14.10's unit form, and `build` and the refreshing reads never do (the rebuild replaces the record; the reads leave it unconsulted, T13.3-2); 14.14 delivered as an exit-2 error by every command that loads configuration — never `version` (T12.6-2); 14.13 and 14.22 reported by both `build` and `check` and by the gated reads (T13.3-3), yet accompanying no `occurrences`/`view`/`at` answer — each is the finding of no domain file, the journal and a write-path component never being domain files (11.2, T11.2-6); every other condition reported by both `build` and `check`, and as a domain file's finding accompanying the answers of each of `occurrences`/`view`/`at` whose domain can hold its staged file: all three for a spec-source staging, `occurrences` alone for a code-source one — the only staging for 14.7, 14.11, and 14.18, which locate in code sources alone — `view`'s and `at`'s domains holding spec sources only (11.2, 11.3–11.5, T11.2-5). * **T14-5 Grammar selection.** A file matched by a code group and named `.tsx`, containing TSX-only syntax (not parseable as plain TypeScript — T14-3's construct) inside a named unit that also holds a dependency marker and a `text(...)` call: `build` succeeds — `.tsx` parses as TSX (14.20) — and the marker's `references` edge and the call's `embeds` edge are recorded and attributed to that unit per 4.6. The negative direction, the same TSX-only construct in a `.ts` file failing 14.20, is T14-3's; a further arm stages it in a code-group file of another name (`.mts`), failing 14.20 identically — any name but `.tsx` selects plain TypeScript (14.20), discriminating against products keying specifically on `.ts`. * **T14-6 Stable codes.** For each of the 23 conditions, staged via its primary test's fixture and read from its stated reporter (T14-4): the finding carries the exact token 14 lists (`missing-id` … `unreadable-record`) as its `code` in the JSON report form, where 12.7 pins it — the value is the token string alone, the ordinal numeral no part of it — so a product omitting or misspelling a code fails even where exit class and located information are right. A plain usage error and a review-operation refusal carry no stable code — `code` `null` (14, T12.7-1/3). -* **T14-7 Refusal reasons.** Staged refusals asserting each stable code with its concerned file, range, or identity (14): `refused-invalid-id` (concerning the invalid identity; intrinsic form only — a structurally misplaced but intrinsically valid new ID reports `refused-structural-parent` alone, never both); `refused-identity-unchanged`, reported alone by an identity-unchanged rename — no collision reason beside it (6.4: the after-removal check collides with nothing); `refused-id-collision`, locating every colliding bearer; `refused-structural-parent` (the violated identity); `refused-cycle`, locating the would-be cycle's full path; `refused-destination-exists` (the occupied path; the section form's non-spec-source occupant included); `refused-missing-target-parent` (the target-parent identity); `refused-invalid-destination` (the destination path; the destination-side directory-component cases of 6.5 report this code, never 14.22 — a plain file staged as a destination directory component and, in T6.5-4's derived-path arm, as a directory component of the destination's `outDir` emit destination). `refused-unresolvable-reference` admits no fixture (T6.4-3/T6.5-6) and is asserted only as the always-passing side of successful operations. Every applicable reason reports together, one finding per reason: a section move staged to both collide (`<new-id>` present in the target file) and create a dependency cycle reports both findings, never only the first. The invalid-workspace refusal reports the workspace's numbered findings alone — on a workspace failing validation, a rename staged to also collide reports the validation findings only, exit 1, no refusal reason evaluated or reported beside them (6.4, 14). +* **T14-7 Refusal reasons.** Staged refusals asserting each stable code with its concerned file, range, or identity (14): `refused-invalid-id` (concerning the invalid identity; intrinsic form only — a structurally misplaced but intrinsically valid new ID reports `refused-structural-parent` alone, never both); `refused-identity-unchanged`, reported alone by an identity-unchanged rename — no collision reason beside it (6.4: the after-removal check collides with nothing); `refused-id-collision`, locating every colliding bearer; `refused-structural-parent` (the violated identity); `refused-cycle`, locating the would-be cycle's full path; `refused-destination-exists` (the occupied path; the section form's non-spec-source occupant included); `refused-missing-target-parent` (the target-parent identity); `refused-invalid-destination` (the destination path; the destination-side directory-component cases of 6.5 report this code, never 14.22 — a plain file staged as a destination directory component, in T6.5-4's derived-path arm as a directory component of the destination's `outDir` emit destination, and in T6.5-4's symbolic-link arms a link to a directory at a component of the destination path, of a created target file's path, and of the `outDir` emit destination — the link and its target byte-identical after each refusal). `refused-unresolvable-reference` admits no fixture (T6.4-3/T6.5-6) and is asserted only as the always-passing side of successful operations. Every applicable reason reports together, one finding per reason: a section move staged to both collide (`<new-id>` present in the target file) and create a dependency cycle reports both findings, never only the first. The invalid-workspace refusal reports the workspace's numbered findings alone — on a workspace failing validation, a rename staged to also collide reports the validation findings only, exit 1, no refusal reason evaluated or reported beside them (6.4, 14). * **T14-8 Location cardinality.** A condition several constructs jointly violate is one finding locating every participant, each in its containing file: a triple-duplicated ID → one condition-3 finding with three locations (one per bearer, no representative chosen); an import-binding collision → one condition-15 finding locating every colliding declaration; a cross-file dependency cycle → one condition-9 finding locating its full path, every participating reference spelling, and a spec import cycle every participating import declaration; a no-occurrence MDX embedding spelling → its condition-6 finding's range the full braced container — the span its occurrence would occupy (5.7), keeping T11.4-6's byte classification exact; a policy finding → `locations` `[]`, `path` `null`, its context identities alone (T12.7-1). Location order within a finding is file bytes, then start, then end (12.7). ## 15. Example @@ -563,9 +563,9 @@ Sections 1–13 exercise each numbered condition in its home context; this secti ## 16. Property-Based and Fuzz Tests -Property tests generate inputs from seeded, reproducible generators (H-10), assert spec-derived invariants, and shrink failures. Each property is also anchored by the deterministic fixtures of sections 1–15; properties exist to search the input space, not to replace them. +Property tests generate inputs from seeded, reproducible generators (H-10), assert spec-derived invariants, and shrink failures. Each property is also anchored by the deterministic fixtures of sections 1–15; properties exist to search the input space, not to replace them. Except where a property stages invalid or imperfect input by design — P-1's invalid draws, P-8, P-11 — generated workspaces are valid by construction and generated edits preserve validity: the generators follow the grammar of 2 (prose bytes that open no construct — no `<`, `{`, or import-like line — well-formed sections with structurally valid IDs, resolving imports and references), so every oracle here is evaluated over documents that build and a generator artifact never surfaces as a product failure. Validity being guaranteed by construction, a `build` failure on a draw is a product failure, reported with its seed (H-10), never a draw to skip: conditioning the properties on exit 0 instead would let a rejecting product pass them vacuously. -* **P-1 Segment/tag validity.** Generator over code points (weighted toward boundaries: whitespace/control classes of 1.4, U+00A0/U+0085/U+2028, `.`/`#`, forbidden names, and glob metacharacters of common dialects — `[` `]` `{` `}` `!` `+` `(` `)` — which are ordinary valid segment characters): the segment property judges the staged spelling's resulting split — a draw containing `.` can be spelled as no single segment (1.4: `.` is the ID separator) and stages as that many segments, its bearer nested beneath the ancestor chain the split's prefixes spell, so the structural rule holds whenever the segments are valid (a `.`-free draw stages as one top-level segment; structural-rule outcomes are T1.3-2..4's, never this oracle's) — asserting acceptance by `build` iff every resulting segment satisfies 1.4; likewise tags, with `.` allowed and whitespace never reaching tag validation — a generated value containing whitespace stages as multiple tags (2.6 splits on runs of whitespace), so the tag property asserts acceptance iff every resulting token satisfies 1.4 (zero tokens: accepted as an omitted prop, T2.6-2). +* **P-1 Segment/tag validity.** Generator over code points (weighted toward boundaries: whitespace/control classes of 1.4, U+00A0/U+0085/U+2028, `.`/`#`, forbidden names, and glob metacharacters of common dialects — `[` `]` `{` `}` `!` `+` `(` `)` — which are ordinary valid segment characters): the segment property judges the staged spelling's resulting split — a draw containing `.` can be spelled as no single segment (1.4: `.` is the ID separator) and stages as that many segments, its bearer nested beneath the ancestor chain the split's prefixes spell, so the structural rule holds whenever the segments are valid (a `.`-free draw stages as one top-level segment; structural-rule outcomes are T1.3-2..4's, never this oracle's) — asserting acceptance by `build` iff every resulting segment satisfies 1.4; likewise tags, with `.` allowed and whitespace never reaching tag validation — a generated value containing whitespace stages as multiple tags (2.6 splits on runs of whitespace), so the tag property asserts acceptance iff every resulting token satisfies 1.4 (zero tokens: accepted as an omitted prop, T2.6-2). Staging discipline (2.7: an `id` or `tags` value is a plain single- or double-quoted static string, and no escape or character-reference form is defined): each draw is spelled in the quote kind its content admits — double quotes for a draw containing `'`, single quotes for one containing `"`, either otherwise — and a draw containing both quote characters, admitting no static-string spelling, lies outside both properties' domains and is never staged: the oracle would predict acceptance for such a valid-but-unspellable draw while the staged file could only be unparseable or prop-invalid — a harness artifact of exactly the class H-11 forbids reporting as a product failure. That the product accepts both quote kinds alike is T2.7-3's deterministic question, not this generator's. * **P-2 Markdown compilation.** Random documents composed of prose blocks — fenced code blocks and inline code spans spelling tag-, import-, and expression-like bytes included (T3-1's grammar boundary: such bytes are content) — nested sections, imports, comments (single- and multi-line), and embeddings, over mixed line terminators, with content weighted toward the whitespace/non-whitespace boundary code points of 1.4 (U+00A0, U+0085, and U+2028 included): compiled output equals an independent oracle implementing the removal/replacement/line-drop rules of 3 (the oracle lives in the harness); compilation is deterministic; content bytes outside removed constructs are preserved. * **P-3 Text algebra.** For random documents: root subtree text equals compiled Markdown output; a node's subtree text equals its own-text runs interleaved with its children's subtree texts in document order (1.6); N children yield N+1 runs. * **P-4 Hash laws.** For random workspaces and random single edits: subtreeHash changed iff the 5.5 condition holds; metadataHash changed iff `d`/`coverage`/`tags` changed; ownHash insensitive to embedded-target edits; effectiveHash monotone over the dependency closure (any dependency-target effectiveHash change propagates); identical workspaces hash identically. From be6c7dadb906b5a9063b48ec2e19ed819c4d5cc5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 1 Sep 2026 22:32:55 +0000 Subject: [PATCH 176/226] =?UTF-8?q?sdg(phase-6):=20TEST-SPEC=20iteration?= =?UTF-8?q?=206=20=E2=80=94=20configuration=20member/shape=20arms;=20code-?= =?UTF-8?q?file=20fresh-identifier=20test;=20preview-side=20and=20form=20g?= =?UTF-8?q?aps=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: I1 (T7-3 value-shape arms; T7.4-1 edgeKinds member arms; T7.5-1 kinds member arms), I2 (new T6.5-9 fresh identifiers in code; T6.5-3 cross-reference), O1 (T4-4 named-binding arm roots SPEC type-only; mixed case recorded as unasserted), O2 (T5.5-2 wording), O3 (T12.7-3 --config nonexistent/malformed arms; T7-1), O4 (T12.7-2 clean {"findings": []} arm), O5 (T13.5-1 interpretive pin), O6 (T6.5-4 file-form directory occupant), O7 (T4.1-3 concrete observation), O8 (T6.5-8 MDX target arm — third conversion direction). Rejected: none. Placement: I1's group/list shape arms sit in T7-3 (the keys-and-values test) rather than T7-2; I2 is a sibling T6.5-9 rather than an arm of T6.5-8, keeping the byte-discipline test single-purpose. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index 515bee9..d19abcb 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -144,13 +144,13 @@ Consumer programs in this section are compiled and run under standard TypeScript * **T4-1 Header.** Each generated module begins with a header identifying it as generated by xspec from its source file (asserted as: header present, mentions xspec and the source path — wording free). * **T4-2 TS import rules.** In code-group files, each fails with 14.15: importing a `.xspec` specifier that designates no discovered spec source — one arm an existing file that is not a discovered spec source, one a nonexistent file; a bare and an absolute specifier ending `.xspec` (the specifier form rule of 2.1 applies in TS, 4); binding anything other than the (optionally aliased) default and `text` exports; a dynamic `import()` with a static `.xspec` specifier; an export declaration whose module specifier ends in `.xspec` — one arm each for `export * from`, `export * as NS from`, `export { … } from`, and a type-only form (`export type { … } from`), all 14.15 (4: no re-export carries a spec module past 4.5); an `import X = require("./NAME.xspec")` declaration; and, for each module-linking form 4 names — an import declaration, an export declaration, an `import X = require(…)`, and a static-specifier dynamic `import()` — a relative specifier designating a derived-file path other than through `.xspec` (`./NAME.xspec.ts` directly; a path under `.xspec/`; a configured Markdown emit destination — the last only while emission is enabled, 7.3). A dynamic `import()` with a non-static specifier is not analyzed: build succeeds, no edges recorded. Duplicate bindings (14.15 applies in either kind of file): an import binding an identifier already bound by another import in the same code-group file fails when either import is a spec module import — spec colliding with spec, and spec colliding with non-spec in both orders; two colliding non-spec imports are not an xspec finding (neither is a spec module import; the unused colliding bindings trigger no xspec error). * **T4-3 Aliased bindings.** `import SPEC, { text as t } from "./NAME.xspec"` works: `t(SPEC.a)` returns text and records the edge. -* **T4-4 Type-only imports.** A type-only spec module import — a `type` modifier on the declaration (`import type SPEC from "./NAME.xspec"`) and, separately, on a named binding (`import { type text as t } from "./NAME.xspec"`) — is valid (4). Bindings introduced type-only are type-level names: a marker-shaped expression statement `SPEC.a` and a call `t(SPEC.a)` rooted at them record no edge (`query edges` reports none from the file) and trigger no xspec finding — not 14.8 and not 14.18: such value-level uses fall under no condition (4.5, 14.18) — and `build`/`check` exit 0, the workspace staying valid; the consumer-side TypeScript error is outside xspec's validations (as in T6.4-5). Control: the same statements under ordinary bindings record their `references`/`embeds` edges. +* **T4-4 Type-only imports.** A type-only spec module import — a `type` modifier on the declaration (`import type SPEC from "./NAME.xspec"`) and, separately, on a named binding (`import { type text as t } from "./NAME.xspec"`) — is valid (4). Bindings introduced type-only are type-level names: a marker-shaped expression statement `SPEC.a` and a call `t(SPEC.a)` rooted at them — in the named-binding arm `SPEC` is itself bound type-only, by a second declaration `import type SPEC from "./NAME.xspec"` beside the `{ type text as t }` one (two declarations of one module binding distinct identifiers, valid under 4), so callee and argument are both rooted at type-only bindings — record no edge (`query edges` reports none from the file) and trigger no xspec finding — not 14.8 and not 14.18: such value-level uses fall under no condition (4.5, 14.18) — and `build`/`check` exit 0, the workspace staying valid; the consumer-side TypeScript error is outside xspec's validations (as in T6.4-5). Control: the same statements under ordinary bindings record their `references`/`embeds` edges. The mixed combination — an ordinary `SPEC` node as the argument of a type-only `t` — is staged in neither arm and asserted nowhere: 4.5 pins the outcome of chains rooted at type-only bindings and states none for an ordinary node passed to such a callee. ### 4.1 Node skeleton * **T4.1-1** The default export is the root; child sections appear as properties named by ID segment; a consumer chain to a leaf type-checks; a chain naming a missing requirement path is a TypeScript type error against the generated module. * **T4.1-2 Readonly.** Assigning to a node property is a type error. -* **T4.1-3 No text as values.** Every node reachable by child property access is an opaque token whose only supported operations are child property access and passing to `text()` (4.1): for each node in a fixture, the value obtained by the supported child-access operation is accepted by `text()`, and the supported operations behave as specified; no requirement text is observable through the module's values — a consumer that never imports `text` and performs only supported operations obtains no node's own or subtree text from the values reachable by those operations (asserted on those values; requirement text is obtainable at runtime only via the `text` export). +* **T4.1-3 No text as values.** Every node reachable by child property access is an opaque token whose only supported operations are child property access and passing to `text()` (4.1): for each node in a fixture, the value obtained by the supported child-access operation is accepted by `text()`, and the supported operations behave as specified; no requirement text is observable through the module's values — a consumer that never imports `text` and performs only supported operations obtains no node's own or subtree text from the values reachable by those operations. The observation is concrete, so the arm cannot pass as a tautology: over every runtime value reachable from the default export by child property access, the harness asserts the value is not a string and that a reflective deep walk of it — property keys and values, own and inherited, its string coercion, and its JSON serialization — encounters no string equal to or containing any node's own or subtree text (fixture texts distinctive: no text equals or contains an ID segment, so property names never match), requirement text being obtainable at runtime only via the `text` export (4.1, 4.3). ### 4.2 Documentation and navigation @@ -205,7 +205,7 @@ Consumer programs in this section are compiled and run under standard TypeScript ### 5.5 Hashes * **T5.5-1 Reporting and determinism.** `query node` reports all four hashes; rebuilding the identical workspace in a fresh directory yields identical hashes (H-6). -* **T5.5-2 ownHash.** Changes when: an own-text run is edited; a child is added; removed; two byte-identical children are reordered; an embedded reference is added; removed; retargeted; repositioned between runs. Unchanged when: a child's text is edited (only child's hashes change); an embedded target's text is edited — the fixture MUST include an own-line embedding whose target's edit toggles its subtree text between empty and non-empty, so the Markdown line-drop outcome flips (3) while the embedder's ownHash is byte-identical across the toggle: for own content the excised expression counts as remaining line content and the target's text is no part of it (1.6). Kind distinction (1.6/5.5: child and embedding references distinguished): across a baseline, a child construct is replaced at its exact position by a `text(...)` embedding of the same canonical identity with identical surrounding bytes — journaled-move the child section to another file, then embed the moved node (imported form) at its former position — and the parent's ownHash differs from baseline, the parent `changed`: the two own-content sequences are equal runs around one reference differing only in kind. Fixture geometry: the child construct and its replacement are in-line — within one line, flanked by content on that line (`foo <S id="c">…</S> baz` at baseline, `foo {text(X.c)} baz` after) — because on its own line a construct's straddling lines drop with their terminators (3) while an own-line `{text(...)}` keeps its line in own content (1.6): the runs would then differ too and the arm would pass vacuously; in-line, the runs are byte-identical and only the reference kind differs. +* **T5.5-2 ownHash.** Changes when: an own-text run is edited; a child is added; removed; two byte-identical children are reordered; an embedded reference is added; removed; retargeted; repositioned between runs. Unchanged when: a child's text is edited (the parent's ownHash alone stays — its subtreeHash and effectiveHash change, T5.5-3); an embedded target's text is edited — the fixture MUST include an own-line embedding whose target's edit toggles its subtree text between empty and non-empty, so the Markdown line-drop outcome flips (3) while the embedder's ownHash is byte-identical across the toggle: for own content the excised expression counts as remaining line content and the target's text is no part of it (1.6). Kind distinction (1.6/5.5: child and embedding references distinguished): across a baseline, a child construct is replaced at its exact position by a `text(...)` embedding of the same canonical identity with identical surrounding bytes — journaled-move the child section to another file, then embed the moved node (imported form) at its former position — and the parent's ownHash differs from baseline, the parent `changed`: the two own-content sequences are equal runs around one reference differing only in kind. Fixture geometry: the child construct and its replacement are in-line — within one line, flanked by content on that line (`foo <S id="c">…</S> baz` at baseline, `foo {text(X.c)} baz` after) — because on its own line a construct's straddling lines drop with their terminators (3) while an own-line `{text(...)}` keeps its line in own content (1.6): the runs would then differ too and the arm would pass vacuously; in-line, the runs are byte-identical and only the reference kind differs. * **T5.5-3 subtreeHash.** Changes exactly under the 5.5 conditions: any descendant added/removed/reordered or any in-subtree own-content change; unchanged for sibling-subtree edits and for embedded-target edits outside the subtree. * **T5.5-4 effectiveHash.** Changes when a dependency edge (either kind a requirement node can bear: `depends` or `embeds` — `references` edges originate only at code locations, 4.5) is added, removed, or retargeted anywhere in the subtree; when a dependency target's effectiveHash changes (transitively); and on retarget between two targets that have equal effectiveHash (byte-identical twin targets fixture). Unchanged when an unrelated node changes. Per-edge pairs (5.5: one pair enters per dependency edge, not per distinct target): a node bearing both `d={T}` and `{text(T)}` to one target contributes two identical pairs — removing the `d` reference alone changes effectiveHash while ownHash stays unchanged (the discriminating arm: a product deduplicating pairs per distinct target reports it unchanged), and removing the embedding alone changes it too. * **T5.5-5 metadataHash.** Changes iff `d` target set, `coverage`, or tags change; embedded `text(...)` references do not affect it; root nodes have a metadataHash (computed from empty inputs) reported by `query node`. Order-insensitivity: reordering the references within one multi-element `d` array and reordering a multi-tag `tags` list changes no hash — metadataHash and effectiveHash included (5.5: target sets enter sorted by canonical identity, tags sorted) — and yields no change categories against a baseline. @@ -267,14 +267,15 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T6.5-1 File form.** IDs unchanged; identities change file part only; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; journal appended; finishing regeneration as T6.4-7; the applied-mapping report as T6.4-1 (6.5: both forms report as rename does). Byte contract of the specifier rewrite: the importing `.ts` file (T6.2-2's fixture — a marker and a `text(...)` call through one `.xspec` import) and the moved file are each byte-identical to their pre-move bytes outside the specifier literals' characters — the ranges 6.6 classes `import-specifier-rewrite`, read by running the preview on a copy — and within each such range hold a specifier of 2.1's form designating the moved file's module, its relative spelling the product's (2.1 pins the form, not a canonical spelling; byte-determinism, T6.1-2/H-6, pins it product-to-itself), so a file-form move rewrites nothing beyond the literals. * **T6.5-2 Section form text edits.** Byte-exact fixtures: moved text spans opening tag's first character through closing tag's last; origin deletion drops lines left empty/whitespace-only (rule of 3); insertion immediately before the target parent's closing tag (or end of file for top-level `new-id`), followed by U+000A and preceded by one when not at line start; target file created when absent; no other byte changes beyond these edits, the identity and reference rewrites (their bytes: T6.5-7, T6.5-8), and the finishing regeneration (6.5). Self-closing arms (1.1, 6.5), byte-exact: moving a self-closing section moves exactly the self-closing tag's own characters; a self-closing target parent is first rewritten to paired form — its `/` and any whitespace immediately before or after the `/` deleted, the closing tag matching the opening tag's name appended immediately after the tag's terminating `>` — and the insertion rule then applies before that closing tag: a `<Spec id="p" />` parent becomes `<Spec id="p">` + U+000A + the moved text + U+000A + `</Spec>`. -* **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. -* **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link (target absent), one arm each (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose target path is occupied by anything other than a discovered spec source — neither an insertion target nor an absent path to create, refused `refused-destination-exists` (6.5, T14-7) — one arm each: a directory; a symbolic link resolving to a discovered spec source (the link-following discriminator: discovery never yields a symlink, 7 — a product resolving the target path through the filesystem finds a spec source there and inserts through the link); and an existing `.mdx` file outside every configured spec group (present, right extension, still no discovered spec source) — an arm asserting two findings, one per reason (14, T14-7): `refused-invalid-destination` (a path in no spec group) is applicable alongside `refused-destination-exists` in every staging of a plain-`.mdx` occupant, since an in-group, non-derived-excluded `.mdx` plain file is always discovered (7, 13.4) — the insertion target, no occupant refusal — and an unparseable discovered occupant is the invalid-workspace precondition's case; the directory and symlink arms, occupant-blocked at otherwise-valid in-group paths, stage singly; section form whose `<new-id>` is not in intrinsic ID form (the mirrored "new ID is valid" check, 6.5; 14: one or more segments joined by `.`, each satisfying 1.4) — a forbidden name (`then`), a whitespace-bearing segment, and the empty `<new-id>` (destination operand `b.mdx#`: one `#`, so the 12.0 split is well-formed and the id part has zero segments), one arm each, each refused `refused-invalid-id` — the empty-id arm discriminating a product that generalizes 11.3's `--to` spelling rule (where the same spelling exits 2 as malformed, T11.3-3) to move operands; section form whose `<new-id>` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; lacking `.mdx`; and the derived-path arm of `refused-invalid-destination` (6.5: a workspace-relative directory component of a derived path the destination would generate — 13.1, 13.2, 7.3 — occupied by a non-directory): with emission enabled under `markdown.outDir`, a file-form move whose destination `new/b.mdx` is otherwise valid, its own directory components unobstructed (`new/` absent — a nonexistent component is never a refusal cause, 13.4, T13.4-8), but whose emit destination `<outDir>/new/b.md` has its component `<outDir>/new` occupied by a plain file lying under no current source's write path (so the workspace passes `build`'s validations) — refused `refused-invalid-destination`, never 14.22 (14, T14-7), discriminating a product that vets only the destination path's own components; the module and companion paths share the destination's directory (13.1), so the `outDir` emit destination is the separable derived-path fixture. The symbolic-link arms of the same clause (6.5: a component occupied by a symbolic link, whatever it targets — discovery never traverses one, 7, and writes never traverse or replace one, 13.4): a file-form move to `specs/sub/b.mdx` and a section-form move creating the target file `specs/sub/new.mdx`, where `specs/sub` is a symbolic link to a real, empty directory — each form staged with the link targeting a directory inside the workspace and, separately, one outside the workspace root — are refused `refused-invalid-destination`, never 14.22 (14, T14-7), exit 1, nothing modified: the link itself and its target directory are byte-identical afterward, no file written through the link inside or outside the workspace; and a sibling of the derived-path arm stages `<outDir>/new` as such a link instead of a plain file, refused identically. These are the discriminating cases for a product vetting components through link-following `stat`, which sees a directory at the link, proceeds, and writes the moved file — and its regenerated derived files — through the link, possibly outside the workspace; T13.4-6's symlink arm covers only 14.22 on `build` writes, never this refusal. The link lies under no current source's write path and is never a source (7), so the workspace passes `build`'s validations. The 6.5 destination clauses "containing `#`" and "not valid UTF-8" admit no staging (dead letters, recorded here as T6.5-6 records its unstageable clauses): a destination path exists only as an operand spelling, and every spelling that would present either is a usage error before any refusal is evaluated — a non-UTF-8 argument value exits 2 (12.0, T12.0-5), so a valid operand never denotes a non-UTF-8 path; a `#`-containing file-form destination classifies as a `<file>#<id>` pair, a mixed-synopsis exit 2 (T6.5-5); and a `#` in the section form's target-file part makes a two-`#` operand, the malformed value T12.0-13 pins as exit 2 on `move`. Plus the valid-workspace precondition as T6.4-6. +* **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers (the code-file freshness observation: T6.5-9) and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. +* **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a directory, by a symbolic link, and by a broken symbolic link (target absent), one arm each, each refused `refused-destination-exists` (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the directory arm discriminates a product probing for a file alone; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose target path is occupied by anything other than a discovered spec source — neither an insertion target nor an absent path to create, refused `refused-destination-exists` (6.5, T14-7) — one arm each: a directory; a symbolic link resolving to a discovered spec source (the link-following discriminator: discovery never yields a symlink, 7 — a product resolving the target path through the filesystem finds a spec source there and inserts through the link); and an existing `.mdx` file outside every configured spec group (present, right extension, still no discovered spec source) — an arm asserting two findings, one per reason (14, T14-7): `refused-invalid-destination` (a path in no spec group) is applicable alongside `refused-destination-exists` in every staging of a plain-`.mdx` occupant, since an in-group, non-derived-excluded `.mdx` plain file is always discovered (7, 13.4) — the insertion target, no occupant refusal — and an unparseable discovered occupant is the invalid-workspace precondition's case; the directory and symlink arms, occupant-blocked at otherwise-valid in-group paths, stage singly; section form whose `<new-id>` is not in intrinsic ID form (the mirrored "new ID is valid" check, 6.5; 14: one or more segments joined by `.`, each satisfying 1.4) — a forbidden name (`then`), a whitespace-bearing segment, and the empty `<new-id>` (destination operand `b.mdx#`: one `#`, so the 12.0 split is well-formed and the id part has zero segments), one arm each, each refused `refused-invalid-id` — the empty-id arm discriminating a product that generalizes 11.3's `--to` spelling rule (where the same spelling exits 2 as malformed, T11.3-3) to move operands; section form whose `<new-id>` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; lacking `.mdx`; and the derived-path arm of `refused-invalid-destination` (6.5: a workspace-relative directory component of a derived path the destination would generate — 13.1, 13.2, 7.3 — occupied by a non-directory): with emission enabled under `markdown.outDir`, a file-form move whose destination `new/b.mdx` is otherwise valid, its own directory components unobstructed (`new/` absent — a nonexistent component is never a refusal cause, 13.4, T13.4-8), but whose emit destination `<outDir>/new/b.md` has its component `<outDir>/new` occupied by a plain file lying under no current source's write path (so the workspace passes `build`'s validations) — refused `refused-invalid-destination`, never 14.22 (14, T14-7), discriminating a product that vets only the destination path's own components; the module and companion paths share the destination's directory (13.1), so the `outDir` emit destination is the separable derived-path fixture. The symbolic-link arms of the same clause (6.5: a component occupied by a symbolic link, whatever it targets — discovery never traverses one, 7, and writes never traverse or replace one, 13.4): a file-form move to `specs/sub/b.mdx` and a section-form move creating the target file `specs/sub/new.mdx`, where `specs/sub` is a symbolic link to a real, empty directory — each form staged with the link targeting a directory inside the workspace and, separately, one outside the workspace root — are refused `refused-invalid-destination`, never 14.22 (14, T14-7), exit 1, nothing modified: the link itself and its target directory are byte-identical afterward, no file written through the link inside or outside the workspace; and a sibling of the derived-path arm stages `<outDir>/new` as such a link instead of a plain file, refused identically. These are the discriminating cases for a product vetting components through link-following `stat`, which sees a directory at the link, proceeds, and writes the moved file — and its regenerated derived files — through the link, possibly outside the workspace; T13.4-6's symlink arm covers only 14.22 on `build` writes, never this refusal. The link lies under no current source's write path and is never a source (7), so the workspace passes `build`'s validations. The 6.5 destination clauses "containing `#`" and "not valid UTF-8" admit no staging (dead letters, recorded here as T6.5-6 records its unstageable clauses): a destination path exists only as an operand spelling, and every spelling that would present either is a usage error before any refusal is evaluated — a non-UTF-8 argument value exits 2 (12.0, T12.0-5), so a valid operand never denotes a non-UTF-8 path; a `#`-containing file-form destination classifies as a `<file>#<id>` pair, a mixed-synopsis exit 2 (T6.5-5); and a `#` in the section form's target-file part makes a two-`#` operand, the malformed value T12.0-13 pins as exit 2 on `move`. Plus the valid-workspace precondition as T6.4-6. * **T6.5-5 Usage errors (exit 2).** Nonexistent origin file or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y`, `a.mdx#x b.mdx`, and `a.mdx b#c.mdx` — the last a `#`-containing file-form destination, classified as a pair — match neither form, exit 2, and an operand containing `#` is always a `<file>#<id>` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). A non-UTF-8 destination operand (raw bytes in the OS argument vector, Linux leg) is a usage-error argument value, exit 2 (12.0, T12.0-5). These exit-2 invocations are the stagings T6.5-4's dead-letter note sets aside. * **T6.5-6 Identity terms.** The new-identity checks read in identity terms (6.5): a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid; the exact self-move — `<target-file>#<new-id>` equal to `<file>#<id>` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `<new-id>` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `<new-id>` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. So is the mirrored "structural parent rules remain satisfied" check, in both forms: the file form changes no ID and no within-file nesting; in the section form the target parent is located as the target file's section bearing `<new-id>` minus its final segment — absent, or lying within the moved subtree, it is `refused-missing-target-parent` (T6.5-4) — prefix replacement preserves the subtree's relative nesting, a single-segment `<new-id>` inserts at top level (exactly one segment, 1.3), and the origin's removal disturbs no remaining ID; on a workspace passing the valid-workspace precondition, 1.3 therefore holds by construction after every non-refused move, the check is exercised as the always-passing side of every successful move (T6.5-1/2/3), and `refused-structural-parent` is staged through rename alone (T6.4-3, T14-7). * **T6.5-7 Operation-side rewrite bytes.** The real move's import-edit extents and reference-conversion spellings, byte-asserted against independently composed expected files — staged so no import is added, the one rewrite direction free of implementation latitude (6.5: identifier choice and insertion offset attach to added imports alone; the addition side's line discipline: T6.5-8). Fixture: the origin file imports the target file under two bindings (valid, 2.1), one declaration alone on its line, the other following a retained, still-referenced third-module import on a shared line; every reference through the two bindings — a `d` chain and a `text(...)` embedding — lies inside the moved subtree, which also holds a single-quoted local string reference to a moved descendant; no reference to a moved node lies outside the subtree. After the section move into the target file: both target-file imports are left unreferenced and removed with 6.5's exact extent — the own-line declaration's line dropped with its terminator (3), the shared-line declaration's own characters alone deleted, the retained import kept byte-for-byte on its kept line; the moved references convert to local form in 6.4's pinned spelling for converted references, double-quoted string literals; the local reference is re-identified by prefix replacement with its single-quote spelling preserved (6.4: minimal in-place edits); and the rewritten origin and target files are each asserted byte-equal to expected bytes composed from the rules of 6.5 and 3. This is the operation-side assertion T6.5-2 sets aside (its no-other-byte-changes check excludes the identity and reference rewrites) and T6.6-4 makes only of the preview's report — failing a product that emits a spec-perfect preview while the real edit leaves an emptied import line behind, normalizes whitespace around a removed declaration, or spells a converted reference single-quoted. The code-source counterpart, fully composed (no import added, so no latitude): a `.ts` file importing the origin module, the target module, and a retained third module — the third module's declaration and the origin's sharing one line in a second variant, the origin's following it — whose only references through the origin binding are markers on nodes of the moved subtree, beside a marker through the target binding and one through the third; after the section move, the origin-module import — its binding left without references — is removed with 6.5's exact extent (the own-line declaration's line dropped with its terminator; the shared-line declaration's own characters alone deleted, the retained declaration kept byte-for-byte on its kept line), the moved markers are rewritten through the existing target binding (6.5: an import is added only where the file lacks the binding), and the file is asserted byte-equal to expected bytes composed from the rules of 6.4/6.5 and 3 — the import-removal rule binds code sources as it binds MDX, and a product that reprints a code file on removal fails here while passing every resolution-only assertion (T6.5-1, T6.2-2). The addition side in code is T6.5-8's TS arm. -* **T6.5-8 Added-import insertion discipline.** The addition-side byte contract of 6.5 — an added import is inserted as a line of its own, the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — asserted with the identifier choice and insertion offset left free (6.5's latitude), shape-aware and value-blind (the ethos T6.5-7 applies to removals). Two section-move arms, each staged so the receiving file's expected post-move bytes are composable from the rules of 6.4/6.5 and 3 up to exactly two unknowns, the fresh identifier and the insertion offset: a TS arm, the grammar-freest case — a code file imports the origin module and references one moved and one unmoved node, so the rewrite needs a target-module binding the file lacks while the origin import keeps its remaining reference and stays — and an MDX arm — the origin file, already holding a retained third-module import (grammar-permitted offsets exist, and freshness is live against its binding), keeps a local string reference to a moved descendant, whose conversion to imported form (6.4's pinned spellings) makes the origin file itself gain the target module's import. In each arm the harness isolates the single added byte run by diff against the composed bytes and asserts it is exactly the declaration followed by U+000A at an insertion offset lying at the start of a line, and exactly U+000A, then the declaration, then U+000A at one that does not — the declaration one import of the target module's specifier binding one fresh identifier (value unpinned) that the rewritten references use, no other byte inserted. This fails a product that joins the added declaration to a neighbor with `;` — still parsing, resolving, and byte-deterministic in either grammar, the shared line still dropping whole from Markdown output in the MDX arm (3), hence passing every other test — omits the preceding terminator at a mid-line offset, adds a spurious blank line, or writes any terminator but U+000A, wherever it chose to insert: a line-start offset is always grammar-available, so the mid-line branch is reachable by implementation latitude alone, and the discipline is asserted over whatever offset the product makes observable (as T6.6-4 asserts its ordering tie-break). +* **T6.5-8 Added-import insertion discipline.** The addition-side byte contract of 6.5 — an added import is inserted as a line of its own, the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — asserted with the identifier choice and insertion offset left free (6.5's latitude), shape-aware and value-blind (the ethos T6.5-7 applies to removals). Three section-move arms, each staged so the receiving file's expected post-move bytes are composable from the rules of 6.4/6.5 and 3 up to exactly two unknowns, the fresh identifier and the insertion offset: a TS arm, the grammar-freest case — a code file imports the origin module and references one moved and one unmoved node, so the rewrite needs a target-module binding the file lacks while the origin import keeps its remaining reference and stays — an MDX origin arm — the origin file, already holding a retained third-module import (grammar-permitted offsets exist, and freshness is live against its binding), keeps a local string reference to a moved descendant, whose conversion to imported form (6.4's pinned spellings) makes the origin file itself gain the target module's import — and an MDX target arm, the third conversion direction, which T6.5-7 (imported→local) and the origin arm (origin-side local→imported) leave byte-unasserted where the moved text lands: the moved subtree holds a local string reference (a `d` entry) to an origin node outside the subtree, one of whose ID segments is not identifier-valid, and the target file — an existing discovered source lacking the origin module's import — gains that import, the reference converting to imported form through the fresh binding in 6.4's pinned spellings (dot access, double-quoted computed access for the non-identifier segment), the moved text otherwise byte-identical and the origin file losing the section and gaining no import; so the addition-side line discipline is asserted in the receiving target file, where T6.6-4(b) asserts only the preview's offset. In each arm the harness isolates the single added byte run by diff against the composed bytes and asserts it is exactly the declaration followed by U+000A at an insertion offset lying at the start of a line, and exactly U+000A, then the declaration, then U+000A at one that does not — the declaration one import of the needed module's specifier — the target module's, or in the MDX target arm the origin module's — binding one fresh identifier (value unpinned) that the rewritten references use, no other byte inserted. This fails a product that joins the added declaration to a neighbor with `;` — still parsing, resolving, and byte-deterministic in either grammar, the shared line still dropping whole from Markdown output in the MDX arms (3), hence passing every other test — omits the preceding terminator at a mid-line offset, adds a spurious blank line, or writes any terminator but U+000A, wherever it chose to insert: a line-start offset is always grammar-available, so the mid-line branch is reachable by implementation latitude alone, and the discipline is asserted over whatever offset the product makes observable (as T6.6-4 asserts its ordering tie-break). +* **T6.5-9 Fresh identifiers in code.** The freshness clause of 6.5 — an added import binds fresh identifiers colliding with no binding already in the file (2.1, 4) — has, in a code file, no xspec-side observation: "any binding already in the file" spans local declarations as well as imports, and an added import colliding with a local declaration is a TypeScript error in the consumer, outside xspec's validations (4.5) — no `build`/`check` finding (condition 15 covers import-versus-import collisions alone, which is why T6.5-3's post-move `check` suffices in MDX, where every binding is an import), no staleness — while 4.5's scope-aware rooting may then drop the rewritten markers' edges silently. A product checking freshness against import bindings alone therefore passes T6.5-8's TS arm, whose receiving file leaves every plausible identifier free. This test stages the collision: T6.5-8's TS arm re-staged with a receiving code file that additionally declares, at module scope, bindings pre-empting the identifiers a product would plausibly derive — a local `const`, a `function`, a `class`, and a `type` alias, and a non-spec import binding — spelled from the target file's basename (as written, lower- and upper-cased, `Spec`- and `SPEC`-suffixed) and from the origin binding's name with a digit and with an underscore appended, the file compiling clean before the move under standard tooling. After the section move, asserted through the standard-tooling channel of H-2: the rewritten file compiles with no diagnostics — its only possible new diagnostic being the import-conflicts-with-local-declaration error a collision produces — so the added declaration's identifier equals none of the pre-empted names; `query edges` reports the moved markers' `references` edges from the file to the moved nodes' new identities and the unmoved marker's edge through the retained origin binding; and `check` is clean (T6.5-3). The pre-empted set is a lure, not a bound: the identifier stays the product's choice (6.5's latitude), and compile-cleanliness is the assertion whatever the choice. ### 6.6 Previews @@ -292,18 +293,18 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ## 7. Project Configuration -* **T7-1 Location.** Configuration found by upward search from a nested working directory; `--config <path>` (resolved against the working directory, 12.0) overrides the search; no configuration reachable → configuration error (14.14, exit 2). +* **T7-1 Location.** Configuration found by upward search from a nested working directory; `--config <path>` (resolved against the working directory, 12.0) overrides the search; no configuration reachable → configuration error (14.14, exit 2) — by a failed upward search, and by `--config` naming a nonexistent file (missing configuration, 14.14; concerned paths per T12.7-3), the latter never a plain usage error. * **T7-2 Declarative form.** Each fails with 14.14 (exit 2): a configuration file that is not well-formed TypeScript (a syntax error); missing `defineConfig` import; import from a specifier other than `"xspec"`; extra statements; a non-literal argument (spread, computed key, template literal, identifier reference, function call, number where boolean expected); a default export that is not one call to the (optionally aliased) binding. An aliased `defineConfig` import is valid. String-literal keys are part of the accepted form (7): a configuration declaring a spec group and a code group under string-literal keys whose names are not TypeScript identifiers (`"my-group"`, `"test-code"`) loads without error, both groups discover their globs' files, and the names resolve wherever group names are referenced — a coverage profile with `target: "my-group"`, `boundary: "test-code"` reports its coverage (8), and a policy rule's selector `{ group: "my-group" }` matches the group's nodes (7.5) — discriminating a product that accepts identifier keys alone, which refuses a valid configuration no other spelling can declare (a non-identifier group name has only the string-literal form). -* **T7-3 Keys.** `specs` missing → 14.14. Omitted optional keys, each with its stated observation: `code` — no code groups: a marker-bearing `.ts` file is undiscovered, the unfiltered `query edges` list carries no edge from it, and naming it in `--from` is unknown (exit 2, 11); `markdown` — no emission: no `.md` is written for any source (T3-6); `coverage` — no profiles: `xspec coverage` reports zero profiles and exits 0; `policy` — no rules: `check` on a workspace whose edges would violate T7.5-2's rule, with the rule omitted, reports no policy findings and exits 0. Empty lists (7): `coverage: []` and `policy: []` are valid and equivalent to omitting the key — zero profiles reported, no policy findings. Unknown keys at top level, in `markdown`, in a profile, in a rule, and in a selector each → 14.14. +* **T7-3 Keys.** `specs` missing → 14.14. Omitted optional keys, each with its stated observation: `code` — no code groups: a marker-bearing `.ts` file is undiscovered, the unfiltered `query edges` list carries no edge from it, and naming it in `--from` is unknown (exit 2, 11); `markdown` — no emission: no `.md` is written for any source (T3-6); `coverage` — no profiles: `xspec coverage` reports zero profiles and exits 0; `policy` — no rules: `check` on a workspace whose edges would violate T7.5-2's rule, with the rule omitted, reports no policy findings and exits 0. Empty lists (7): `coverage: []` and `policy: []` are valid and equivalent to omitting the key — zero profiles reported, no policy findings. Unknown keys at top level, in `markdown`, in a profile, in a rule, and in a selector each → 14.14. Value shapes (7, 7.1, 7.2; 14.14: a configuration that does not conform, an otherwise invalid group shape) — each → 14.14, one arm each: a spec group and a code group whose value is a single string rather than a list (`product: "specs/**/*.mdx"`); a glob list holding a non-string element (`[true]` — a literal 7 admits in form, never as a glob); `coverage` and `policy` given as an object rather than a list (`coverage: {}`); and `specs` and `code` given as a list rather than a map of groups — discriminating a product that reads the declarative form loosely, accepting whatever its own loader tolerates. * **T7-4 Globs.** Semantics fixtures: `*` any possibly empty run of bytes within one path segment; `?` exactly one byte within a segment; `**` whole segments including none; case-sensitive matching — including a single-casing probe stageable on any filesystem: a group whose only pattern is `SPECS/*.mdx` over a workspace directory `specs/` holding `A.mdx` discovers zero sources (rerun on the Windows leg, E-6, where a product matching globs through case-insensitive filesystem lookups wrongly discovers the file); byte semantics (7: paths match as their UTF-8 bytes), on the Linux leg — a file whose name contains a two-byte code point (`é.mdx`): `?.mdx` does not match it while `??.mdx` and `*.mdx` do, discriminating bytes from characters; a dotfile matched only by a pattern segment written with a leading `.` — wildcards never match dot-segments: `a/**/b.mdx` does not match `a/.h/b.mdx`, `*` does not match `.hidden`, `?x` does not match `.x`; a pattern resolving outside the workspace root → 14.14. All paths resolve relative to the configuration file's directory. Literal metacharacters (7: globs support exactly `*`, `?`, `**` — every other character is a literal): a pattern segment containing `[1]`, `{a,c}`, `!`, or `+(x)` matches exactly the file name containing those characters and never what a character-class, brace-expansion, negation, or extglob dialect would match — `a[1].mdx` matches `a[1].mdx` and not `a1.mdx`; `b{a,c}.mdx` matches `b{a,c}.mdx` and not `ba.mdx` or `bc.mdx`. * **T7-5 Symbolic links.** A symlinked file matched by a glob is not discovered; a symlinked directory is not traversed (contents undiscovered); broken links ignored; a symlink cycle does not hang discovery; workspace-external content behind a link never enters the discovered set. * **T7-6 Discovery boundaries.** Derived files are never discovered as sources even when globs match them (`.xspec.` names, `.xspec/` paths, Markdown emit destinations while emission is enabled, 13.4); an import never adds an unmatched file to the workspace (2.1: the target must already be discovered, else 14.15); a group with no matches and an empty `specs`/`code` map are valid with zero sources. * **T7.1-1 Spec groups.** A file in two spec groups is valid (and coverage/policy see it in both); a spec-group match without `.mdx` → 14.19. * **T7.2-1 Code groups.** Code groups act as coverage boundaries and the impacted-code population (asserted in 8/9); a file matched by both a spec and a code group → 14.14. * **T7.3-1 Markdown config.** `markdown` absent → no emission; `emit: false` → none; `emit: true` → emission next to each source; `markdown` present without `emit` → 14.14 (7.3: `emit` is required when `markdown` is present); `outDir` redirects preserving workspace-relative paths; `outDir` resolving outside the root → 14.14; emit-destination classification follows `emit` (with emission off, a path that would be a destination can be a discovered source; with it on, it cannot — 7.3/13.4, asserted via discovery and via the import rule of T4-2); classification is by configuration alone, "whether or not emission has yet run" (7.3): in a workspace where no emission has ever run — emission enabled, source `specs/A.mdx`, a user-authored file at its destination `specs/A.md`, a spec-group glob matching both — a read command (`ids`, representative; its 13.3 refresh never emits) succeeds treating the destination as no source: the `.md` file is not discovered (no 14.19 from the non-`.mdx` match) and its bytes are untouched, discriminating a product that classifies destinations by existing emitted output rather than by configuration. -* **T7.4-1 Profile validation.** Two profiles sharing a `name` → 14.14 (7.4: unique profile name); a profile lacking any one of the required `name`, `target`, `boundary`, or `mode` fields (one fixture per field) → 14.14; `targets` other than `"leaves"`/`"all"`, `mode` other than `"direct"`/`"transitive"`, `boundaryKind` other than `"spec"`/`"code"` → each 14.14; unknown `target` group name → 14.14; `target` naming an existing code-only group → 14.14 (7.4: `target` must be a configured spec group's name — the wrong-kind reference of 14.14, discriminating against validation over all group names); unknown `boundary` group name → 14.14; `boundaryKind: "spec"` naming a code-only group and `boundaryKind: "code"` naming a spec-only group (the referenced name is not of the required kind, either direction) → each 14.14; empty `targetTags` → 14.14; empty `edgeKinds` → 14.14; `boundaryKind` required when the boundary name is both a spec and a code group (absent → 14.14) and inferred when unambiguous; unknown profile name at `coverage <name>` → usage error (12.0). +* **T7.4-1 Profile validation.** Two profiles sharing a `name` → 14.14 (7.4: unique profile name); a profile lacking any one of the required `name`, `target`, `boundary`, or `mode` fields (one fixture per field) → 14.14; `targets` other than `"leaves"`/`"all"`, `mode` other than `"direct"`/`"transitive"`, `boundaryKind` other than `"spec"`/`"code"` → each 14.14; unknown `target` group name → 14.14; `target` naming an existing code-only group → 14.14 (7.4: `target` must be a configured spec group's name — the wrong-kind reference of 14.14, discriminating against validation over all group names); unknown `boundary` group name → 14.14; `boundaryKind: "spec"` naming a code-only group and `boundaryKind: "code"` naming a spec-only group (the referenced name is not of the required kind, either direction) → each 14.14; empty `targetTags` → 14.14; empty `edgeKinds` → 14.14; `edgeKinds` not a subset of `["depends", "embeds", "references"]` (7.4; an otherwise invalid profile shape, 14.14) — holding the non-dependency kind `"contains"`, an unknown token (`"depend"`), and a non-string element (`true`), one arm each → 14.14, discriminating a product that accepts and ignores a stray member or lets `contains` grant coverage (8); a non-string `targetTags` element (`[true]`) → 14.14 likewise; `boundaryKind` required when the boundary name is both a spec and a code group (absent → 14.14) and inferred when unambiguous; unknown profile name at `coverage <name>` → usage error (12.0). * **T7.4-2 Profile semantics.** `targets` defaults to `"leaves"`; `"all"` includes internal nodes; `targetTags` restricts to nodes carrying at least one listed tag; `edgeKinds` defaults to all three and restricts paths when given (each via coverage runs, 8). -* **T7.5-1 Rule validation.** Two rules sharing a `name` → 14.14 (7.5: unique rule name); a rule lacking any one of the required `name`, `type`, `from`, or `to` fields (one fixture per field) → 14.14; `type` other than `"forbidden"`/`"allowedOnly"` → 14.14; empty `kinds` → 14.14; empty selector `tags` → 14.14; a selector with zero or two of `group`/`files`/`tags` → 14.14; an unknown group name in a selector → 14.14; a selector `kind` mismatching its group's kind (`kind: "spec"` naming a code-only group; `kind: "code"` naming a spec-only group) → each 14.14; ambiguous group name without `kind` → 14.14; a capture wildcard appearing more than once in `from` → 14.14 (a capture violation); `to` referencing a capture absent from `from` → 14.14. +* **T7.5-1 Rule validation.** Two rules sharing a `name` → 14.14 (7.5: unique rule name); a rule lacking any one of the required `name`, `type`, `from`, or `to` fields (one fixture per field) → 14.14; `type` other than `"forbidden"`/`"allowedOnly"` → 14.14; empty `kinds` → 14.14; `kinds` not a subset of the dependency edge kinds (7.5; an otherwise invalid rule shape, 14.14) — holding `"contains"`, an unknown token (`"depend"`), and a non-string element (`true`), one arm each → 14.14; empty selector `tags` → 14.14; a non-string selector `tags` element (`[true]`) → 14.14; a selector with zero or two of `group`/`files`/`tags` → 14.14; an unknown group name in a selector → 14.14; a selector `kind` mismatching its group's kind (`kind: "spec"` naming a code-only group; `kind: "code"` naming a spec-only group) → each 14.14; ambiguous group name without `kind` → 14.14; a capture wildcard appearing more than once in `from` → 14.14 (a capture violation); `to` referencing a capture absent from `from` → 14.14. * **T7.5-2 forbidden.** An edge whose source matches `from` and target matches `to` is a finding of `check` (rule name + offending edge, exit 1); non-matching edges are not; `kinds` restricts which edges are evaluated. * **T7.5-3 allowedOnly.** Every edge from a `from`-matching source must have a `to`-matching target; each violating edge is a separate finding. * **T7.5-4 Selectors.** `group` (with `kind` where needed) matches nodes of spec groups and code locations of code groups; `files` matches by glob; `tags` matches nodes carrying at least one listed tag. @@ -500,8 +501,8 @@ Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are Assertions here — and wherever these forms appear across the suite — are form-exact (H-3): member names, `null`-vs-omission, `[]`-vs-`null`, and orderings are asserted literally, never adapted. * **T12.7-1 Value forms.** A source range is `{"start", "end"}`, non-negative integers, wherever any JSON output carries one (12.7's value forms bind every JSON output, H-3): asserted literally on the pinned document forms and, through the H-3 decode, on each unpinned surface that carries ranges — a `query node` range and a `query nodes`/`subtree`/`ancestors` row's (11.1), `show --json`'s (12.4), and a review payload's (10.7): a present scope node's and a present `code-impact` location's, every range the payload carries in this one form. Paths: valid-UTF-8 paths are plain strings; a non-UTF-8 path (Linux leg) is `{"bytes": "…"}` — its exact bytes as lowercase hexadecimal, two digits per byte — asserted at each output the 12.0 rule names: an inventory source path, an occurrence's referencing file, a view's file and an import's resolved target, and a finding's location file and concerned path; a valid-UTF-8 path never takes the byte form. Unavailability is exactly `{"unavailable": true}`, and no object of any other form carries a member named `unavailable` (a structural walk over every JSON document the suite captures — the unpinned-shape surfaces of H-3 included, the exclusivity being universal like the value forms; S-5 guards the walk). A finding is `{"code", "message", "locations", "path", "identities"}`: `code` the stable token string or `null` where 14 assigns none (a review-refusal finding); `locations` one `{"file", "range"}` per offending construct, ordered by file bytes, then start, then end, `[]` for unlocated conditions; `path` `null` for located conditions, the concerned path otherwise; `identities` contractual where 14 states them — a policy finding carries the rule name, source identity, kind token, and target identity in that order with `locations` `[]` and `path` `null` (14.12), a cross-module call names the foreign module (14.11), a refusal reason its concerned identity (T14-7). -* **T12.7-2 Findings arrays and document forms.** A workspace staging several conditions, and a multi-reason refusal (T14-7): every findings array is ordered by code — numbered conditions in numeric order, then refusal reasons in 14's listed order, then code-less findings — then by locations element-wise (a proper prefix sorting first), then by concerned path (`null` first; byte-form and plain paths in one byte order), then by identities, then by message; identically-staged duplicate findings collapse to one. Document forms: `build`/`check`/gated-read/refused-operation reports are `{"findings": […]}`; a refused preview keeps `{"findings", "mapping", "files", "delta"}` with the three `null` (T6.6-3); `occurrences` is `{"findings", "occurrences"}`; `view` `{"findings", "views"}`, each node `{"identity", "range", "opening", "closing", "attributes", "tags", "coverage", "children"}` plus `ownText`/`subtreeText` exactly when `--text` is given (the stated conditional presence — absent without the flag), `attributes` entries `{"name", "range", "text"}`, imports `{"range", "name", "target"}`; `at` `{"findings", "resolution"}`, `resolution` `{"section", "occurrence"}` with `occurrence` `null` when the offset lies in none; `inventory` and previews per T11.6-* and T6.6-4/5; `version` `{"product", "interface"}`. Member presence: `null` is never omission (a refused preview still carries all four members; an unset `outDir` is `null`); empty lists are `[]`, never `null` (a finding-free `findings`, a root's `attributes`, an empty delta direction); stated `null`s vs structural absence per surface (a root's `tags`/`coverage`, T11.4-3; an absent `targetTags`, T11.6-2). -* **T12.7-3 Error document.** Exit-2 invocations with JSON in effect emit `{"error": …}` holding one finding form as the entire stdout: a configuration error → stable code `configuration-error` and concerned path in the anchoring form (the configuration file; for a failed upward search with no `--config`, `.`); a plain usage error → `code` and `path` `null`; one finding however many defects — a configuration file with several distinct defects yields a single condition-14 finding. JSON is in effect for a JSON-only surface without `--json` (`inventory` with an unknown flag) and whenever `--json` appears among the arguments, the arguments themselves erroneous included (an unknown command beside `--json`) — each the error document on stdout, diagnostics on stderr (T12.0-2). +* **T12.7-2 Findings arrays and document forms.** A workspace staging several conditions, and a multi-reason refusal (T14-7): every findings array is ordered by code — numbered conditions in numeric order, then refusal reasons in 14's listed order, then code-less findings — then by locations element-wise (a proper prefix sorting first), then by concerned path (`null` first; byte-form and plain paths in one byte order), then by identities, then by message; identically-staged duplicate findings collapse to one. Document forms: `build`/`check`/gated-read/refused-operation reports are `{"findings": […]}` — and on a clean, freshly built workspace a successful `build --json` and `check --json` each emit exactly `{"findings": []}` as the entire stdout (12.7: the findings-only form with the empty array, its one member and nothing beside it — the pin exercised on the report form itself, not only on the JSON-only surfaces; T12.1-1, T12.2-1); a refused preview keeps `{"findings", "mapping", "files", "delta"}` with the three `null` (T6.6-3); `occurrences` is `{"findings", "occurrences"}`; `view` `{"findings", "views"}`, each node `{"identity", "range", "opening", "closing", "attributes", "tags", "coverage", "children"}` plus `ownText`/`subtreeText` exactly when `--text` is given (the stated conditional presence — absent without the flag), `attributes` entries `{"name", "range", "text"}`, imports `{"range", "name", "target"}`; `at` `{"findings", "resolution"}`, `resolution` `{"section", "occurrence"}` with `occurrence` `null` when the offset lies in none; `inventory` and previews per T11.6-* and T6.6-4/5; `version` `{"product", "interface"}`. Member presence: `null` is never omission (a refused preview still carries all four members; an unset `outDir` is `null`); empty lists are `[]`, never `null` (a finding-free `findings`, a root's `attributes`, an empty delta direction); stated `null`s vs structural absence per surface (a root's `tags`/`coverage`, T11.4-3; an absent `targetTags`, T11.6-2). +* **T12.7-3 Error document.** Exit-2 invocations with JSON in effect emit `{"error": …}` holding one finding form as the entire stdout: a configuration error → stable code `configuration-error` and concerned path in the anchoring form: the configuration file the upward search found; `.` for a failed upward search with no `--config`; and, `--config <path>` given — naming a nonexistent file, and, separately, a malformed one (14: where a configuration file is concerned, the path `--config` names is the concerned path, a file existing there or not; missing configuration is 14.14, never a plain usage error) — that path in 11.6's canonical anchoring spelling, staged from a sibling directory as `--config ../cfg/xspec.config.ts` so the reported path is `../cfg/xspec.config.ts` (T11.6-1's form), never `.`; a plain usage error → `code` and `path` `null`; one finding however many defects — a configuration file with several distinct defects yields a single condition-14 finding. JSON is in effect for a JSON-only surface without `--json` (`inventory` with an unknown flag) and whenever `--json` appears among the arguments, the arguments themselves erroneous included (an unknown command beside `--json`) — each the error document on stdout, diagnostics on stderr (T12.0-2). ## 13. Workspace Files @@ -536,7 +537,7 @@ Assertions here — and wherever these forms appear across the suite — are for All mutual-exclusion tests use the `--test-hold <path>` seam for determinism. -* **T13.5-1 Hold seam basics.** A mutating command (`rename`, `move`, `review create/resolve/split`) with `--test-hold`: creates an empty file at the path after acquiring exclusivity and before modifying anything (workspace byte-identical while held), proceeds only once the file is deleted, then completes normally. Seam neutrality (13.5: the seam changes no other behavior): the final workspace state of a held-then-released run — sources, journal, sessions, derived files, and graph data — is byte-identical to the same operation run without `--test-hold` on an identical twin workspace (the hold path outside the workspace; a product-to-itself comparison under H-4, well-defined across directories per H-6). If anything exists at the hold path — a file, directory, or symbolic link — the command fails exit 2 without modifying anything. A non-mutating command (`build` and `query` as representatives) given `--test-hold` fails exit 2 as an unknown flag: 13.5 grants the seam to mutating commands alone, and unknown flags are usage errors (12.0). +* **T13.5-1 Hold seam basics.** A mutating command (`rename`, `move`, `review create/resolve/split`) with `--test-hold`: creates an empty file at the path after acquiring exclusivity and before modifying anything (workspace byte-identical while held), proceeds only once the file is deleted, then completes normally. Seam neutrality (13.5: the seam changes no other behavior): the final workspace state of a held-then-released run — sources, journal, sessions, derived files, and graph data — is byte-identical to the same operation run without `--test-hold` on an identical twin workspace (the hold path outside the workspace; a product-to-itself comparison under H-4, well-defined across directories per H-6). If anything exists at the hold path — a file, directory, or symbolic link — the command fails exit 2 without modifying anything. A non-mutating command (`build` and `query` as representatives) given `--test-hold` fails exit 2 as an unknown flag — an interpretive pin, recorded as such (as T10.1-1 and T13.4-1 record theirs): 13.5 grants the seam to mutating commands alone, so on every other command the flag is one the command does not accept — an unknown flag, a usage error (12.0); 12.0's listing of `--test-hold` beside `--config` fixes only how its value resolves, not which commands take it, and 6.6 states the `--preview` case outright only because `rename` and `move` do accept the flag. SPEC.md states no acceptance of the flag by a non-mutating command, so a product accepting and ignoring it fails this arm by intent. * **T13.5-2 Mutual exclusion.** While command 1 is held, each other mutating command fails promptly with exit 2 and modifies nothing (journal, sessions, sources byte-identical); after command 1 completes, the second command succeeds. * **T13.5-3 Exclusivity ends with the process.** Kill a held mutating command; a subsequent mutating command succeeds (a terminated holder never blocks). * **T13.5-4 Readers during mutation.** While a mutating command is held, read commands still run and observe the prior state; non-mutating commands run concurrently with each other (parallel `build`/`query` storm on one workspace terminates, and any derived-file inconsistency is resolved by one final `build` — byte-equal to a clean build). From 2bc11fa1ef30f3aa41f5a1b89528b28b0e1aae34 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 1 Sep 2026 23:27:33 +0000 Subject: [PATCH 177/226] =?UTF-8?q?sdg(phase-6):=20TEST-SPEC=20iteration?= =?UTF-8?q?=207=20=E2=80=94=20third-module=20bindings=20in=20moved=20text;?= =?UTF-8?q?=20id-attribute=20quote=20contract;=20callee-side=20shadowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: I1 (new T6.5-10 — value-blind arm (a): the added import of X's module in the target and the origin's exact removal; byte-composable arm (b): moved references re-rooted to the target's existing binding, no import added; T6.5-3 cross-reference), I2 (T6.4-2 whole-file contract now spells single-quoted `id` attributes whose quotes the expected bytes keep; T6.5-7's re-identified descendant likewise), I3 (T4.5-4 callee-side arm: a local `function text` shadowing the import makes `text(SPEC.a)` condition 18 — no edge, no occurrence, the unused import valid), O1 (H-3: session staging shape-aware through the adapter, graph-data staging shape-blind), O2 (T1.5-3 reworded — rename takes `<file> <old-id>`; bare paths in move/rename operands name files), O3 (T11.6-2: rule `kinds` default and inferred selector `kind` explicit), O4 (T6.6-4(a): mapping order by `from` bytes made observable), O5 (T6.5-3 third-file arm), O6 (T6.4-5 section-move arm), O7 (T11-6: `query subtree`/`ancestors` join the wrong-kind arm), O9 (T4.6-3 value-side boundary of named units), O10 (T10.5-1: deep-change context node is the branch head). Rejected: O8 — 13.5 does not state whether hold-file creation brings a missing parent directory into existence; 13.4's directory-creation rule binds xspec's own workspace writes, and the hold path is an arbitrary filesystem path, so the arm would pin a reading SPEC.md leaves open (not blocking; no SPEC problem logged). Placement: I1 is a sibling T6.5-10 rather than arms of T6.5-7/T6.5-8, keeping each byte-discipline test single-purpose (as T6.5-9 in iteration 6). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index d19abcb..5736656 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -18,7 +18,7 @@ These requirements bind the harness implementation regardless of test framework * **H-1 Workspace isolation.** Every test constructs a fresh, self-contained workspace in a unique temporary directory: `xspec.config.ts`, source files, and (when needed) a local git repository with scripted commits. Tests share no mutable state. Two harness instances MUST be able to run concurrently on the same machine (unique temporary roots), satisfying SPEC.md 13.5 isolation from the observer side. * **H-2 Blackbox drive.** Tests invoke the `xspec` executable as a subprocess with controlled working directory, arguments, and environment, and observe: exit code, standard output, standard error, and workspace file state. Consumer-side contracts (generated modules, type errors, runtime behavior, hover/go-to-definition) are exercised by compiling and running small consumer TypeScript programs under standard TypeScript tooling with no xspec runtime dependency (SPEC.md 13.1). No other channel into the product exists — in particular, invoking the product in-process (importing product code or calling a product-internal entry function) is not a permitted channel for any test, fast paths included: SPEC.md's complete interface is the executable, an in-process entry is an implementation detail outside that interface, and subprocess invocation carries the process-level contract the suite asserts (exit codes, stream separation, working directory, environment, termination; 12.0, 13.5). -* **H-3 Output adapters, form-exact documents, and universal value forms.** SPEC.md 12.7 fixes two tiers of concrete JSON shape. Document forms — the whole document's member set, `null`-vs-omission, `[]`-vs-`null`, and orderings — are pinned for every findings-only report, the exit-2 error document, and the surfaces of 6.6, 11.3–11.6, and 12.6: assertions on those documents are form-exact end to end, and no adapter may re-map, rename, or coerce them — output differing from 12.7 in shape is a conformance failure, never an adapter fixture (T12.7-1..3). Value forms are universal: 12.7 fixes "the value forms every JSON output uses" — a source range is `{"start", "end"}`, a path a plain string or the marked byte form, an identity a string, unavailability exactly `{"unavailable": true}`, a finding the 12.7 finding form (findings arrays the member `"findings"`, in 12.7 order), an occurrence record the 12.7 record form — and 12.6 versions that whole contract, so these forms bind wherever their data appear in any JSON output, pinned document or not. Where SPEC.md leaves a document's shape unpinned, fixing its information content instead (12.0) — the command JSON of `query`, `ids`, `show`, `coverage`, `impact`, the `review` payloads, and the applied-mapping reports of 6.4/6.5 — assertions go through a thin decoding adapter that maps the product's actual output onto the information model this document asserts against (nodes, categories, counts, paths, …). The adapter's latitude is exactly the surrounding unpinned shape — which members hold which data, nesting, grouping — never the value forms: a located value-form datum is asserted literally (T12.7-1's unpinned-surface arms), and a product carrying it in any other shape — a range as `[start, end]` or `{"from", "to"}` in a `query` row or a review payload — fails, the adapter never mapping it back. Adapters are the only place aware of unpinned shape; they may be adjusted to shape, never to values, and they MUST fail loudly (test error, not pass) when required information is absent. Human-readable reports are asserted only for required information (via robust matching), never exact wording. Where a test stages a corrupt or tampered product-written file whose concrete shape SPEC.md leaves opaque (T10.1-4, T6.6-6), the staging transformation lives in this same adapter layer — shape-aware, value-blind, applied to a file the product itself wrote, and failing loudly when the shape does not match — never fabricating such a file from an assumed layout. +* **H-3 Output adapters, form-exact documents, and universal value forms.** SPEC.md 12.7 fixes two tiers of concrete JSON shape. Document forms — the whole document's member set, `null`-vs-omission, `[]`-vs-`null`, and orderings — are pinned for every findings-only report, the exit-2 error document, and the surfaces of 6.6, 11.3–11.6, and 12.6: assertions on those documents are form-exact end to end, and no adapter may re-map, rename, or coerce them — output differing from 12.7 in shape is a conformance failure, never an adapter fixture (T12.7-1..3). Value forms are universal: 12.7 fixes "the value forms every JSON output uses" — a source range is `{"start", "end"}`, a path a plain string or the marked byte form, an identity a string, unavailability exactly `{"unavailable": true}`, a finding the 12.7 finding form (findings arrays the member `"findings"`, in 12.7 order), an occurrence record the 12.7 record form — and 12.6 versions that whole contract, so these forms bind wherever their data appear in any JSON output, pinned document or not. Where SPEC.md leaves a document's shape unpinned, fixing its information content instead (12.0) — the command JSON of `query`, `ids`, `show`, `coverage`, `impact`, the `review` payloads, and the applied-mapping reports of 6.4/6.5 — assertions go through a thin decoding adapter that maps the product's actual output onto the information model this document asserts against (nodes, categories, counts, paths, …). The adapter's latitude is exactly the surrounding unpinned shape — which members hold which data, nesting, grouping — never the value forms: a located value-form datum is asserted literally (T12.7-1's unpinned-surface arms), and a product carrying it in any other shape — a range as `[start, end]` or `{"from", "to"}` in a `query` row or a review payload — fails, the adapter never mapping it back. Adapters are the only place aware of unpinned shape; they may be adjusted to shape, never to values, and they MUST fail loudly (test error, not pass) when required information is absent. Human-readable reports are asserted only for required information (via robust matching), never exact wording. Where a test stages a corrupt or tampered product-written file whose concrete shape SPEC.md leaves opaque, the transformation is applied to a file the product itself wrote, never fabricated from an assumed layout: where a shape is discernible — the session file's JSON objects and sorted keys (T10.1-4; 10.1, 13.4) — the staging lives in this same adapter layer, shape-aware, value-blind, and failing loudly when the shape does not match; where SPEC.md enumerates no layout at all — graph data, 13.3 (T6.6-6 and the tests staging its record corruption) — the staging is shape-blind, truncation or garbage over the whole operational path set, and no adapter is involved. * **H-4 Byte assertions.** Where SPEC.md requires byte determinism or exact bytes (12.0 determinism, 3 Markdown output, 6.5 move edits, 13.4 stable ordering), tests assert byte equality. Where SPEC.md declares content opaque (journal entry content 6.1, graph data content 13.3), tests assert only the stated observable contract (location, line-orientation, append-only effect, refresh/staleness behavior) and MUST NOT pin opaque bytes across product versions — except for determinism checks comparing the product to itself. * **H-5 Exit codes and streams.** Every test asserts the exact exit code and, where relevant, the stdout/stderr separation of 12.0: reports and findings are stdout content; usage/configuration error messages and all other diagnostics are stderr content; when JSON output is in effect — `--json` among the invocation's arguments, even when the arguments are themselves the error, or a JSON-only surface (10.7, 11, 12.6) — stdout is exactly one JSON document, on exit 2 the error document of 12.7; when it is not in effect, exit-2 stdout is empty. * **H-6 Determinism protocol.** Tests marked *determinism* run the same command twice (or rebuild the same workspace in two separate directories) and assert byte-identical outputs and written files, after normalizing nothing. Workspace-relative path rules (1.5) make this well-defined across directories. @@ -63,7 +63,7 @@ These requirements bind the harness implementation regardless of test framework * **T1.5-1** Identities in every output (`query`, `show`, `ids`, coverage, impact) are workspace-relative and `/`-separated, regardless of the working directory the command runs from (run from a nested directory and from the root; compare outputs byte-wise). * **T1.5-2** A discovered source file whose path contains `#` fails with 14.19 — one arm for a spec-group file, one for a code-group file (14.19 covers both); a discovered source whose workspace-relative path is not valid UTF-8 also fails with 14.19 (7) — staged on the Linux leg, where file names are byte strings. -* **T1.5-3** `path#id` addresses a section and bare `path` addresses the root across `query node`, `show`, and `move`/`rename` arguments. +* **T1.5-3** `path#id` addresses a section and bare `path` addresses the root across `query node` and `show` (1.5, 11.1, 12.4); `move`'s section-form operands spell `<file>#<id>` under the same split (12.0, 6.5), while `rename`'s `<file>` and `move`'s file-form operands name files — a bare path there is a file operand, never a root-node address (6.4, 6.5). ### 1.6 Own text, subtree text, and own content @@ -174,7 +174,7 @@ Consumer programs in this section are compiled and run under standard TypeScript * **T4.5-1 Marker semantics.** A bare requirement reference as an expression statement records a `references` edge from the enclosing code location; at runtime the program behaves as if the line were absent (harmless property read) with no additional tooling installed. * **T4.5-2 Root marker.** A bare reference to the default export records a `references` edge to the root; it grants no coverage in any profile (roots never appear in coverage paths and root-targeted edges never extend one, 8; T8-5), but the code location is impacted (9.2) by any text edit in the document — an edit changing the root's subtreeHash or effectiveHash (4.5). Upstream arm (4.5: in the document or upstream of it): with the marker's document bearing a root-sourced dependency edge into another file (a top-level `{text(...)}`, as T8-5), an edit in that file changing only the root's effectiveHash leaves the location impacted — transitively (9.2), no node of the marker's document `changed`. * **T4.5-3 Static rule in TS.** A non-static bare reference in expression-statement position (computed index by variable, optional chaining, etc.) fails with 14.8 (invalid argument, not 14.18). -* **T4.5-4 Shadowing.** A local declaration shadowing the import binding: chains rooted at the local are not spec references — no edge, no error, program builds. +* **T4.5-4 Shadowing.** A local declaration shadowing the import binding: chains rooted at the local are not spec references — no edge, no error, program builds. Callee side (4.5: rooting is scope-aware and value-level for the `text` binding as for the node chain): with an inner-scope `function text(x: unknown) {}` shadowing the imported `text`, a call `text(SPEC.a)` in that scope has a non-spec callee and a node argument — the "passing to any other function" of 4.5, 14.18 (T4.5-5): `build` and `check` report the condition-18 finding located at that use, exit 1; the call records no `embeds` edge and no occurrence (5.7) — `occurrences --file` on that file, answering on the failing workspace (11.2), lists none for it and carries the finding — and the shadowed `text` import binding, never used, stays a valid import (2.1, 4). Control, in the same file: the identical call outside the shadowing scope is an ordinary `text` call, its `embeds` occurrence listed. A product resolving the callee by name records an `embeds` edge and no finding, failing the arm; unlike T4-4's unasserted mixed combination — a type-only callee, whose value use is a consumer type error 4.5 places outside xspec's validations — the shadowing local is ordinary value-level TypeScript, so 4.5 pins this outcome. * **T4.5-5 Sanctioned uses only.** Each fails with 14.18: aliasing a node to a variable; destructuring the module; re-exporting the binding; storing a node in an array/object; passing a node to a function other than a spec module's `text` export; using `text` as a value (passing/storing it) other than as a callee. * **T4.5-6 text in statement position.** A `text(...)` call as an expression statement is valid, records an `embeds` edge, and is not a marker (kind asserted via `query edges --kinds`). * **T4.5-7 Type-level freedom.** `typeof SPEC.a.b` and other type-level references build with no edges recorded and are not rewritten by rename (see T6.4-5). @@ -183,7 +183,7 @@ Consumer programs in this section are compiled and run under standard TypeScript * **T4.6-1 Attribution.** Markers and `text(...)` calls placed: at file top level → attributed to the file (`path`); inside a function declaration, a class method, a getter, a setter, a class member property initialized with an arrow function, with a function expression, and with a class expression, a variable declaration initialized with an arrow function, with a function expression, and with a class expression, a namespace, and a named default export → attributed to `path#unit` with the dot-joined chain, outermost first (nested cases like `Class.method` and `ns.fn` asserted). Markers' `references` edges and `text(...)` calls' `embeds` edges (4.3: from the calling code location) attribute to the same innermost enclosing named unit. A class declaration is itself an innermost attribution target, not only a chain element: a marker in a class `static` block (statements in the class body; the block binds no name, so it is no unit) and a `text(...)` call in a plain non-function property initializer (an ordinary expression, 4.5; such a property is not a named unit, 4.6) each attribute to the bare class unit `path#C`. Dotted namespaces (4.6): `namespace A.B` declares nested namespaces, one named unit per dot-separated name — a marker directly inside `namespace A.B { }` attributes to `path#A.B`, and one inside a function `f` declared there to `path#A.B.f`. * **T4.6-2 Anonymous default.** A marker inside an anonymous default-exported function is attributed to `path#default`. -* **T4.6-3 Not named units.** Markers inside an IIFE, a function stored via destructuring, a computed-name class member, a string-literal-name class member, a numeric-literal-name class member (`123() {}`), and a private (`#`-prefixed) member (`#priv() {}`) attribute to the nearest enclosing named unit or the file — the private-member arm asserts attribution to the bare class unit `path#C`, never to a `#`-named unit (4.6). +* **T4.6-3 Not named units.** Markers inside an IIFE, a function stored via destructuring, a computed-name class member, a string-literal-name class member, a numeric-literal-name class member (`123() {}`), and a private (`#`-prefixed) member (`#priv() {}`) attribute to the nearest enclosing named unit or the file — the private-member arm asserts attribution to the bare class unit `path#C`, never to a `#`-named unit (4.6). Value-side boundary (4.6: a variable declaration is a named unit only when its initializer is a function expression, an arrow function, or a class expression): a `text(...)` call that is itself the initializer of a plain-identifier variable declaration — `const s = text(SPEC.a)` — attributes, inside a function `f`, to `path#f` and, at top level, to `path`, never to a unit named `s` (asserted via `query edges`; `s` stores the returned string, no node or `text` binding, so 4.5 is untouched). * **T4.6-4 Duplicate chains.** A getter/setter pair and two same-named declarations in sibling scopes: second occurrence in document order is `path#unit@2` (1-based), asserted via `query edges`/coverage boundary membership. ## 5. Workspace Graph @@ -256,10 +256,10 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ### 6.4 Rename * **T6.4-1 Rewrites.** Renaming a mid-tree ID rewrites: its `id`, all descendant `id`s by prefix replacement, local string references, external chain references in other files, `text(...)` targets in MDX and TS, and TS markers — workspace builds and all edges retarget (query-asserted); mapping appended to journal. The command's own report is the applied mapping — every identity pair the operation journaled, the information of the preview's `mapping` (6.4) — carried in JSON per 12.0 (H-3 adapter; the successful operation's report shape is unpinned). -* **T6.4-2 Minimal edits.** Quote style (single vs double) and access form (dot vs computed) of untouched reference parts are preserved byte-wise; only the affected parts change. The rewritten segment itself keeps every keepable form (6.4: the fallback spellings apply only where a form cannot be kept), byte-asserted in MDX and TS spellings alike: a double-quoted computed segment renamed to an identifier-valid name stays computed and double-quoted (`BASE["login-v2"]` → `BASE["login2"]`, never `BASE.login2`); a single-quoted computed segment keeps its single quotes whether its new name is identifier-valid (`BASE['login-v2']` → `BASE['login2']`) or not (→ `BASE['login-v3']`); a dot-access segment renamed to an identifier-valid name stays dot access; a single-quoted local string reference whose own segment is rewritten — `{text('login-v2')}` and a `d` array's `'login-v2'` entry — keeps its single quotes (T6.5-7 pins the same for a move's prefix re-identification). Where the form cannot be kept: a dot-access segment whose new name is not a TS identifier is written as double-quoted computed access; a reference converted between local and imported form (6.5) uses dot access for identifier-valid segments, double-quoted computed access for the others, and double-quoted string literals. A product normalizing every touched segment to dot access or double quotes fails the keepable-form arms. Whole-file byte contract: each rewritten file, `.mdx` and `.ts` alike — T6.4-1's marker and `text(...)`-call rewrites in code included — is asserted byte-equal to an expected file differing from the original in the rewritten segments alone (6.4: minimal in-place edits bind code sources as they bind MDX), failing a product that reprints a code file through a printer on rename. +* **T6.4-2 Minimal edits.** Quote style (single vs double) and access form (dot vs computed) of untouched reference parts are preserved byte-wise; only the affected parts change. The rewritten segment itself keeps every keepable form (6.4: the fallback spellings apply only where a form cannot be kept), byte-asserted in MDX and TS spellings alike: a double-quoted computed segment renamed to an identifier-valid name stays computed and double-quoted (`BASE["login-v2"]` → `BASE["login2"]`, never `BASE.login2`); a single-quoted computed segment keeps its single quotes whether its new name is identifier-valid (`BASE['login-v2']` → `BASE['login2']`) or not (→ `BASE['login-v3']`); a dot-access segment renamed to an identifier-valid name stays dot access; a single-quoted local string reference whose own segment is rewritten — `{text('login-v2')}` and a `d` array's `'login-v2'` entry — keeps its single quotes (T6.5-7 pins the same for a move's prefix re-identification). Where the form cannot be kept: a dot-access segment whose new name is not a TS identifier is written as double-quoted computed access; a reference converted between local and imported form (6.5) uses dot access for identifier-valid segments, double-quoted computed access for the others, and double-quoted string literals. A product normalizing every touched segment to dot access or double quotes fails the keepable-form arms. Whole-file byte contract: each rewritten file, `.mdx` and `.ts` alike — T6.4-1's marker and `text(...)`-call rewrites in code included — is asserted byte-equal to an expected file differing from the original in the rewritten segments alone (6.4: minimal in-place edits bind code sources as they bind MDX), failing a product that reprints a code file through a printer on rename. The `id`-attribute rewrites fall under the same contract, quotes included: the fixture spells the renamed section's own `id` and one rewritten descendant's `id` single-quoted (2.7 admits `id='login'`, T2.7-3), and the expected bytes keep those quotes around the new values (6.4: rewrites are minimal in-place edits — the double-quoted fallback applies only where a form cannot be kept, and the fixture's new IDs contain no quote character) — a product re-emitting every rewritten `id` attribute double-quoted, or rewriting the whole attribute as `id="…"`, fails. * **T6.4-3 Validation refusals (exit 1).** New ID invalid (1.4); equal to old; colliding with an existing ID; violating structural parent rules. Each refusal modifies nothing (workspace byte-compare). The remaining 6.4 clause — all rewritten references resolve — admits no discriminating fixture: rename rewrites only valid workspaces (T6.4-6) and retargets every affected reference to identities that exist after the operation, so a non-resolving rewritten reference is unconstructible; the clause is exercised as the always-passing side of every successful rename (T6.4-1). * **T6.4-4 Usage errors (exit 2).** Nonexistent `<file>`; nonexistent old ID; a discovered code source as `<file>` — a wrong-kind operand, judged like existence before any content question (6.4). Checked before source validation: same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering); but an old ID inside an unparseable origin file is masked — validation findings reported, exit 1. Old-ID existence is parse-local over spelled identities (6.4, 11.2): renaming an ID two sections both spell is no usage error — the bearers establish existence, their undefined node identities notwithstanding, and the duplicate-ID finding refuses instead (exit 1, the invalid-workspace refusal, T14-7); so is renaming an ID whose sole bearer spells it beneath an ancestor spelling no identity (an undefined ancestor chain — 6.4's second parenthesized mechanism, 11.2): the bearer establishes existence and the ancestor's finding refuses (exit 1, never exit 2); an old ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent — exit 2 even beside that file's findings. -* **T6.4-5 Type-level references.** A `typeof`-level reference to the old identity is not rewritten; the workspace stays xspec-valid (the consumer type error is outside xspec's validations — `build` and `check` report no finding for it). +* **T6.4-5 Type-level references.** A `typeof`-level reference to the old identity is not rewritten; the workspace stays xspec-valid (the consumer type error is outside xspec's validations — `build` and `check` report no finding for it). Move arm (6.4: a rename or move alike can leave type-level references naming vacated identities): the same `typeof` reference to a node then moved by a section-form move into another file is likewise left byte-unchanged — naming the vacated identity — the workspace valid, and the move's applied mapping and journal entry the same as without the reference. * **T6.4-6 Valid-workspace precondition.** With a pre-existing validation error elsewhere, rename refuses (exit 1) before modifying anything. * **T6.4-7 Finishing regeneration.** After a successful rename, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the rewritten sources; `check` immediately after reports no staleness (14.10). @@ -267,15 +267,16 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T6.5-1 File form.** IDs unchanged; identities change file part only; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; journal appended; finishing regeneration as T6.4-7; the applied-mapping report as T6.4-1 (6.5: both forms report as rename does). Byte contract of the specifier rewrite: the importing `.ts` file (T6.2-2's fixture — a marker and a `text(...)` call through one `.xspec` import) and the moved file are each byte-identical to their pre-move bytes outside the specifier literals' characters — the ranges 6.6 classes `import-specifier-rewrite`, read by running the preview on a copy — and within each such range hold a specifier of 2.1's form designating the moved file's module, its relative spelling the product's (2.1 pins the form, not a canonical spelling; byte-determinism, T6.1-2/H-6, pins it product-to-itself), so a file-form move rewrites nothing beyond the literals. * **T6.5-2 Section form text edits.** Byte-exact fixtures: moved text spans opening tag's first character through closing tag's last; origin deletion drops lines left empty/whitespace-only (rule of 3); insertion immediately before the target parent's closing tag (or end of file for top-level `new-id`), followed by U+000A and preceded by one when not at line start; target file created when absent; no other byte changes beyond these edits, the identity and reference rewrites (their bytes: T6.5-7, T6.5-8), and the finishing regeneration (6.5). Self-closing arms (1.1, 6.5), byte-exact: moving a self-closing section moves exactly the self-closing tag's own characters; a self-closing target parent is first rewritten to paired form — its `/` and any whitespace immediately before or after the `/` deleted, the closing tag matching the opening tag's name appended immediately after the tag's terminating `>` — and the insertion rule then applies before that closing tag: a `<Spec id="p" />` parent becomes `<Spec id="p">` + U+000A + the moved text + U+000A + `</Spec>`. -* **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers (the code-file freshness observation: T6.5-9) and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. +* **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers (the code-file freshness observation: T6.5-9; the moved text's own imported-form references to a third module, whose bindings travel with it: T6.5-10) and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. Third-file arm (6.5: all references across the workspace): a spec source that is neither origin nor target, importing the origin module and referencing a moved node through it (a `d` chain), has that reference rewritten to the target module under an import of it added there (bytes per T6.5-8's discipline), the origin import removed when the moved reference was its binding's last and kept when another reference through it remains (one arm each); `query edges` reports the third file's edge under the moved node's new identity and `check` is clean. * **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a directory, by a symbolic link, and by a broken symbolic link (target absent), one arm each, each refused `refused-destination-exists` (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the directory arm discriminates a product probing for a file alone; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose target path is occupied by anything other than a discovered spec source — neither an insertion target nor an absent path to create, refused `refused-destination-exists` (6.5, T14-7) — one arm each: a directory; a symbolic link resolving to a discovered spec source (the link-following discriminator: discovery never yields a symlink, 7 — a product resolving the target path through the filesystem finds a spec source there and inserts through the link); and an existing `.mdx` file outside every configured spec group (present, right extension, still no discovered spec source) — an arm asserting two findings, one per reason (14, T14-7): `refused-invalid-destination` (a path in no spec group) is applicable alongside `refused-destination-exists` in every staging of a plain-`.mdx` occupant, since an in-group, non-derived-excluded `.mdx` plain file is always discovered (7, 13.4) — the insertion target, no occupant refusal — and an unparseable discovered occupant is the invalid-workspace precondition's case; the directory and symlink arms, occupant-blocked at otherwise-valid in-group paths, stage singly; section form whose `<new-id>` is not in intrinsic ID form (the mirrored "new ID is valid" check, 6.5; 14: one or more segments joined by `.`, each satisfying 1.4) — a forbidden name (`then`), a whitespace-bearing segment, and the empty `<new-id>` (destination operand `b.mdx#`: one `#`, so the 12.0 split is well-formed and the id part has zero segments), one arm each, each refused `refused-invalid-id` — the empty-id arm discriminating a product that generalizes 11.3's `--to` spelling rule (where the same spelling exits 2 as malformed, T11.3-3) to move operands; section form whose `<new-id>` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; lacking `.mdx`; and the derived-path arm of `refused-invalid-destination` (6.5: a workspace-relative directory component of a derived path the destination would generate — 13.1, 13.2, 7.3 — occupied by a non-directory): with emission enabled under `markdown.outDir`, a file-form move whose destination `new/b.mdx` is otherwise valid, its own directory components unobstructed (`new/` absent — a nonexistent component is never a refusal cause, 13.4, T13.4-8), but whose emit destination `<outDir>/new/b.md` has its component `<outDir>/new` occupied by a plain file lying under no current source's write path (so the workspace passes `build`'s validations) — refused `refused-invalid-destination`, never 14.22 (14, T14-7), discriminating a product that vets only the destination path's own components; the module and companion paths share the destination's directory (13.1), so the `outDir` emit destination is the separable derived-path fixture. The symbolic-link arms of the same clause (6.5: a component occupied by a symbolic link, whatever it targets — discovery never traverses one, 7, and writes never traverse or replace one, 13.4): a file-form move to `specs/sub/b.mdx` and a section-form move creating the target file `specs/sub/new.mdx`, where `specs/sub` is a symbolic link to a real, empty directory — each form staged with the link targeting a directory inside the workspace and, separately, one outside the workspace root — are refused `refused-invalid-destination`, never 14.22 (14, T14-7), exit 1, nothing modified: the link itself and its target directory are byte-identical afterward, no file written through the link inside or outside the workspace; and a sibling of the derived-path arm stages `<outDir>/new` as such a link instead of a plain file, refused identically. These are the discriminating cases for a product vetting components through link-following `stat`, which sees a directory at the link, proceeds, and writes the moved file — and its regenerated derived files — through the link, possibly outside the workspace; T13.4-6's symlink arm covers only 14.22 on `build` writes, never this refusal. The link lies under no current source's write path and is never a source (7), so the workspace passes `build`'s validations. The 6.5 destination clauses "containing `#`" and "not valid UTF-8" admit no staging (dead letters, recorded here as T6.5-6 records its unstageable clauses): a destination path exists only as an operand spelling, and every spelling that would present either is a usage error before any refusal is evaluated — a non-UTF-8 argument value exits 2 (12.0, T12.0-5), so a valid operand never denotes a non-UTF-8 path; a `#`-containing file-form destination classifies as a `<file>#<id>` pair, a mixed-synopsis exit 2 (T6.5-5); and a `#` in the section form's target-file part makes a two-`#` operand, the malformed value T12.0-13 pins as exit 2 on `move`. Plus the valid-workspace precondition as T6.4-6. * **T6.5-5 Usage errors (exit 2).** Nonexistent origin file or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y`, `a.mdx#x b.mdx`, and `a.mdx b#c.mdx` — the last a `#`-containing file-form destination, classified as a pair — match neither form, exit 2, and an operand containing `#` is always a `<file>#<id>` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). A non-UTF-8 destination operand (raw bytes in the OS argument vector, Linux leg) is a usage-error argument value, exit 2 (12.0, T12.0-5). These exit-2 invocations are the stagings T6.5-4's dead-letter note sets aside. * **T6.5-6 Identity terms.** The new-identity checks read in identity terms (6.5): a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid; the exact self-move — `<target-file>#<new-id>` equal to `<file>#<id>` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `<new-id>` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `<new-id>` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. So is the mirrored "structural parent rules remain satisfied" check, in both forms: the file form changes no ID and no within-file nesting; in the section form the target parent is located as the target file's section bearing `<new-id>` minus its final segment — absent, or lying within the moved subtree, it is `refused-missing-target-parent` (T6.5-4) — prefix replacement preserves the subtree's relative nesting, a single-segment `<new-id>` inserts at top level (exactly one segment, 1.3), and the origin's removal disturbs no remaining ID; on a workspace passing the valid-workspace precondition, 1.3 therefore holds by construction after every non-refused move, the check is exercised as the always-passing side of every successful move (T6.5-1/2/3), and `refused-structural-parent` is staged through rename alone (T6.4-3, T14-7). -* **T6.5-7 Operation-side rewrite bytes.** The real move's import-edit extents and reference-conversion spellings, byte-asserted against independently composed expected files — staged so no import is added, the one rewrite direction free of implementation latitude (6.5: identifier choice and insertion offset attach to added imports alone; the addition side's line discipline: T6.5-8). Fixture: the origin file imports the target file under two bindings (valid, 2.1), one declaration alone on its line, the other following a retained, still-referenced third-module import on a shared line; every reference through the two bindings — a `d` chain and a `text(...)` embedding — lies inside the moved subtree, which also holds a single-quoted local string reference to a moved descendant; no reference to a moved node lies outside the subtree. After the section move into the target file: both target-file imports are left unreferenced and removed with 6.5's exact extent — the own-line declaration's line dropped with its terminator (3), the shared-line declaration's own characters alone deleted, the retained import kept byte-for-byte on its kept line; the moved references convert to local form in 6.4's pinned spelling for converted references, double-quoted string literals; the local reference is re-identified by prefix replacement with its single-quote spelling preserved (6.4: minimal in-place edits); and the rewritten origin and target files are each asserted byte-equal to expected bytes composed from the rules of 6.5 and 3. This is the operation-side assertion T6.5-2 sets aside (its no-other-byte-changes check excludes the identity and reference rewrites) and T6.6-4 makes only of the preview's report — failing a product that emits a spec-perfect preview while the real edit leaves an emptied import line behind, normalizes whitespace around a removed declaration, or spells a converted reference single-quoted. The code-source counterpart, fully composed (no import added, so no latitude): a `.ts` file importing the origin module, the target module, and a retained third module — the third module's declaration and the origin's sharing one line in a second variant, the origin's following it — whose only references through the origin binding are markers on nodes of the moved subtree, beside a marker through the target binding and one through the third; after the section move, the origin-module import — its binding left without references — is removed with 6.5's exact extent (the own-line declaration's line dropped with its terminator; the shared-line declaration's own characters alone deleted, the retained declaration kept byte-for-byte on its kept line), the moved markers are rewritten through the existing target binding (6.5: an import is added only where the file lacks the binding), and the file is asserted byte-equal to expected bytes composed from the rules of 6.4/6.5 and 3 — the import-removal rule binds code sources as it binds MDX, and a product that reprints a code file on removal fails here while passing every resolution-only assertion (T6.5-1, T6.2-2). The addition side in code is T6.5-8's TS arm. +* **T6.5-7 Operation-side rewrite bytes.** The real move's import-edit extents and reference-conversion spellings, byte-asserted against independently composed expected files — staged so no import is added, the one rewrite direction free of implementation latitude (6.5: identifier choice and insertion offset attach to added imports alone; the addition side's line discipline: T6.5-8). Fixture: the origin file imports the target file under two bindings (valid, 2.1), one declaration alone on its line, the other following a retained, still-referenced third-module import on a shared line; every reference through the two bindings — a `d` chain and a `text(...)` embedding — lies inside the moved subtree, which also holds a single-quoted local string reference to a moved descendant and spells that descendant's `id` attribute single-quoted (2.7); no reference to a moved node lies outside the subtree. After the section move into the target file: both target-file imports are left unreferenced and removed with 6.5's exact extent — the own-line declaration's line dropped with its terminator (3), the shared-line declaration's own characters alone deleted, the retained import kept byte-for-byte on its kept line; the moved references convert to local form in 6.4's pinned spelling for converted references, double-quoted string literals; the local reference and the single-quoted `id` attribute are each re-identified by prefix replacement with their single-quote spellings preserved (6.4: minimal in-place edits — binding the `id`-attribute rewrite as they bind references, T6.4-2); and the rewritten origin and target files are each asserted byte-equal to expected bytes composed from the rules of 6.5 and 3. This is the operation-side assertion T6.5-2 sets aside (its no-other-byte-changes check excludes the identity and reference rewrites) and T6.6-4 makes only of the preview's report — failing a product that emits a spec-perfect preview while the real edit leaves an emptied import line behind, normalizes whitespace around a removed declaration, or spells a converted reference single-quoted. The code-source counterpart, fully composed (no import added, so no latitude): a `.ts` file importing the origin module, the target module, and a retained third module — the third module's declaration and the origin's sharing one line in a second variant, the origin's following it — whose only references through the origin binding are markers on nodes of the moved subtree, beside a marker through the target binding and one through the third; after the section move, the origin-module import — its binding left without references — is removed with 6.5's exact extent (the own-line declaration's line dropped with its terminator; the shared-line declaration's own characters alone deleted, the retained declaration kept byte-for-byte on its kept line), the moved markers are rewritten through the existing target binding (6.5: an import is added only where the file lacks the binding), and the file is asserted byte-equal to expected bytes composed from the rules of 6.4/6.5 and 3 — the import-removal rule binds code sources as it binds MDX, and a product that reprints a code file on removal fails here while passing every resolution-only assertion (T6.5-1, T6.2-2). The addition side in code is T6.5-8's TS arm. * **T6.5-8 Added-import insertion discipline.** The addition-side byte contract of 6.5 — an added import is inserted as a line of its own, the declaration's characters followed by a U+000A line terminator, preceded by one when the insertion point is not at the start of a line — asserted with the identifier choice and insertion offset left free (6.5's latitude), shape-aware and value-blind (the ethos T6.5-7 applies to removals). Three section-move arms, each staged so the receiving file's expected post-move bytes are composable from the rules of 6.4/6.5 and 3 up to exactly two unknowns, the fresh identifier and the insertion offset: a TS arm, the grammar-freest case — a code file imports the origin module and references one moved and one unmoved node, so the rewrite needs a target-module binding the file lacks while the origin import keeps its remaining reference and stays — an MDX origin arm — the origin file, already holding a retained third-module import (grammar-permitted offsets exist, and freshness is live against its binding), keeps a local string reference to a moved descendant, whose conversion to imported form (6.4's pinned spellings) makes the origin file itself gain the target module's import — and an MDX target arm, the third conversion direction, which T6.5-7 (imported→local) and the origin arm (origin-side local→imported) leave byte-unasserted where the moved text lands: the moved subtree holds a local string reference (a `d` entry) to an origin node outside the subtree, one of whose ID segments is not identifier-valid, and the target file — an existing discovered source lacking the origin module's import — gains that import, the reference converting to imported form through the fresh binding in 6.4's pinned spellings (dot access, double-quoted computed access for the non-identifier segment), the moved text otherwise byte-identical and the origin file losing the section and gaining no import; so the addition-side line discipline is asserted in the receiving target file, where T6.6-4(b) asserts only the preview's offset. In each arm the harness isolates the single added byte run by diff against the composed bytes and asserts it is exactly the declaration followed by U+000A at an insertion offset lying at the start of a line, and exactly U+000A, then the declaration, then U+000A at one that does not — the declaration one import of the needed module's specifier — the target module's, or in the MDX target arm the origin module's — binding one fresh identifier (value unpinned) that the rewritten references use, no other byte inserted. This fails a product that joins the added declaration to a neighbor with `;` — still parsing, resolving, and byte-deterministic in either grammar, the shared line still dropping whole from Markdown output in the MDX arms (3), hence passing every other test — omits the preceding terminator at a mid-line offset, adds a spurious blank line, or writes any terminator but U+000A, wherever it chose to insert: a line-start offset is always grammar-available, so the mid-line branch is reachable by implementation latitude alone, and the discipline is asserted over whatever offset the product makes observable (as T6.6-4 asserts its ordering tie-break). * **T6.5-9 Fresh identifiers in code.** The freshness clause of 6.5 — an added import binds fresh identifiers colliding with no binding already in the file (2.1, 4) — has, in a code file, no xspec-side observation: "any binding already in the file" spans local declarations as well as imports, and an added import colliding with a local declaration is a TypeScript error in the consumer, outside xspec's validations (4.5) — no `build`/`check` finding (condition 15 covers import-versus-import collisions alone, which is why T6.5-3's post-move `check` suffices in MDX, where every binding is an import), no staleness — while 4.5's scope-aware rooting may then drop the rewritten markers' edges silently. A product checking freshness against import bindings alone therefore passes T6.5-8's TS arm, whose receiving file leaves every plausible identifier free. This test stages the collision: T6.5-8's TS arm re-staged with a receiving code file that additionally declares, at module scope, bindings pre-empting the identifiers a product would plausibly derive — a local `const`, a `function`, a `class`, and a `type` alias, and a non-spec import binding — spelled from the target file's basename (as written, lower- and upper-cased, `Spec`- and `SPEC`-suffixed) and from the origin binding's name with a digit and with an underscore appended, the file compiling clean before the move under standard tooling. After the section move, asserted through the standard-tooling channel of H-2: the rewritten file compiles with no diagnostics — its only possible new diagnostic being the import-conflicts-with-local-declaration error a collision produces — so the added declaration's identifier equals none of the pre-empted names; `query edges` reports the moved markers' `references` edges from the file to the moved nodes' new identities and the unmoved marker's edge through the retained origin binding; and `check` is clean (T6.5-3). The pre-empted set is a lure, not a bound: the identifier stays the product's choice (6.5's latitude), and compile-cleanliness is the assertion whatever the choice. +* **T6.5-10 Third-module bindings carried with moved text.** A reference inside the moved text need not target a moved node to need rewriting: an imported-form reference to a node of a spec module `X` that is neither origin nor target — `d={X.foo}`, `{text(X.bar)}` — is bound by the origin file's import of `X`, which the moved text leaves behind, so in the target file it needs a binding of `X`'s module (6.5: an import is added when a rewritten reference needs a module binding its file lacks — and only then, the reading T6.5-7's TS arm pins for markers rewritten through an existing binding). T6.5-7 keeps its third-module reference outside the moved subtree and T6.5-8's conversions are local↔imported alone, so no fixture of theirs meets this shape. Two section-move arms over three spec sources — origin `a.mdx`, target `b.mdx`, and `x.mdx`, all in one directory — where the moved subtree holds a `d` reference and a `{text(...)}` embedding through the origin's `X` binding, one of them through a double-quoted computed segment (`X["bar-baz"]`), and no reference to a moved node lies outside the subtree: (a) value-blind, T6.5-8's discipline — the target file, an existing discovered source, holds no import of `x.mdx`'s module, and the origin's only references through `X` lie inside the moved subtree: after the move the target's bytes are composable from the rules of 6.4/6.5 and 3 up to exactly two unknowns, the fresh identifier — appearing in the added declaration and at each moved reference's root, `X` itself admissible there, being fresh in that file — and the insertion offset; the single added byte run isolated by diff is exactly one import declaration under 6.5's line discipline (T6.5-8), its specifier of 2.1's form designating `x.mdx`'s module (its relative spelling the product's, as T6.5-1), each moved reference is rooted at the identifier that declaration binds with its access form kept (6.4), the moved text otherwise byte-identical; the origin file loses the section and, its `X` binding left without references, its own-line `X` declaration with the line's terminator (6.5's exact extent, T6.5-7), and is otherwise byte-identical; (b) byte-composable, no latitude — the target file already imports `x.mdx`'s module under another binding `Z`, referenced by a section of its own, and the origin keeps a reference through `X` outside the moved subtree: no import is added (the file lacks no binding of the module), each moved reference is re-rooted to `Z` with quote style and access form kept — `X.foo` → `Z.foo`, `X["bar-baz"]` → `Z["bar-baz"]` (6.4: minimal in-place edits) — the origin's `X` declaration stays byte-for-byte, and the rewritten origin and target files are each asserted byte-equal to expected bytes composed from the rules of 6.4/6.5 and 3. In both arms `query edges` reports the moved nodes' `depends` and `embeds` edges under their new identities to `x.mdx`'s unchanged nodes, and `build` and `check` are clean (6.5: a successful move's finishing regeneration runs on a valid workspace). A product converting only between local and imported forms leaves `X.foo` unbound in the target — an invalid workspace behind a reported success — and fails both arms; one adding a second import of a module the target already binds fails (b)'s whole-file contract. ### 6.6 Previews @@ -283,7 +284,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T6.6-2 Modifies nothing.** A rename `--preview` and a section-form move `--preview` on workspaces where the real operation would proceed: exit 0, findings `[]`, and every byte of the workspace identical afterward — sources, journal (an absent journal stays absent), derived files, and graph data untouched; a subsequent real run on the same state performs the previewed plan, its applied mapping (T6.4-1) equal to the preview's `mapping`. Preview output is byte-deterministic across repeated runs (H-6) and, under `--json`, the form-exact 12.7 preview document (H-3). * **T6.6-3 Refusal and scheduling equivalence.** For each refusal of T6.4-3 and T6.5-4 — the invalid-workspace precondition included — staged identically, the `--preview` invocation reports the same findings (same stable codes, locations, identities; 14) and exits 1, its `mapping`, `files`, and `delta` `null` (12.7), modifying nothing; for the usage errors of T6.4-4/T6.5-5 the preview exits 2 identically (argument checks precede either way). The equivalence is over workspace state, never scheduling (6.6): while another mutating command is held (`--test-hold`, 13.5), a `--preview` invocation runs to completion — it takes no exclusivity and never meets the mutual-exclusion refusal of T13.5-2 — and `--test-hold` combined with `--preview` is a usage error, exit 2. -* **T6.6-4 Report content.** Byte-precise fixtures asserted against precomputed pre-operation offsets, form-exact per 12.7: (a) a rename preview reports the complete identity mapping — the renamed ID and every descendant — and, per rewritten file, `id-rewrite` edits spanning each rewritten `id` attribute's own characters and `reference-rewrite` edits spanning each affected occurrence's span (5.7) across MDX and TS; (b) a section-move preview into an existing target file reports the `origin-deletion` as one range spanning the construct's own characters extended over the leftover whitespace and line terminator of each additionally dropped line (contiguous bytes: the adjunct drop lies inside this range, no class of its own), the re-identification's `id-rewrite` edits nested inside that deletion range in the same pre-operation coordinates (containment is geometry, each edit under its own class), `target-insertion` as a zero-length range at the insertion offset, `target-parent-rewrite` spanning a self-closing target parent's tag, `import-addition` as a zero-length range at the exact offset the real operation then uses (6.5; byte-asserted by running the operation on a copy), and `import-removal` spanning the declaration plus its adjunct drops; (c) a file-form move preview reports `import-specifier-rewrite` edits spanning the specifier literals and `file-relocation` spanning the entire moved file, the relocated file's entry under its current, pre-operation path; (d) a section-move preview whose target file does not exist reports, under the path the creation would occupy, exactly one `file-creation` edit at the start of the new file — the only location without pre-operation coordinates; the insertion and any import additions there are subsumed, while the moved text's own rewrites are reported inside the origin file's deletion range. Every edit is class-plus-range only — no replacement text (12.7) — and every reported class is one of the ten 12.7 class names. The edit ordering's final tie-break — class-name bytes after range start and range end (12.7) — admits no product-independent discriminating fixture: distinct nonzero-range edits rewrite or remove distinct constructs and never share both endpoints, so an identical-range pair arises only between zero-length insertion points — an import addition whose implementation-chosen offset (6.5) coincides with the target insertion, possible only at end of file for a top-level `<new-id>`, an import being grammar-permitted nowhere inside a section construct — a coincidence implementation latitude alone decides; the harness asserts the full 12.7 comparator over whatever edits are emitted (`import-addition` ordering before `target-insertion` on coincidence), exercising the tie-break exactly where a product makes it observable. +* **T6.6-4 Report content.** Byte-precise fixtures asserted against precomputed pre-operation offsets, form-exact per 12.7: (a) a rename preview reports the complete identity mapping — the renamed ID and every descendant — ordered by `from` bytes (12.7), the fixture's descendants `a.z` and `a.c` standing in document order opposite to byte order so that a product emitting document order fails, and, per rewritten file, `id-rewrite` edits spanning each rewritten `id` attribute's own characters and `reference-rewrite` edits spanning each affected occurrence's span (5.7) across MDX and TS; (b) a section-move preview into an existing target file reports the `origin-deletion` as one range spanning the construct's own characters extended over the leftover whitespace and line terminator of each additionally dropped line (contiguous bytes: the adjunct drop lies inside this range, no class of its own), the re-identification's `id-rewrite` edits nested inside that deletion range in the same pre-operation coordinates (containment is geometry, each edit under its own class), `target-insertion` as a zero-length range at the insertion offset, `target-parent-rewrite` spanning a self-closing target parent's tag, `import-addition` as a zero-length range at the exact offset the real operation then uses (6.5; byte-asserted by running the operation on a copy), and `import-removal` spanning the declaration plus its adjunct drops; (c) a file-form move preview reports `import-specifier-rewrite` edits spanning the specifier literals and `file-relocation` spanning the entire moved file, the relocated file's entry under its current, pre-operation path; (d) a section-move preview whose target file does not exist reports, under the path the creation would occupy, exactly one `file-creation` edit at the start of the new file — the only location without pre-operation coordinates; the insertion and any import additions there are subsumed, while the moved text's own rewrites are reported inside the origin file's deletion range. Every edit is class-plus-range only — no replacement text (12.7) — and every reported class is one of the ten 12.7 class names. The edit ordering's final tie-break — class-name bytes after range start and range end (12.7) — admits no product-independent discriminating fixture: distinct nonzero-range edits rewrite or remove distinct constructs and never share both endpoints, so an identical-range pair arises only between zero-length insertion points — an import addition whose implementation-chosen offset (6.5) coincides with the target insertion, possible only at end of file for a top-level `<new-id>`, an import being grammar-permitted nowhere inside a section construct — a coincidence implementation latitude alone decides; the harness asserts the full 12.7 comparator over whatever edits are emitted (`import-addition` ordering before `target-insertion` on coincidence), exercising the tie-break exactly where a product makes it observable. * **T6.6-5 Delta.** After a build, a file-form move preview reports the derived-file delta both directions: under `generated` the destination's module, companion, and (emission enabled) Markdown paths — nothing recorded there — and under `removed` the recorded pre-move module, companions, and Markdown the operation would leave no longer generated (6.6); a rename preview on the same workspace reports `[]` in both directions (regeneration rewrites recorded paths in place), and the created-target move of T6.6-4(d) reports the new file's derived paths under `generated`. Record-based, not presence-based, with exact expectations in both directions (12.7: form-exact, paths in byte order): with graph data deleted (T13.3-2's operational definition) — the record then missing, read as empty (11.6: empty before any generation has run), never as unreadable (14.23, T6.6-6) — the same move preview's `generated` is exactly every derived path the post-operation workspace generates, nothing being recorded: the module and companions of every discovered spec source, the moved file's under its destination path, plus each source's Markdown emit destination with emission enabled — composed from the `recorded` set the inventory reported after the build (11.6; the companion suffixes are the product's own, H-4), the moved file's entries re-based to its destination — and its `removed` is exactly `[]`; and the preview still writes nothing: no refresh, graph data still absent afterward. Lagging-record counterpart: with emission enabled in the configuration after the build and no rebuild, the same preview's `generated` is exactly the destination's module, companions, and Markdown together with every other discovered spec source's Markdown emit destination — the paths the current configuration generates that the stale record lacks — and its `removed` exactly the recorded pre-move module and companions. * **T6.6-6 Unreadable record.** Corrupt the product-written graph data shape-blind (truncation or garbage over T13.3-2's operational path set; H-3/H-4 staging discipline, as T10.1-4 stages sessions): a move `--preview` whose plan is otherwise valid emits the full preview — `mapping` and `files` complete — with `delta` explicitly unavailable as one datum, never read as an empty record, the condition-23 finding (`unreadable-record`, concerned path the graph-data area, no path inside it named) in `findings`, exit 1 (14.23). The real operation on the same state is not refused — it proceeds, its finishing regeneration replacing the corrupt record (`check` clean afterward, T12.2-2) — and a refused preview staged on the same corrupt-record state (an identity-unchanged rename) reports the refusal findings alone, `mapping`/`files`/`delta` `null`, never a condition-23 finding (6.6: a refused preview consults no record). @@ -365,7 +366,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( ### 10.5 path-blocks -* **T10.5-1 Generation.** SPEC.md §15's worked change (leaf text edit) yields exactly the four listed items with specified scope/context/origin. Extended fixture: a `changed` node with a `changed` ancestor generates no own item (skipping rule); scope of `subtree-coherence` is the node plus all descendants; multiple changed nodes sharing an ancestor A yield one `parent-consistency` item for A against the union of branches. +* **T10.5-1 Generation.** SPEC.md §15's worked change (leaf text edit) yields exactly the four listed items with specified scope/context/origin. Extended fixture: a `changed` node with a `changed` ancestor generates no own item (skipping rule); scope of `subtree-coherence` is the node plus all descendants; multiple changed nodes sharing an ancestor A yield one `parent-consistency` item for A against the union of branches; for a change two levels beneath A (A → B → C, only C `changed`), A's `parent-consistency` item's context is exactly `{B}` — the branch head, A's child on that branch (10.5: each changed branch enters as one context node) — never `C`, while B's item's context is `{C}`, the identities asserted (T10.4-1's deep-edit sensitivity presupposes this context node). * **T10.5-2 Blocking chains.** A's `parent-consistency` item is blocked by, per changed branch, the child's `subtree-coherence` item (child is the changed node) or the child's `parent-consistency` item (deeper change); chains extend to the root; only those two kinds block `parent-consistency` items; `metadata-consistency`, `dependency-consistency`, and `code-impact` items have empty `blockedBy`. * **T10.5-3 Metadata/dependency/code items.** One `metadata-consistency` per `metadata-changed` node (context: added and removed `d` targets; `coverage`/`tags` changes described in `reason`); one `dependency-consistency` per node with a dependency edge to a target present on both sides of the baseline (5.6) whose effectiveHash changed (context: those targets; origin: originating nodes) — an edge to a target added since the baseline yields no such item, the change being reviewed at its source — both halves of 10.5's note staged (a new `d` edge makes the source `metadata-changed`, a new embedding makes it `changed`): a fixture node whose only affected target was added since the baseline gets no `dependency-consistency` item, its new `d` edge surfacing as its own `metadata-consistency` item; a second node whose only affected target entered through a new `{text(...)}` embedding likewise gets no `dependency-consistency` item — the new embedded reference changes its own content (5.5), it is `changed`, and the change is reviewed via its own `subtree-coherence` item; one `code-impact` per impacted location (context: the impact-edge targets that make it impacted, added and deleted included). * **T10.5-4 Item order.** A fixture with items of all kinds across two files asserts the total order: requirement-scoped first by depth deepest-first (roots 0), then kind order `subtree-coherence`, `metadata-consistency`, `dependency-consistency`, `parent-consistency`, then file path bytes, then document order; `code-impact` items last by location identity; after deleting a scope node, absent-scope items order after present ones by identity then item `id` (10.5 ordering rule); `status`/`next`/`export` all present this order. @@ -404,7 +405,7 @@ SPEC.md 11's five commands are JSON-only: for `query`, `occurrences`, `view`, `a * **T11-3 subtree/ancestors.** `subtree` returns the node plus descendants in document order (root query returns the whole file); `ancestors` returns proper ancestors nearest-first ending at the file root, excluding the queried node (empty for a root); rows carry the row fields of T11-2 — identity, source range, tags, coverage attribute (11: one row contract for `nodes`, `subtree`, and `ancestors`) — asserted on `subtree` and `ancestors` rows including a tagged `coverage="none"` node and a root (attribute absent), so a product omitting a row field from either subcommand fails. * **T11-4 edges.** `--from`/`--to` accept requirement nodes and code locations; `--kinds` filters over all four kinds and defaults to no filter (contains edges included); comma-separated list form; unknown kind value → exit 2. * **T11-5 reachable.** Reports existence of a dependency path under the given kinds (default: all three dependency kinds, never `contains`) and one shortest witness path with the 12.0 tie-break (two-equal-paths fixture); equal `--from` and `--to` (a node bearing both incoming and outgoing dependency edges) report that no path exists — a zero-length path is not a path (11); `--kinds contains` → exit 2 (invalid flag value: `reachable` accepts only the three dependency kinds, 11 — `edges` accepts all four, T11-4). -* **T11-6 Identity resolution.** Bare `path` resolves to a root node for a spec-group file and to a code location for a code-group file; `path#unit` and `path#unit@N` address code locations; a path in no configured group → exit 2 (unknown, 12.0). Wrong-kind operands (12.0): `query node` and `show` (12.4) given a code-group `path` or `path#unit` — a code source named where a requirement-node identity is required — each exit 2. Unknown code units (12.0: an unknown node identity named in arguments, the check judged parse-local over the named file's named units, 4.6): on a discovered code source, `query edges --from <path>#<unspelled-unit>` → exit 2 — likewise `edges --to` and `reachable --from`/`--to` given the same identity — and an out-of-range disambiguator, `<path>#<unit>@2` where the chain occurs once in the file (4.6), is equally unknown, exit 2 — as is `<path>#<unit>@1` at every occurrence count: 4.6 suffixes only occurrences after the first, so no occurrence bears `@1` — the first occurrence's identity is the bare `path#unit`, and identities compare byte-wise (12.0) — staged where the chain occurs once and where it occurs twice, the two-occurrence arm discriminating a product that resolves `@1` to the first occurrence: never a bare edgeless graph node with an empty answer, exit 0, and never a resolved answer (the failing-workspace arm: T12.0-10). +* **T11-6 Identity resolution.** Bare `path` resolves to a root node for a spec-group file and to a code location for a code-group file; `path#unit` and `path#unit@N` address code locations; a path in no configured group → exit 2 (unknown, 12.0). Wrong-kind operands (12.0): `query node`, `query subtree`, `query ancestors` (11.1: `<node>` is a requirement-node identity for all three), and `show` (12.4) given a code-group `path` or `path#unit` — a code source named where a requirement-node identity is required — each exit 2. Unknown code units (12.0: an unknown node identity named in arguments, the check judged parse-local over the named file's named units, 4.6): on a discovered code source, `query edges --from <path>#<unspelled-unit>` → exit 2 — likewise `edges --to` and `reachable --from`/`--to` given the same identity — and an out-of-range disambiguator, `<path>#<unit>@2` where the chain occurs once in the file (4.6), is equally unknown, exit 2 — as is `<path>#<unit>@1` at every occurrence count: 4.6 suffixes only occurrences after the first, so no occurrence bears `@1` — the first occurrence's identity is the bare `path#unit`, and identities compare byte-wise (12.0) — staged where the chain occurs once and where it occurs twice, the two-occurrence arm discriminating a product that resolves `@1` to the first occurrence: never a bare edgeless graph node with an empty answer, exit 0, and never a resolved answer (the failing-workspace arm: T12.0-10). * **T11-7 Ordering.** Every result list is deterministic: repeated runs byte-identical; content-identical workspaces in different directories produce identical output (H-6). ### 11.2 Availability on imperfect files @@ -443,7 +444,7 @@ Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are ### 11.6 `xspec inventory` * **T11.6-1 Anchoring.** From the workspace root, `root` is `.`; from nested `a/b`, `root` is `../..` and `config` `../../xspec.config.ts`; from a sibling directory with `--config`, ascent `..` segments then descent segments, joined with `/`, no `.` segments, no trailing separator (11.6) — asserted byte-exactly, working-directory-dependence being pure invocation input (12.0). Drive-mismatch arm, Windows leg (E-6): a working directory and workspace root on different drive letters (a substituted drive mapping suffices) → the anchoring in the platform's absolute, drive-qualified spelling — the sole absolute-path case and sole platform-separator output — deterministic per invocation; on the Linux leg no absolute form ever appears. -* **T11.6-2 Configuration, sources, derived map.** The resolved view with every default and inferred kind explicit: `markdown` absent → `{"emit": false, "outDir": null}`; a defaulted profile → `targets` `"leaves"`, `edgeKinds` all three, `boundaryKind` explicit though inferred, `targetTags` `null`; group references inside profiles and rules stay configured names resolving against the reported group list; every discovered source with its group memberships (a two-group file); the derived map per spec source — module path, and Markdown destination exactly while emission is enabled, both present before any build has run (determined by configuration and discovery); a spec-group file without `.mdx` (14.19 staged beside it) → both structurally absent; with emission disabled → `markdown` `null` for every source (7.3, 12.7). +* **T11.6-2 Configuration, sources, derived map.** The resolved view with every default and inferred kind explicit: `markdown` absent → `{"emit": false, "outDir": null}`; a defaulted profile → `targets` `"leaves"`, `edgeKinds` all three, `boundaryKind` explicit though inferred, `targetTags` `null`; a rule with `kinds` omitted → `kinds` all three dependency kinds (7.5), and a group selector without `kind` naming an unambiguous group → its inferred `kind` reported explicitly, never `null` (12.7: every default and inferred kind explicit); group references inside profiles and rules stay configured names resolving against the reported group list; every discovered source with its group memberships (a two-group file); the derived map per spec source — module path, and Markdown destination exactly while emission is enabled, both present before any build has run (determined by configuration and discovery); a spec-group file without `.mdx` (14.19 staged beside it) → both structurally absent; with emission disabled → `markdown` `null` for every source (7.3, 12.7). * **T11.6-3 Record, area, durables, order.** `recorded` is empty before any generation; after a build it lists the recorded derived paths — modules, companions, Markdown — each companion attributable to its source through the 13.1 naming scheme; after a configuration change without rebuild it lags, reported as recorded, not as configured (11.6). The graph-data area is reported unconditionally — before any build — as `.xspec`, no trailing separator; a foreign file placed under `.xspec/` (neither journal, session-named, nor recorded) appears in no inventory list and is never claimed (unattributed, 11.6). `journal` reports occupancy by presence alone: absent → `false`; a plain file, a directory, and a symlink each → `true`, no content read, no 14.13 from inventory. Sessions are selected by name alone: a product-written session, a garbage-content `S.json`, and a directory named `S2.json` are all listed (content unread, no 14.21 here); `notes.txt` and `.foo.json` never (10.1). Orders: paths byte order; groups, profiles, rules configuration order; session files byte order of file name. * **T11.6-4 No parse, no write, one finding.** On a workspace whose sources fail every validation family — an unparseable file included — plus a garbage journal line and a corrupt session: `inventory` answers in full, finding-free, exit 0, modifying nothing (byte-compare; no refresh) — it parses no sources and reads no journal or session content, those findings reported where their conditions assign them, never here. Configuration errors keep precedence: missing and invalid configuration → exit 2, the error document, no inventory. The one finding it ever carries: with the record corrupted shape-blind (T6.6-6's staging), `recorded` is explicitly unavailable — never read as empty — with the condition-23 finding (stable code, concerned path the graph-data area), exit 1, every other member emitted in full (14.23). From 897bfeae005413d74a24c68ad58a1d10754a4caa Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 00:30:40 +0000 Subject: [PATCH 178/226] =?UTF-8?q?sdg(phase-6):=20TEST-SPEC=20iteration?= =?UTF-8?q?=208=20=E2=80=94=20valueless=20id/coverage/tags=20at=20build;?= =?UTF-8?q?=20dead-letter,=20undiscovered-file,=20collision-cardinality,?= =?UTF-8?q?=20hold-ordering,=20self-test=20rationale=20arms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: I1, O1, O2, O3, O4, O5, O6, O7, O8. Rejected: none. I1 — T2.7-3 gains a 14.17 arm per string prop in valueless form (`<S id>`, `<S id="x" coverage>`, `<S id="x" tags>`), the `<S id>` arm pinned 14.17 and no 14.1; T1.3-6 gains the valueless-`id` masking arm; T11.2-2 cites the accompanying conditions. O1 — T6.3-4 records the ambiguous-mapping clause as unstageable (opaque entries, one observable outcome shared with the unresolvable case). O2 — T6.4-4/T6.5-5 stage the present-but-undiscovered `.mdx` origin beside the absent one. O3 — T12.0-10's without-configuration arm adds missing-required-flag/argument probes. O4 — T6.4-3 stages a two-bearer collision through prefix replacement; T14-7 cites it. O5 — T13.5-1 states the fresh fixture and adds a stale-workspace arm (the hold precedes the 13.3 refresh). O6 — S-3..S-6 each state why certification cannot reach them. O7 — T4-4's unasserted mixed case labelled an ambiguity in SPEC.md 4.5; not escalated to SPEC-PROBLEMS: the case is a consumer TypeScript error under either reading, blocking no test or implementation. O8 — T11.4-3's valueless prop shares T2.7-3's `<S id="x" tags>` fixture. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/TEST-SPEC.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/specs/TEST-SPEC.md b/specs/TEST-SPEC.md index 5736656..8a332e4 100644 --- a/specs/TEST-SPEC.md +++ b/specs/TEST-SPEC.md @@ -49,7 +49,7 @@ These requirements bind the harness implementation regardless of test framework * **T1.3-3 Level skipping.** A child whose ID adds two segments (`a` containing `a.b.c` with no `a.b` section) fails with 14.2. * **T1.3-4 Top-level segment count.** A top-level section with a multi-segment ID fails; a one-segment top-level ID passes (checked against the empty prefix, 14.2). * **T1.3-5 Duplicate IDs.** Two sections with the same ID in one file fail with 14.3; the same ID in two different files is valid (uniqueness is per file, identities differ by path, 1.5). -* **T1.3-6 No-identity masking.** A section lacking `id` with children: the immediate children report no 14.2 (masked by 14.1), while their other conditions and the grandchildren's structural checks still report (14.2 masking note). Invalid-form arms (14.1: a repeated `id` attribute or a non-quoted-static value is condition 17, never condition 1, and masks condition 2 for the immediate children the same way): a repeated-`id` section and a braced-`id` (`id={"x"}`) section, each with an immediate child whose ID the structural rule would otherwise judge — each bearer reports 14.17 and no 14.1, its immediate children report no 14.2, and their own children's structural checks still report (14.2). +* **T1.3-6 No-identity masking.** A section lacking `id` with children: the immediate children report no 14.2 (masked by 14.1), while their other conditions and the grandchildren's structural checks still report (14.2 masking note). Invalid-form arms (14.1: a repeated `id` attribute or a non-quoted-static value is condition 17, never condition 1, and masks condition 2 for the immediate children the same way): a repeated-`id` section, a braced-`id` (`id={"x"}`) section, and a valueless-`id` (`<S id>`, the bare name — T2.7-3's arm) section, each with an immediate child whose ID the structural rule would otherwise judge — each bearer reports 14.17 and no 14.1, its immediate children report no 14.2, and their own children's structural checks still report (14.2); a product reading the bare name as an absent `id` masks the same children under condition 1, so the bearer's own code is the valueless arm's discriminating assertion. * **T1.3-7 Depth.** SPEC.md bounds no nesting depth — 1.3's structural rule holds at every level: a valid workspace whose one file nests sections at least 2048 levels deep (P-8's giant-nesting floor, 16) builds with exit 0; `query subtree` on the root returns the root plus every section, in document order, the count asserted; `view` serves the full positional tree. The deterministic anchor of P-8's floor outside the generator machinery, and a deterministic exercise of the harness's answer-scale capacity (H-11, S-8). ### 1.4 ID segments and tags @@ -124,7 +124,7 @@ These requirements bind the harness implementation regardless of test framework * **T2.7-1 Foreign constructs.** A JSX element other than `<S>`/`<Spec>`, an expression container other than `text(...)` or an MDX comment, and an export statement each fail with 14.16. * **T2.7-2 Comments.** An MDX comment inside a section: absent from Markdown output; not part of own text (`query node`). Hash and category stability (1.6/3), each arm against a committed baseline: editing only the comment's content (inline and own-line layouts), deleting an inline comment that shares its line with retained non-whitespace content, and deleting an own-line comment together with its entire line (construct plus terminator) each change no hash and produce no change categories. Boundary: deleting only an own-line comment's construct characters — leaving the emptied line in place — changes the containing section's ownHash and makes it `changed`, with the cascades of 5.6: the line, previously dropped as left empty purely by removals, is now already empty in the source and kept, contributing its terminator (3, T3-3). -* **T2.7-3 Props.** Repeated props (defined or unknown) fail with 14.17; unknown props fail with 14.17; a spread attribute (`{...expr}`) on `<S>`/`<Spec>` fails with 14.17 (2.7); braced string values fail with 14.17 for each of the three string props — `id={"login"}`, `coverage={"none"}`, `tags={"a"}`; quoted (`d="x"`) or valueless `d` fails with 14.17; braced `d` holding a non-reference expression (e.g. a number, an object literal) fails with 14.8. Positive quoting arm (2.7: single- or double-quoted alike): `id='login'`, `coverage='none'`, and `tags='a b'` build byte-identically in outputs to their double-quoted variants. +* **T2.7-3 Props.** Repeated props (defined or unknown) fail with 14.17; unknown props fail with 14.17; a spread attribute (`{...expr}`) on `<S>`/`<Spec>` fails with 14.17 (2.7); braced string values fail with 14.17 for each of the three string props — `id={"login"}`, `coverage={"none"}`, `tags={"a"}`; valueless (bare-name) values fail with 14.17 for each of the three — `<S id>`, `<S id="x" coverage>`, `<S id="x" tags>` (2.7: any value form but the quoted static string), the `<S id>` arm reporting 14.17 and no 14.1 (14.1: a value not in quoted static-string form is condition 17, never condition 1 — a product reporting `<S id>` as `missing-id` fails; its masking of the children: T1.3-6; the view side of the same bare-name forms: T11.2-2, T11.4-3); quoted (`d="x"`) or valueless `d` fails with 14.17; braced `d` holding a non-reference expression (e.g. a number, an object literal) fails with 14.8. Positive quoting arm (2.7: single- or double-quoted alike): `id='login'`, `coverage='none'`, and `tags='a b'` build byte-identically in outputs to their double-quoted variants. ## 3. Markdown Compilation @@ -144,7 +144,7 @@ Consumer programs in this section are compiled and run under standard TypeScript * **T4-1 Header.** Each generated module begins with a header identifying it as generated by xspec from its source file (asserted as: header present, mentions xspec and the source path — wording free). * **T4-2 TS import rules.** In code-group files, each fails with 14.15: importing a `.xspec` specifier that designates no discovered spec source — one arm an existing file that is not a discovered spec source, one a nonexistent file; a bare and an absolute specifier ending `.xspec` (the specifier form rule of 2.1 applies in TS, 4); binding anything other than the (optionally aliased) default and `text` exports; a dynamic `import()` with a static `.xspec` specifier; an export declaration whose module specifier ends in `.xspec` — one arm each for `export * from`, `export * as NS from`, `export { … } from`, and a type-only form (`export type { … } from`), all 14.15 (4: no re-export carries a spec module past 4.5); an `import X = require("./NAME.xspec")` declaration; and, for each module-linking form 4 names — an import declaration, an export declaration, an `import X = require(…)`, and a static-specifier dynamic `import()` — a relative specifier designating a derived-file path other than through `.xspec` (`./NAME.xspec.ts` directly; a path under `.xspec/`; a configured Markdown emit destination — the last only while emission is enabled, 7.3). A dynamic `import()` with a non-static specifier is not analyzed: build succeeds, no edges recorded. Duplicate bindings (14.15 applies in either kind of file): an import binding an identifier already bound by another import in the same code-group file fails when either import is a spec module import — spec colliding with spec, and spec colliding with non-spec in both orders; two colliding non-spec imports are not an xspec finding (neither is a spec module import; the unused colliding bindings trigger no xspec error). * **T4-3 Aliased bindings.** `import SPEC, { text as t } from "./NAME.xspec"` works: `t(SPEC.a)` returns text and records the edge. -* **T4-4 Type-only imports.** A type-only spec module import — a `type` modifier on the declaration (`import type SPEC from "./NAME.xspec"`) and, separately, on a named binding (`import { type text as t } from "./NAME.xspec"`) — is valid (4). Bindings introduced type-only are type-level names: a marker-shaped expression statement `SPEC.a` and a call `t(SPEC.a)` rooted at them — in the named-binding arm `SPEC` is itself bound type-only, by a second declaration `import type SPEC from "./NAME.xspec"` beside the `{ type text as t }` one (two declarations of one module binding distinct identifiers, valid under 4), so callee and argument are both rooted at type-only bindings — record no edge (`query edges` reports none from the file) and trigger no xspec finding — not 14.8 and not 14.18: such value-level uses fall under no condition (4.5, 14.18) — and `build`/`check` exit 0, the workspace staying valid; the consumer-side TypeScript error is outside xspec's validations (as in T6.4-5). Control: the same statements under ordinary bindings record their `references`/`embeds` edges. The mixed combination — an ordinary `SPEC` node as the argument of a type-only `t` — is staged in neither arm and asserted nowhere: 4.5 pins the outcome of chains rooted at type-only bindings and states none for an ordinary node passed to such a callee. +* **T4-4 Type-only imports.** A type-only spec module import — a `type` modifier on the declaration (`import type SPEC from "./NAME.xspec"`) and, separately, on a named binding (`import { type text as t } from "./NAME.xspec"`) — is valid (4). Bindings introduced type-only are type-level names: a marker-shaped expression statement `SPEC.a` and a call `t(SPEC.a)` rooted at them — in the named-binding arm `SPEC` is itself bound type-only, by a second declaration `import type SPEC from "./NAME.xspec"` beside the `{ type text as t }` one (two declarations of one module binding distinct identifiers, valid under 4), so callee and argument are both rooted at type-only bindings — record no edge (`query edges` reports none from the file) and trigger no xspec finding — not 14.8 and not 14.18: such value-level uses fall under no condition (4.5, 14.18) — and `build`/`check` exit 0, the workspace staying valid; the consumer-side TypeScript error is outside xspec's validations (as in T6.4-5). Control: the same statements under ordinary bindings record their `references`/`embeds` edges. The mixed combination — an ordinary `SPEC` node as the argument of a type-only `t` — is staged in neither arm and asserted nowhere: an ambiguity in SPEC.md 4.5, not a harness gap — 4.5 pins the outcome of chains rooted at type-only bindings and states none for an ordinary node passed to such a callee (14.18's "any other function", or the consumer type error 4.5 places outside xspec's validations) — so this document asserts nothing there rather than pin an outcome SPEC.md does not. ### 4.1 Node skeleton @@ -251,14 +251,14 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T6.3-1 Config at ref.** A baseline where the configuration had different group membership: baseline graph reflects the old configuration (a file added to a group since then is absent from the baseline side). * **T6.3-2 Absent journal.** A baseline predating the journal file resolves normally (an absent journal reads as empty, and an empty journal is a prefix of every journal); a current workspace whose journal file is absent while the baseline's journal is non-empty reads as empty on the current side and fails as a prefix violation (T6.3-4). * **T6.3-3 Replay and composition.** Rename a→b, commit, rename b→c: impact with the older baseline maps a→c through composed entries and reports no changes. -* **T6.3-4 Failures.** Each fails at a baseline-taking command with an actionable error naming the offending entries or files, as a usage error (exit 2, 12.0): a baseline journal that is not a prefix of the current journal (simulate by rewriting history/journal in the fixture); a replay that cannot resolve a mapping — a garbage line appended to the current journal after the baseline commit (shape-independent staging, H-4), run at `impact --base` and at `review create --base`, the error naming the offending entries and `review create` modifying nothing (10.7); a baseline whose sources fail parse/validation; an unresolvable ref. Precedence arm (12.0: baseline resolution precedes source validation): `impact --base <unresolvable-ref>` and `review create --base <unresolvable-ref>` on a workspace whose current sources also fail build validation each exit 2 with the baseline error, report no validation findings, and modify nothing — a product that validates the current sources before resolving the baseline exits 1 there; the resolvable-ref counterpart over invalid sources is T13.3-3's refresh failure (exit 1). +* **T6.3-4 Failures.** Each fails at a baseline-taking command with an actionable error naming the offending entries or files, as a usage error (exit 2, 12.0): a baseline journal that is not a prefix of the current journal (simulate by rewriting history/journal in the fixture); a replay that cannot resolve a mapping — a garbage line appended to the current journal after the baseline commit (shape-independent staging, H-4), run at `impact --base` and at `review create --base`, the error naming the offending entries and `review create` modifying nothing (10.7); a baseline whose sources fail parse/validation; an unresolvable ref. 6.3's remaining replay failure, an *ambiguous* mapping, admits no discriminating staging (recorded here as T6.5-6 records its unstageable clauses): entry content is opaque (6.1, H-4), so no fixture can compose entries known to replay ambiguously rather than unresolvably, and the two share one observable outcome — exit 2, the error naming the offending entries — which the garbage-line arm asserts. Precedence arm (12.0: baseline resolution precedes source validation): `impact --base <unresolvable-ref>` and `review create --base <unresolvable-ref>` on a workspace whose current sources also fail build validation each exit 2 with the baseline error, report no validation findings, and modify nothing — a product that validates the current sources before resolving the baseline exits 1 there; the resolvable-ref counterpart over invalid sources is T13.3-3's refresh failure (exit 1). ### 6.4 Rename * **T6.4-1 Rewrites.** Renaming a mid-tree ID rewrites: its `id`, all descendant `id`s by prefix replacement, local string references, external chain references in other files, `text(...)` targets in MDX and TS, and TS markers — workspace builds and all edges retarget (query-asserted); mapping appended to journal. The command's own report is the applied mapping — every identity pair the operation journaled, the information of the preview's `mapping` (6.4) — carried in JSON per 12.0 (H-3 adapter; the successful operation's report shape is unpinned). * **T6.4-2 Minimal edits.** Quote style (single vs double) and access form (dot vs computed) of untouched reference parts are preserved byte-wise; only the affected parts change. The rewritten segment itself keeps every keepable form (6.4: the fallback spellings apply only where a form cannot be kept), byte-asserted in MDX and TS spellings alike: a double-quoted computed segment renamed to an identifier-valid name stays computed and double-quoted (`BASE["login-v2"]` → `BASE["login2"]`, never `BASE.login2`); a single-quoted computed segment keeps its single quotes whether its new name is identifier-valid (`BASE['login-v2']` → `BASE['login2']`) or not (→ `BASE['login-v3']`); a dot-access segment renamed to an identifier-valid name stays dot access; a single-quoted local string reference whose own segment is rewritten — `{text('login-v2')}` and a `d` array's `'login-v2'` entry — keeps its single quotes (T6.5-7 pins the same for a move's prefix re-identification). Where the form cannot be kept: a dot-access segment whose new name is not a TS identifier is written as double-quoted computed access; a reference converted between local and imported form (6.5) uses dot access for identifier-valid segments, double-quoted computed access for the others, and double-quoted string literals. A product normalizing every touched segment to dot access or double quotes fails the keepable-form arms. Whole-file byte contract: each rewritten file, `.mdx` and `.ts` alike — T6.4-1's marker and `text(...)`-call rewrites in code included — is asserted byte-equal to an expected file differing from the original in the rewritten segments alone (6.4: minimal in-place edits bind code sources as they bind MDX), failing a product that reprints a code file through a printer on rename. The `id`-attribute rewrites fall under the same contract, quotes included: the fixture spells the renamed section's own `id` and one rewritten descendant's `id` single-quoted (2.7 admits `id='login'`, T2.7-3), and the expected bytes keep those quotes around the new values (6.4: rewrites are minimal in-place edits — the double-quoted fallback applies only where a form cannot be kept, and the fixture's new IDs contain no quote character) — a product re-emitting every rewritten `id` attribute double-quoted, or rewriting the whole attribute as `id="…"`, fails. -* **T6.4-3 Validation refusals (exit 1).** New ID invalid (1.4); equal to old; colliding with an existing ID; violating structural parent rules. Each refusal modifies nothing (workspace byte-compare). The remaining 6.4 clause — all rewritten references resolve — admits no discriminating fixture: rename rewrites only valid workspaces (T6.4-6) and retargets every affected reference to identities that exist after the operation, so a non-resolving rewritten reference is unconstructible; the clause is exercised as the always-passing side of every successful rename (T6.4-1). -* **T6.4-4 Usage errors (exit 2).** Nonexistent `<file>`; nonexistent old ID; a discovered code source as `<file>` — a wrong-kind operand, judged like existence before any content question (6.4). Checked before source validation: same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering); but an old ID inside an unparseable origin file is masked — validation findings reported, exit 1. Old-ID existence is parse-local over spelled identities (6.4, 11.2): renaming an ID two sections both spell is no usage error — the bearers establish existence, their undefined node identities notwithstanding, and the duplicate-ID finding refuses instead (exit 1, the invalid-workspace refusal, T14-7); so is renaming an ID whose sole bearer spells it beneath an ancestor spelling no identity (an undefined ancestor chain — 6.4's second parenthesized mechanism, 11.2): the bearer establishes existence and the ancestor's finding refuses (exit 1, never exit 2); an old ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent — exit 2 even beside that file's findings. +* **T6.4-3 Validation refusals (exit 1).** New ID invalid (1.4); equal to old; colliding with an existing ID — one arm a single colliding bearer, one two: rename `a`→`b` in a file holding `a` with child `a.c` beside `b` with child `b.c`, so the new ID and an ID its prefix replacement produces (`b.c`) each collide (6.4: each ID the prefix replacement produces), the one `refused-id-collision` finding locating both bearers, `b` and `b.c` (14, T14-7: every colliding bearer — a product locating the first alone fails); violating structural parent rules. Each refusal modifies nothing (workspace byte-compare). The remaining 6.4 clause — all rewritten references resolve — admits no discriminating fixture: rename rewrites only valid workspaces (T6.4-6) and retargets every affected reference to identities that exist after the operation, so a non-resolving rewritten reference is unconstructible; the clause is exercised as the always-passing side of every successful rename (T6.4-1). +* **T6.4-4 Usage errors (exit 2).** Nonexistent `<file>` — one arm a path with no file on disk, one an `.mdx` file present on disk, holding a section spelling the old ID, but matched by no spec group (12.0: a file named in an argument exists as a member of the discovered set, T11.4-2's operand rule — a product probing the filesystem instead finds the file and proceeds); nonexistent old ID; a discovered code source as `<file>` — a wrong-kind operand, judged like existence before any content question (6.4). Checked before source validation: same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering); but an old ID inside an unparseable origin file is masked — validation findings reported, exit 1. Old-ID existence is parse-local over spelled identities (6.4, 11.2): renaming an ID two sections both spell is no usage error — the bearers establish existence, their undefined node identities notwithstanding, and the duplicate-ID finding refuses instead (exit 1, the invalid-workspace refusal, T14-7); so is renaming an ID whose sole bearer spells it beneath an ancestor spelling no identity (an undefined ancestor chain — 6.4's second parenthesized mechanism, 11.2): the bearer establishes existence and the ancestor's finding refuses (exit 1, never exit 2); an old ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent — exit 2 even beside that file's findings. * **T6.4-5 Type-level references.** A `typeof`-level reference to the old identity is not rewritten; the workspace stays xspec-valid (the consumer type error is outside xspec's validations — `build` and `check` report no finding for it). Move arm (6.4: a rename or move alike can leave type-level references naming vacated identities): the same `typeof` reference to a node then moved by a section-form move into another file is likewise left byte-unchanged — naming the vacated identity — the workspace valid, and the move's applied mapping and journal entry the same as without the reference. * **T6.4-6 Valid-workspace precondition.** With a pre-existing validation error elsewhere, rename refuses (exit 1) before modifying anything. * **T6.4-7 Finishing regeneration.** After a successful rename, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the rewritten sources; `check` immediately after reports no staleness (14.10). @@ -269,7 +269,7 @@ Occurrences are observed through `xspec occurrences` (11.3) and per-file views ( * **T6.5-2 Section form text edits.** Byte-exact fixtures: moved text spans opening tag's first character through closing tag's last; origin deletion drops lines left empty/whitespace-only (rule of 3); insertion immediately before the target parent's closing tag (or end of file for top-level `new-id`), followed by U+000A and preceded by one when not at line start; target file created when absent; no other byte changes beyond these edits, the identity and reference rewrites (their bytes: T6.5-7, T6.5-8), and the finishing regeneration (6.5). Self-closing arms (1.1, 6.5), byte-exact: moving a self-closing section moves exactly the self-closing tag's own characters; a self-closing target parent is first rewritten to paired form — its `/` and any whitespace immediately before or after the `/` deleted, the closing tag matching the opening tag's name appended immediately after the tag's terminating `>` — and the insertion rule then applies before that closing tag: a `<Spec id="p" />` parent becomes `<Spec id="p">` + U+000A + the moved text + U+000A + `</Spec>`. * **T6.5-3 Re-identification and reference conversion.** Subtree re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers (the code-file freshness observation: T6.5-9; the moved text's own imported-form references to a third module, whose bindings travel with it: T6.5-10) and unneeded ones removed — removal is exact: an import is removed only when its binding had references and the rewrite leaves it with none, so an import whose binding was already unreferenced before the move stays (6.5, 2.1); rewritten content is byte-deterministic (two identical fixtures produce identical bytes); the full mapping is appended to the journal (6.5: both forms); finishing regeneration as T6.4-7 — after the section-form move, generated modules, Markdown output, and graph data are byte-identical to a fresh `build` of the moved sources and `check` reports no staleness. Third-file arm (6.5: all references across the workspace): a spec source that is neither origin nor target, importing the origin module and referencing a moved node through it (a `d` chain), has that reference rewritten to the target module under an import of it added there (bytes per T6.5-8's discipline), the origin import removed when the moved reference was its binding's last and kept when another reference through it remains (one arm each); `query edges` reports the third file's edge under the moved node's new identity and `check` is clean. * **T6.5-4 Refusals (exit 1, nothing modified).** A move creating a spec import cycle; creating a dependency cycle; file form whose destination exists — occupied by a plain file, by a directory, by a symbolic link, and by a broken symbolic link (target absent), one arm each, each refused `refused-destination-exists` (6.5: whatever kind of filesystem object occupies it, a symbolic link included; the directory arm discriminates a product probing for a file alone; the broken-link arm discriminates a product probing existence through link-following stat, which sees it absent and proceeds); section form whose target path is occupied by anything other than a discovered spec source — neither an insertion target nor an absent path to create, refused `refused-destination-exists` (6.5, T14-7) — one arm each: a directory; a symbolic link resolving to a discovered spec source (the link-following discriminator: discovery never yields a symlink, 7 — a product resolving the target path through the filesystem finds a spec source there and inserts through the link); and an existing `.mdx` file outside every configured spec group (present, right extension, still no discovered spec source) — an arm asserting two findings, one per reason (14, T14-7): `refused-invalid-destination` (a path in no spec group) is applicable alongside `refused-destination-exists` in every staging of a plain-`.mdx` occupant, since an in-group, non-derived-excluded `.mdx` plain file is always discovered (7, 13.4) — the insertion target, no occupant refusal — and an unparseable discovered occupant is the invalid-workspace precondition's case; the directory and symlink arms, occupant-blocked at otherwise-valid in-group paths, stage singly; section form whose `<new-id>` is not in intrinsic ID form (the mirrored "new ID is valid" check, 6.5; 14: one or more segments joined by `.`, each satisfying 1.4) — a forbidden name (`then`), a whitespace-bearing segment, and the empty `<new-id>` (destination operand `b.mdx#`: one `#`, so the 12.0 split is well-formed and the id part has zero segments), one arm each, each refused `refused-invalid-id` — the empty-id arm discriminating a product that generalizes 11.3's `--to` spelling rule (where the same spelling exits 2 as malformed, T11.3-3) to move operands; section form whose `<new-id>` collides with an ID in a distinct target file — the ordinary cross-file collision, `a.mdx#x` → `b.mdx#y` with a section `y` already present in `b.mdx` (the same-file variant and its after-the-removal qualifier: T6.5-6); section form whose target parent is missing; whose target parent lies within the moved subtree; destination path in no configured spec group; in a code group as well; lacking `.mdx`; and the derived-path arm of `refused-invalid-destination` (6.5: a workspace-relative directory component of a derived path the destination would generate — 13.1, 13.2, 7.3 — occupied by a non-directory): with emission enabled under `markdown.outDir`, a file-form move whose destination `new/b.mdx` is otherwise valid, its own directory components unobstructed (`new/` absent — a nonexistent component is never a refusal cause, 13.4, T13.4-8), but whose emit destination `<outDir>/new/b.md` has its component `<outDir>/new` occupied by a plain file lying under no current source's write path (so the workspace passes `build`'s validations) — refused `refused-invalid-destination`, never 14.22 (14, T14-7), discriminating a product that vets only the destination path's own components; the module and companion paths share the destination's directory (13.1), so the `outDir` emit destination is the separable derived-path fixture. The symbolic-link arms of the same clause (6.5: a component occupied by a symbolic link, whatever it targets — discovery never traverses one, 7, and writes never traverse or replace one, 13.4): a file-form move to `specs/sub/b.mdx` and a section-form move creating the target file `specs/sub/new.mdx`, where `specs/sub` is a symbolic link to a real, empty directory — each form staged with the link targeting a directory inside the workspace and, separately, one outside the workspace root — are refused `refused-invalid-destination`, never 14.22 (14, T14-7), exit 1, nothing modified: the link itself and its target directory are byte-identical afterward, no file written through the link inside or outside the workspace; and a sibling of the derived-path arm stages `<outDir>/new` as such a link instead of a plain file, refused identically. These are the discriminating cases for a product vetting components through link-following `stat`, which sees a directory at the link, proceeds, and writes the moved file — and its regenerated derived files — through the link, possibly outside the workspace; T13.4-6's symlink arm covers only 14.22 on `build` writes, never this refusal. The link lies under no current source's write path and is never a source (7), so the workspace passes `build`'s validations. The 6.5 destination clauses "containing `#`" and "not valid UTF-8" admit no staging (dead letters, recorded here as T6.5-6 records its unstageable clauses): a destination path exists only as an operand spelling, and every spelling that would present either is a usage error before any refusal is evaluated — a non-UTF-8 argument value exits 2 (12.0, T12.0-5), so a valid operand never denotes a non-UTF-8 path; a `#`-containing file-form destination classifies as a `<file>#<id>` pair, a mixed-synopsis exit 2 (T6.5-5); and a `#` in the section form's target-file part makes a two-`#` operand, the malformed value T12.0-13 pins as exit 2 on `move`. Plus the valid-workspace precondition as T6.4-6. -* **T6.5-5 Usage errors (exit 2).** Nonexistent origin file or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y`, `a.mdx#x b.mdx`, and `a.mdx b#c.mdx` — the last a `#`-containing file-form destination, classified as a pair — match neither form, exit 2, and an operand containing `#` is always a `<file>#<id>` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). A non-UTF-8 destination operand (raw bytes in the OS argument vector, Linux leg) is a usage-error argument value, exit 2 (12.0, T12.0-5). These exit-2 invocations are the stagings T6.5-4's dead-letter note sets aside. +* **T6.5-5 Usage errors (exit 2).** Nonexistent origin file — in each form, both of T6.4-4's spellings: absent on disk, and present but undiscovered — or origin ID; a wrong-kind (code-source) origin in each form (6.5: both forms' origin operands name discovered spec sources); ordering, masking, and parse-local ID existence as T6.4-4. Operand classification is by spelling alone (6.5): the mixed-synopsis invocations `a.mdx b.mdx#y`, `a.mdx#x b.mdx`, and `a.mdx b#c.mdx` — the last a `#`-containing file-form destination, classified as a pair — match neither form, exit 2, and an operand containing `#` is always a `<file>#<id>` pair under the 12.0 split, so the file form cannot spell a `#`-containing path (a harmless limit, such paths being invalid source paths, 14.19; T12.0-13). A non-UTF-8 destination operand (raw bytes in the OS argument vector, Linux leg) is a usage-error argument value, exit 2 (12.0, T12.0-5). These exit-2 invocations are the stagings T6.5-4's dead-letter note sets aside. * **T6.5-6 Identity terms.** The new-identity checks read in identity terms (6.5): a cross-file section move keeping its ID (`a.mdx#x` → `b.mdx#x`, no `x` in `b.mdx`) is valid; the exact self-move — `<target-file>#<new-id>` equal to `<file>#<id>` — is refused (exit 1), modifies nothing, and appends no journal entry (journal byte-compared around the attempt); a same-file move whose `<new-id>` collides with an ID remaining in the target file after the removal is refused. The collision clause's after-the-removal qualifier admits no discriminating fixture: structural IDs (1.3) make the vacated set exactly the moved subtree's IDs, so a `<new-id>` matching only vacated identities is always independently refused — as the exact self-move, or because its target parent is missing or lies within the moved subtree (T6.5-4). The mirrored "all rewritten references resolve" clause is likewise unstageable, for T6.4-3's reason. So is the mirrored "structural parent rules remain satisfied" check, in both forms: the file form changes no ID and no within-file nesting; in the section form the target parent is located as the target file's section bearing `<new-id>` minus its final segment — absent, or lying within the moved subtree, it is `refused-missing-target-parent` (T6.5-4) — prefix replacement preserves the subtree's relative nesting, a single-segment `<new-id>` inserts at top level (exactly one segment, 1.3), and the origin's removal disturbs no remaining ID; on a workspace passing the valid-workspace precondition, 1.3 therefore holds by construction after every non-refused move, the check is exercised as the always-passing side of every successful move (T6.5-1/2/3), and `refused-structural-parent` is staged through rename alone (T6.4-3, T14-7). * **T6.5-7 Operation-side rewrite bytes.** The real move's import-edit extents and reference-conversion spellings, byte-asserted against independently composed expected files — staged so no import is added, the one rewrite direction free of implementation latitude (6.5: identifier choice and insertion offset attach to added imports alone; the addition side's line discipline: T6.5-8). Fixture: the origin file imports the target file under two bindings (valid, 2.1), one declaration alone on its line, the other following a retained, still-referenced third-module import on a shared line; every reference through the two bindings — a `d` chain and a `text(...)` embedding — lies inside the moved subtree, which also holds a single-quoted local string reference to a moved descendant and spells that descendant's `id` attribute single-quoted (2.7); no reference to a moved node lies outside the subtree. After the section move into the target file: both target-file imports are left unreferenced and removed with 6.5's exact extent — the own-line declaration's line dropped with its terminator (3), the shared-line declaration's own characters alone deleted, the retained import kept byte-for-byte on its kept line; the moved references convert to local form in 6.4's pinned spelling for converted references, double-quoted string literals; the local reference and the single-quoted `id` attribute are each re-identified by prefix replacement with their single-quote spellings preserved (6.4: minimal in-place edits — binding the `id`-attribute rewrite as they bind references, T6.4-2); and the rewritten origin and target files are each asserted byte-equal to expected bytes composed from the rules of 6.5 and 3. This is the operation-side assertion T6.5-2 sets aside (its no-other-byte-changes check excludes the identity and reference rewrites) and T6.6-4 makes only of the preview's report — failing a product that emits a spec-perfect preview while the real edit leaves an emptied import line behind, normalizes whitespace around a removed declaration, or spells a converted reference single-quoted. The code-source counterpart, fully composed (no import added, so no latitude): a `.ts` file importing the origin module, the target module, and a retained third module — the third module's declaration and the origin's sharing one line in a second variant, the origin's following it — whose only references through the origin binding are markers on nodes of the moved subtree, beside a marker through the target binding and one through the third; after the section move, the origin-module import — its binding left without references — is removed with 6.5's exact extent (the own-line declaration's line dropped with its terminator; the shared-line declaration's own characters alone deleted, the retained declaration kept byte-for-byte on its kept line), the moved markers are rewritten through the existing target binding (6.5: an import is added only where the file lacks the binding), and the file is asserted byte-equal to expected bytes composed from the rules of 6.4/6.5 and 3 — the import-removal rule binds code sources as it binds MDX, and a product that reprints a code file on removal fails here while passing every resolution-only assertion (T6.5-1, T6.2-2). The addition side in code is T6.5-8's TS arm. @@ -413,7 +413,7 @@ SPEC.md 11's five commands are JSON-only: for `query`, `occurrences`, `view`, `a Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are 11.3–11.5's, the availability rules this section's. * **T11.2-1 Parse-local structure, per-file masking, no writes.** Three spec files: A parseable with findings of both levels — an unresolved `d` reference and a self-cycle (resolution-level); a duplicate-ID pair, a malformed segment, an unknown prop, an invalid construct (per-file structural) — B unparseable, C finding-free. `view` over all three: A's full positional tree, construct ranges, raw attribute spellings, comment ranges, and occurrence positions are all served — structure survives A's own findings and B's invalidity; B contributes no view, its parse-failure finding accompanying; C's view is complete. The workspace fails `build`, so the gated reads report findings without answering (T13.3-3) while these surfaces answer per file — and modify nothing: graph data and derived files byte-identical around each invocation (11.2; the passing-workspace counterpart participates in refresh, T13.3-2). -* **T11.2-2 Spelled identities and interpreted data.** One file, each node's identity datum asserted via `view`: exactly one quoted static `id` → defined; a repeated `id` (values agreeing, and disagreeing — one arm each), a braced `id={"x"}`, a valueless `id`, and no `id` → each spells none, identity explicitly unavailable; two sections both spelling `x` → both unavailable, no winner, while a uniquely spelled `x.y` beneath one of them keeps its defined identity (a defined identity without defined prefix identities); descendants of a no-identity or malformed-identity section are undefined by inheritance; a section uniquely spelling `z` stays defined beside another section's invalid-form `id` attributes (uniqueness compares spelled identities only — an invalid form contests nothing). Interpreted tags and coverage: absent props define the defaults (no tags, coverage-required); a repeated, malformed, or invalid-valued `tags`/`coverage` leaves the interpreted value unavailable, its raw spelling still listed (T11.4-3). +* **T11.2-2 Spelled identities and interpreted data.** One file, each node's identity datum asserted via `view`: exactly one quoted static `id` → defined; a repeated `id` (values agreeing, and disagreeing — one arm each), a braced `id={"x"}`, a valueless `id` (`<S id>`, T2.7-3's arm), and no `id` → each spells none, identity explicitly unavailable, the accompanying finding 14.17 for the three invalid forms — never 14.1 for the valueless one — and 14.1 for the absent one (T2.7-3, T1.3-1); two sections both spelling `x` → both unavailable, no winner, while a uniquely spelled `x.y` beneath one of them keeps its defined identity (a defined identity without defined prefix identities); descendants of a no-identity or malformed-identity section are undefined by inheritance; a section uniquely spelling `z` stays defined beside another section's invalid-form `id` attributes (uniqueness compares spelled identities only — an invalid form contests nothing). Interpreted tags and coverage: absent props define the defaults (no tags, coverage-required); a repeated, malformed, or invalid-valued `tags`/`coverage` leaves the interpreted value unavailable, its raw spelling still listed (T11.4-3). * **T11.2-3 Invalid paths.** (Linux leg) A discovered spec source `a#b.mdx` and a non-UTF-8-named one (14.19): every node identity in each — root included — is explicitly unavailable while tree, ranges, and attributes stay on view; the condition-19 finding accompanies every answer whose domain includes the file; no identity over the invalid path is ever emitted, the non-UTF-8 path itself presented in the marked byte form (12.0, T12.7-1). A code source with `#` in its path defines no identity for its whole-file location or any unit: its spellings still record occurrences, each record's `source` explicitly unavailable (5.7, T11.3-1). Root identity is defined exactly when the file's path is valid. * **T11.2-4 Resolution and expanded text.** Resolution turns on the referenced identity's own definedness: with duplicate spellings of `a` and a unique `a.b` beneath one bearer, a reference to `a.b` resolves and records its occurrence while a reference to `a` records none — ambiguous, every bearer undefined — reported by its finding's range, never as a record or an unavailable target. Source-side unavailability (5.7, 11.2): resolving spellings themselves live in undefined-identity sections — a `d` entry naming `a.b` on the other duplicate bearer of `a`, and a `{text("a.b")}` embedding inside a section spelling no identity (`id` absent) — and each still records its occurrence: the record carries `file`, its own `range`, `kind`, and `target` (`a.b`), with `source` exactly the unavailability marker — identity and range withheld together as one datum (12.7; enumerated so in T11.3-1), never a picked bearer's identity and never a dropped record — while the view still positions each enclosing construct, its identity unavailable (11.4, T11.2-2); the file's findings — the duplicate-`id` and missing-`id` conditions among them — accompany, exit 1. Expanded text via `view --text`: a chain A embeds B embeds C with an unresolved embedding in C → A's and B's own/subtree text unavailable (one unresolved spelling on the expansion path, or one embedding cycle — staged separately — poisons the whole value; partial expansion never occurs), sibling nodes with resolved expansions staying defined and byte-exact; removal classification is by syntactic form — after deleting an imported file, the importing file's text values are byte-identical to before (the import removed by form, its 14.15 finding notwithstanding), and a stray element (14.16) is content, preserved byte-for-byte in the enclosing text and located by its finding. * **T11.2-5 Domain, findings, exits.** `view` naming only C (T11.2-1's finding-free file) → finding-free, exit 0, while A and B stay invalid — the domain is the requested files; naming A → A's findings of both levels accompany, exit 1, the full answer still emitted (the document complete and parseable, H-5); a two-file cycle accompanies whole when either participant is in the domain (14.9). Any finding or explicitly-unavailable datum → exit 1 with the full answer; complete and finding-free → exit 0; argument checks precede answering — unknown `<file>`, wrong-kind `<file>`, invalid glob, malformed `--to`, out-of-range offset each exit 2 whatever findings the named files carry (per-surface arms in T11.3-2/3, T11.4-2, T11.5-2). @@ -430,7 +430,7 @@ Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are * **T11.4-1 Views and tree.** With neither operands nor `--file`, every discovered spec source is viewed, multi-file order by path bytes, one JSON document; per parseable file: the root and the full positional section tree in document order — a section nested inside an invalid non-section element parents to the innermost enclosing section construct (the enclosure 11.2's chain conditions read), the root when none encloses it; per node, its construct range and the decomposition: opening and closing tag ranges for paired sections, opening only for self-closing, neither for the root — byte-asserted against precomputed offsets (1.7). List orders (12.7): on a file holding several import declarations, several MDX comments, and several reference occurrences, `imports`, `comments`, and `occurrences` are each in document order, form-exact (H-3) — asserted here because P-12 sorts the view's occurrences itself and cannot see a misordered list. * **T11.4-2 Operands vs restriction.** `<file>` operands assert membership: an undiscovered file → exit 2 (unknown); a discovered code source → exit 2 (wrong-kind operand, 12.0); `--file` restricts the domain: a glob matching nothing, or only code sources, admits the empty set — empty, finding-free answer, exit 0; combining `<file>` operands with `--file` → exit 2; the requested files form a set (a file named twice yields one view). -* **T11.4-3 Attributes and per-node data.** Raw attribute spellings as parsed, one entry per spelled attribute in tag order — a repeated `id` (both entries), an unknown prop, a spread attribute (`name` structurally absent, its text the whole braced construct), a valueless prop (bare name) — each with range and source text; inclusion is by form, the invalidity a located finding beside the view, never an omission (14.17). Per-node `identity`, `tags`, `coverage` each plain or explicitly unavailable per T11.2-2; a root's `tags` and `coverage` are structurally absent — the stated `null`, never the unavailability marker, no finding and no exit-1 consequence: a finding-free file's view exits 0 with them `null` (11.4, 12.7). +* **T11.4-3 Attributes and per-node data.** Raw attribute spellings as parsed, one entry per spelled attribute in tag order — a repeated `id` (both entries), an unknown prop, a spread attribute (`name` structurally absent, its text the whole braced construct), a valueless prop (bare name: `tags`, staged as T2.7-3's `<S id="x" tags>` so build and view share one fixture, its 14.17 finding beside the view the condition the build reports) — each with range and source text; inclusion is by form, the invalidity a located finding beside the view, never an omission (14.17). Per-node `identity`, `tags`, `coverage` each plain or explicitly unavailable per T11.2-2; a root's `tags` and `coverage` are structurally absent — the stated `null`, never the unavailability marker, no finding and no exit-1 consequence: a finding-free file's view exits 0 with them `null` (11.4, 12.7). * **T11.4-4 Imports.** Every import declaration, valid and invalid, with its range; its binding name — the default binding's identifier; structurally absent for the side-effect-only, named-only, and namespace-only forms (never "unavailable"; a named-clause identifier is not this datum) — and its resolved target where specifier form and discovery define one, explicitly unavailable otherwise (`./typo.xspec`; a bare specifier), the invalidity a located 14.15 finding beside it (11.4). * **T11.4-5 `--text` and the expansion domain.** With `--text`, each node carries own and subtree text per T11.2-4. The consulted domain: requesting only A, whose embeddings reach B and C transitively — B's and C's findings accompany (a deep unresolved spelling's or cycle's finding lies in a consulted file never requested); a non-occurrence-recording spelling is the expansion's boundary — no further file is consulted, the blocking finding lying in a file already consulted; a masked file is never consulted by expansion (no spelling resolves into it), its parse-failure finding accompanying only when itself requested; an unparseable requested file contributes no view; an invalid-path requested file keeps its view (T11.2-3). Without `--text`, requesting A consults A alone: B's findings absent, the exit following A's own findings. * **T11.4-6 Byte classification.** On a finding-free file with imports, sections, tags, comments, and embeddings: from the view alone — tag ranges, attribute ranges, import ranges, comment ranges, embedding-occurrence container spans (5.7) — the harness classifies every byte as annotation or content and reproduces the compiled Markdown through the rules of 3, byte-equal to the emitted output (the P-2 oracle applied to view data). On an imperfect file, jointly with the findings: an invalid construct (no view entry) and a no-occurrence embedding spelling are located by their findings' ranges — the embedding form's finding spanning its full braced container (14, T14-8) — so view plus findings again position every removable construct. @@ -461,7 +461,7 @@ Tests here drive `occurrences`, `view`, and `at`; the per-surface contracts are * **T12.0-7 Determinism.** Representative sweep: `build` outputs, generated files, graph data, Markdown, journal entries, session files, and every report are byte-identical across repeated runs and across content-identical workspaces at different absolute paths (no wall-clock, randomness, absolute paths, or environment leakage; run with differing irrelevant environment variables). * **T12.0-8 Shortest-path tie-break.** Where one shortest path is reported (coverage 8.2, impact 9.3, reachable 11), among equal-length candidates the element-wise byte-least node-identity sequence is reported (dedicated fixtures per command). * **T12.0-9 Exit-code partition.** A table-driven sweep asserting one representative per class per command family: 0 (success and informational reports: `ids`, `show`, `impact` with differences, `query`, review reads including fully-resolved `next`, `coverage` without `--check`, `version`, and complete finding-free answers — `occurrences`/`view`/`at` over a clean domain, `inventory`, a successful preview; 11.2, 11.6, 6.6); 1 (findings: failing `build`, `check` findings, `coverage --check` uncovered, refused `rename`/`move` and their refused previews, refused review operations, corrupt-session reports, and answers carrying findings or explicitly-unavailable data — emitted in full; 11.2, 11.6, 6.6); 2 (usage/configuration: unknown command; unknown flag; missing required flag/argument; invalid flag value; unknown profile/session/group/item/node/file — except `occurrences --to`, where only a malformed spelling is a usage error, T11.3-3; wrong-kind operands — a code source where a spec source or a requirement-node identity is required; invalid session name; configuration errors; unreadable baseline; mutual-exclusion refusal). -* **T12.0-10 Argument-check precedence.** Rename/move and baseline arms: T6.4-4/T6.5-5 (existence, kind, and masking) and T6.3-4. Gated reads (12.0): on one workspace failing `build`'s validations, each gated read given a usage-error argument exits 2 with that error and reports no validation findings — `coverage <unknown-profile>`; `query nodes --group <code-group>`; `review status <unknown-session>`; `show <file>#<unspelled-id>`, `query node <code-source-path>`, and `query edges --from <code-source-path>#<unspelled-unit>` (T11-6) — each check judged from what it consults (configuration; the session directory; parse-local spelled identities or named units of the named file, 11.2, 4.6), with the same names on a valid twin workspace giving the same exit-2 errors. Masking: `show <unparseable-file>#<id>` on the failing workspace → the gated report, exit 1 (as T6.4-4). Past the gate: on a passing workspace, `review resolve <corrupt-session> <any-item-id> --status updated` reports the corruption, exit 1 — the item ID judged only against session content, which the corruption withholds (10.1; an unknown item ID in a well-formed session stays exit 2, T10.7-10). Within class 2: an unknown command, a repeated flag, and a malformed value (`show a#b#c`, the multi-`#` spelling of T12.0-13) are reported without loading configuration — identically with the workspace's configuration file invalid or missing — while a configuration error precedes every check that consults configuration or discovery: `coverage <unknown-profile>` with invalid configuration reports 14.14, not the unknown profile (12.0). +* **T12.0-10 Argument-check precedence.** Rename/move and baseline arms: T6.4-4/T6.5-5 (existence, kind, and masking) and T6.3-4. Gated reads (12.0): on one workspace failing `build`'s validations, each gated read given a usage-error argument exits 2 with that error and reports no validation findings — `coverage <unknown-profile>`; `query nodes --group <code-group>`; `review status <unknown-session>`; `show <file>#<unspelled-id>`, `query node <code-source-path>`, and `query edges --from <code-source-path>#<unspelled-unit>` (T11-6) — each check judged from what it consults (configuration; the session directory; parse-local spelled identities or named units of the named file, 11.2, 4.6), with the same names on a valid twin workspace giving the same exit-2 errors. Masking: `show <unparseable-file>#<id>` on the failing workspace → the gated report, exit 1 (as T6.4-4). Past the gate: on a passing workspace, `review resolve <corrupt-session> <any-item-id> --status updated` reports the corruption, exit 1 — the item ID judged only against session content, which the corruption withholds (10.1; an unknown item ID in a well-formed session stays exit 2, T10.7-10). Within class 2: an unknown command, a repeated flag, a missing required flag or argument (`review create --name n` with none of `--base`, `--strategy audit`, or `--coverage`, 10.7; `at` given `<file>` alone, 11.5), and a malformed value (`show a#b#c`, the multi-`#` spelling of T12.0-13) are reported without loading configuration — identically with the workspace's configuration file invalid or missing — while a configuration error precedes every check that consults configuration or discovery: `coverage <unknown-profile>` with invalid configuration reports 14.14, not the unknown profile (12.0). * **T12.0-11 Git is read-only.** SPEC.md's preamble: git data is read only where explicitly stated and never written. On a freshly built git fixture, around each git-reading invocation — `impact --base`, `review create --base`, and `review status`/`next`/`resolve` on the resulting baseline session (whose generator runs reconstruct the recorded baseline, 6.3/10.4) — everything under `.git/` is byte-identical before and after (same file set, same bytes: refs, HEAD, index, and objects untouched), and no workspace file changes except those the command's own specification writes (the session file; none for `impact`). * **T12.0-12 Git-less operation.** The non-baseline surface — `build`, `check`, `ids`, `show`, `coverage`, `query`, `occurrences`, `view`, `at`, `inventory`, `version`, `rename`, `move` (their `--preview` invocations included), and `review` with the `audit` and `coverage` strategies through `create`/`list`/`status`/`next`/`show`/`split`/`resolve`/`export` — runs to its specified outcomes in a workspace that is not a git repository and has no enclosing repository. Only baseline-taking invocations (`impact --base`, `review create --base`, later commands on a baseline session) require git; T10.6-1's git-less audit is one instance of this sweep. * **T12.0-13 `#` in operands.** More than one `#` in a `<node>`, `<graph-node>`, `--to`, or move-operand value (`a#b#c`) is a malformed value — exit 2 on `show`, `query node`, `occurrences --to`, and `move` (12.0). A bare `<file>` operand or `--file` glob is a whole path or pattern with no delimiter role for `#`: with a discovered source `specs/a#b.mdx` staged (Linux leg; condition 19), `view specs/a#b.mdx` names that discovered file — membership holds, the view served with identities unavailable, exit 1 (T11.2-3), never a `specs/a` + `b.mdx` pair (which would be exit 2, unknown file); `at specs/a#b.mdx 0` resolves the same way, and `occurrences --file 'specs/a#*'` matches it as a pattern. @@ -538,7 +538,7 @@ Assertions here — and wherever these forms appear across the suite — are for All mutual-exclusion tests use the `--test-hold <path>` seam for determinism. -* **T13.5-1 Hold seam basics.** A mutating command (`rename`, `move`, `review create/resolve/split`) with `--test-hold`: creates an empty file at the path after acquiring exclusivity and before modifying anything (workspace byte-identical while held), proceeds only once the file is deleted, then completes normally. Seam neutrality (13.5: the seam changes no other behavior): the final workspace state of a held-then-released run — sources, journal, sessions, derived files, and graph data — is byte-identical to the same operation run without `--test-hold` on an identical twin workspace (the hold path outside the workspace; a product-to-itself comparison under H-4, well-defined across directories per H-6). If anything exists at the hold path — a file, directory, or symbolic link — the command fails exit 2 without modifying anything. A non-mutating command (`build` and `query` as representatives) given `--test-hold` fails exit 2 as an unknown flag — an interpretive pin, recorded as such (as T10.1-1 and T13.4-1 record theirs): 13.5 grants the seam to mutating commands alone, so on every other command the flag is one the command does not accept — an unknown flag, a usage error (12.0); 12.0's listing of `--test-hold` beside `--config` fixes only how its value resolves, not which commands take it, and 6.6 states the `--preview` case outright only because `rename` and `move` do accept the flag. SPEC.md states no acceptance of the flag by a non-mutating command, so a product accepting and ignoring it fails this arm by intent. +* **T13.5-1 Hold seam basics.** A mutating command (`rename`, `move`, `review create/resolve/split`) with `--test-hold`: creates an empty file at the path after acquiring exclusivity and before modifying anything (workspace byte-identical while held — staged on a freshly built workspace, as T10.1-1, so no refresh is pending), proceeds only once the file is deleted, then completes normally. Stale-workspace arm (13.5: the hold precedes every modification, the 13.3 refresh included): on a workspace whose graph data is stale — a section's text edited after `build`, the workspace still valid — `review create --strategy audit --test-hold`: while held, graph data, and every other workspace file, is byte-identical to its pre-invocation state; after release the session is created and the graph data refreshed (T10.1-1) — discriminating a product that refreshes before acquiring exclusivity. Seam neutrality (13.5: the seam changes no other behavior): the final workspace state of a held-then-released run — sources, journal, sessions, derived files, and graph data — is byte-identical to the same operation run without `--test-hold` on an identical twin workspace (the hold path outside the workspace; a product-to-itself comparison under H-4, well-defined across directories per H-6). If anything exists at the hold path — a file, directory, or symbolic link — the command fails exit 2 without modifying anything. A non-mutating command (`build` and `query` as representatives) given `--test-hold` fails exit 2 as an unknown flag — an interpretive pin, recorded as such (as T10.1-1 and T13.4-1 record theirs): 13.5 grants the seam to mutating commands alone, so on every other command the flag is one the command does not accept — an unknown flag, a usage error (12.0); 12.0's listing of `--test-hold` beside `--config` fixes only how its value resolves, not which commands take it, and 6.6 states the `--preview` case outright only because `rename` and `move` do accept the flag. SPEC.md states no acceptance of the flag by a non-mutating command, so a product accepting and ignoring it fails this arm by intent. * **T13.5-2 Mutual exclusion.** While command 1 is held, each other mutating command fails promptly with exit 2 and modifies nothing (journal, sessions, sources byte-identical); after command 1 completes, the second command succeeds. * **T13.5-3 Exclusivity ends with the process.** Kill a held mutating command; a subsequent mutating command succeeds (a terminated holder never blocks). * **T13.5-4 Readers during mutation.** While a mutating command is held, read commands still run and observe the prior state; non-mutating commands run concurrently with each other (parallel `build`/`query` storm on one workspace terminates, and any derived-file inconsistency is resolved by one final `build` — byte-equal to a clean build). @@ -556,7 +556,7 @@ Sections 1–13 exercise each numbered condition in its home context; this secti * **T14-4 Reporter matrix.** 14.10 and 14.12 reported by `check` only (a stale workspace `build`s successfully by regenerating; a policy-violating workspace `build`s successfully); 14.21 reported by `check`, by `review` subcommands naming the session, and by `review list` — not by `build`, and on a workspace failing `build`'s validations by `check` alone, beside the gate's findings (T10.1-5); 14.23 reported by `inventory` and `rename`/`move` previews only — `check` reports the state as 14.10's unit form, and `build` and the refreshing reads never do (the rebuild replaces the record; the reads leave it unconsulted, T13.3-2); 14.14 delivered as an exit-2 error by every command that loads configuration — never `version` (T12.6-2); 14.13 and 14.22 reported by both `build` and `check` and by the gated reads (T13.3-3), yet accompanying no `occurrences`/`view`/`at` answer — each is the finding of no domain file, the journal and a write-path component never being domain files (11.2, T11.2-6); every other condition reported by both `build` and `check`, and as a domain file's finding accompanying the answers of each of `occurrences`/`view`/`at` whose domain can hold its staged file: all three for a spec-source staging, `occurrences` alone for a code-source one — the only staging for 14.7, 14.11, and 14.18, which locate in code sources alone — `view`'s and `at`'s domains holding spec sources only (11.2, 11.3–11.5, T11.2-5). * **T14-5 Grammar selection.** A file matched by a code group and named `.tsx`, containing TSX-only syntax (not parseable as plain TypeScript — T14-3's construct) inside a named unit that also holds a dependency marker and a `text(...)` call: `build` succeeds — `.tsx` parses as TSX (14.20) — and the marker's `references` edge and the call's `embeds` edge are recorded and attributed to that unit per 4.6. The negative direction, the same TSX-only construct in a `.ts` file failing 14.20, is T14-3's; a further arm stages it in a code-group file of another name (`.mts`), failing 14.20 identically — any name but `.tsx` selects plain TypeScript (14.20), discriminating against products keying specifically on `.ts`. * **T14-6 Stable codes.** For each of the 23 conditions, staged via its primary test's fixture and read from its stated reporter (T14-4): the finding carries the exact token 14 lists (`missing-id` … `unreadable-record`) as its `code` in the JSON report form, where 12.7 pins it — the value is the token string alone, the ordinal numeral no part of it — so a product omitting or misspelling a code fails even where exit class and located information are right. A plain usage error and a review-operation refusal carry no stable code — `code` `null` (14, T12.7-1/3). -* **T14-7 Refusal reasons.** Staged refusals asserting each stable code with its concerned file, range, or identity (14): `refused-invalid-id` (concerning the invalid identity; intrinsic form only — a structurally misplaced but intrinsically valid new ID reports `refused-structural-parent` alone, never both); `refused-identity-unchanged`, reported alone by an identity-unchanged rename — no collision reason beside it (6.4: the after-removal check collides with nothing); `refused-id-collision`, locating every colliding bearer; `refused-structural-parent` (the violated identity); `refused-cycle`, locating the would-be cycle's full path; `refused-destination-exists` (the occupied path; the section form's non-spec-source occupant included); `refused-missing-target-parent` (the target-parent identity); `refused-invalid-destination` (the destination path; the destination-side directory-component cases of 6.5 report this code, never 14.22 — a plain file staged as a destination directory component, in T6.5-4's derived-path arm as a directory component of the destination's `outDir` emit destination, and in T6.5-4's symbolic-link arms a link to a directory at a component of the destination path, of a created target file's path, and of the `outDir` emit destination — the link and its target byte-identical after each refusal). `refused-unresolvable-reference` admits no fixture (T6.4-3/T6.5-6) and is asserted only as the always-passing side of successful operations. Every applicable reason reports together, one finding per reason: a section move staged to both collide (`<new-id>` present in the target file) and create a dependency cycle reports both findings, never only the first. The invalid-workspace refusal reports the workspace's numbered findings alone — on a workspace failing validation, a rename staged to also collide reports the validation findings only, exit 1, no refusal reason evaluated or reported beside them (6.4, 14). +* **T14-7 Refusal reasons.** Staged refusals asserting each stable code with its concerned file, range, or identity (14): `refused-invalid-id` (concerning the invalid identity; intrinsic form only — a structurally misplaced but intrinsically valid new ID reports `refused-structural-parent` alone, never both); `refused-identity-unchanged`, reported alone by an identity-unchanged rename — no collision reason beside it (6.4: the after-removal check collides with nothing); `refused-id-collision`, locating every colliding bearer (two in T6.4-3's prefix-replacement arm, `b` and `b.c`); `refused-structural-parent` (the violated identity); `refused-cycle`, locating the would-be cycle's full path; `refused-destination-exists` (the occupied path; the section form's non-spec-source occupant included); `refused-missing-target-parent` (the target-parent identity); `refused-invalid-destination` (the destination path; the destination-side directory-component cases of 6.5 report this code, never 14.22 — a plain file staged as a destination directory component, in T6.5-4's derived-path arm as a directory component of the destination's `outDir` emit destination, and in T6.5-4's symbolic-link arms a link to a directory at a component of the destination path, of a created target file's path, and of the `outDir` emit destination — the link and its target byte-identical after each refusal). `refused-unresolvable-reference` admits no fixture (T6.4-3/T6.5-6) and is asserted only as the always-passing side of successful operations. Every applicable reason reports together, one finding per reason: a section move staged to both collide (`<new-id>` present in the target file) and create a dependency cycle reports both findings, never only the first. The invalid-workspace refusal reports the workspace's numbered findings alone — on a workspace failing validation, a rename staged to also collide reports the validation findings only, exit 1, no refusal reason evaluated or reported beside them (6.4, 14). * **T14-8 Location cardinality.** A condition several constructs jointly violate is one finding locating every participant, each in its containing file: a triple-duplicated ID → one condition-3 finding with three locations (one per bearer, no representative chosen); an import-binding collision → one condition-15 finding locating every colliding declaration; a cross-file dependency cycle → one condition-9 finding locating its full path, every participating reference spelling, and a spec import cycle every participating import declaration; a no-occurrence MDX embedding spelling → its condition-6 finding's range the full braced container — the span its occurrence would occupy (5.7), keeping T11.4-6's byte classification exact; a policy finding → `locations` `[]`, `path` `null`, its context identities alone (T12.7-1). Location order within a finding is file bytes, then start, then end (12.7). ## 15. Example @@ -589,10 +589,10 @@ Confidence that the harness itself is correct comes primarily from certification * **C-2 Fixture interface.** Fixtures are driven through the identical blackbox surfaces as the product (H-2): the runner takes an executable/workspace binding and nothing else, so certifying and testing use one code path. * **S-1 Traceability self-check.** The H-7 map is complete and well-formed (fails on unmapped H-7 keys or dangling references). * **S-2 Workspace builder.** The fixture builder writes exactly the declared bytes (round-trip check including CRLF/CR content, invalid-UTF-8 blobs, BOMs, symlinks, and git fixtures with scripted commits), with scale vectors at the suite's staged maxima — a document nested at least at P-8's giant-nesting floor and one at the largest document size the suite stages (deterministic fixtures and generator draws alike, 16), each read back byte-complete — so a truncating writer or recursion-limited serializer cannot silently stage shallower or smaller inputs than declared, P-8's floor going unmet while deterministic tests still pass against their equally-shrunken expectations (the input-side counterpart of S-8's answer-side capacity gate) — certification cannot exercise builder bugs that make fixtures diverge from their declarations. -* **S-3 Subprocess driver.** Captures exit codes and keeps stdout/stderr separated (verified against a known-behavior stand-in command); enforces per-test working directories; detects hangs via timeout and reports them as failures, not skips. -* **S-4 TypeScript tooling driver.** Detects a known type error, a known definition location, and a known hover text in a hand-written non-xspec fixture project, so section 4's consumer assertions cannot pass vacuously. -* **S-5 Output adapters.** Each adapter (H-3) rejects documents missing required information (fed synthetic wrong-shape documents) rather than defaulting. -* **S-6 Oracles.** The Markdown oracle (P-2), the glob/capture oracle (P-7), the coverage-reachability oracle (P-13), the section-move category oracle (P-5), and the baseline graph-diff oracle (P-6) pass their own fixed vector suites before being trusted by property tests, each derived from SPEC.md's worked material: 3, 7.5, and 15 respectively for the first three; 6.2's worked straddling-line case plus the clean-boundary and final-position cases of T6.2-3/T6.2-4 for the section-move oracle; 5.6's three worked examples plus the added/deleted convention of T5.6-6 for the graph-diff oracle. +* **S-3 Subprocess driver.** Captures exit codes and keeps stdout/stderr separated (verified against a known-behavior stand-in command); enforces per-test working directories; detects hangs via timeout and reports them as failures, not skips. Certification runs through this same driver (C-2), so a driver defect is a spurious verdict on every fixture, not a deviation any fixture can target. +* **S-4 TypeScript tooling driver.** Detects a known type error, a known definition location, and a known hover text in a hand-written non-xspec fixture project, so section 4's consumer assertions cannot pass vacuously. Certification supplies products, not consumer projects, and reaches the tooling only through whichever violators target section 4 (a selective set, CERTIFICATIONS.md); a driver blind to a diagnostic kind passes conformer and violator alike wherever no violator targets that kind, so each kind's detection is checked directly. +* **S-5 Output adapters.** Each adapter (H-3) rejects documents missing required information (fed synthetic wrong-shape documents) rather than defaulting. Every fixture emits conforming shapes (C-2 drives them as products), so an adapter defaulting absent information passes conformer and violator alike — the vacuous pass no fixture is defined to produce. +* **S-6 Oracles.** The Markdown oracle (P-2), the glob/capture oracle (P-7), the coverage-reachability oracle (P-13), the section-move category oracle (P-5), and the baseline graph-diff oracle (P-6) pass their own fixed vector suites before being trusted by property tests, each derived from SPEC.md's worked material: 3, 7.5, and 15 respectively for the first three; 6.2's worked straddling-line case plus the clean-boundary and final-position cases of T6.2-3/T6.2-4 for the section-move oracle; 5.6's three worked examples plus the added/deleted convention of T5.6-6 for the graph-diff oracle. An oracle defect makes a conformer fail spuriously — a spurious fail, which no violator can reveal (as S-8). * **S-7 Red-green sweep.** Against an empty stub product (every command exits with an unexpected code and no output), every product-facing test fails with a diagnosed assertion and the suite completes without harness errors (H-8). * **S-8 Answer-scale capacity.** The H-3/12.7 decoders and every answer-document walk the suite performs succeed, every datum evaluated without harness error, on synthetic conforming-form documents at the maximum answer scale H-11 obliges: the scale of the largest answers SPEC.md permits a conforming product over the inputs the suite stages (deterministic fixtures and generator draws, 16, alike) — expansion blowup included, a `view --text` answer multiplying embedded subtree text through each expansion level past its staged input's own size — never merely the staged inputs' size; among them a `view` document nested at least as deep as P-8's giant-nesting floor. Capture is gated at the same scale through S-3's stand-in mechanism: a stand-in command emitting the largest of these synthetic documents on standard output is driven through the H-2 capture path product invocations use, and the captured bytes MUST be complete and identical to what the stand-in emitted. So the capacity H-11 requires is gated and regression-guarded, capture through evaluation, before any product exists (H-8's ordering). Certification cannot exercise this class: a harness-side failure against a conforming answer is a spurious fail, not a vacuous pass or a missed deviation, so no CERTIFICATIONS.md fixture reaches it. From 587edcaee139a5dd81a1b5b664ceba6d57dcd57f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 01:32:01 +0000 Subject: [PATCH 179/226] =?UTF-8?q?sdg(phase-7):=20revisit=20CERTIFICATION?= =?UTF-8?q?S=20for=20the=20Phase=206=20TEST-SPEC=20changes=20=E2=80=94=20a?= =?UTF-8?q?dd=20VIOL-CORE-EARLYREFRESH,=20move=20T6.1-1=20to=20Exclusions,?= =?UTF-8?q?=20refresh=20VALID/AVAIL=20scopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/CERTIFICATIONS.md | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/specs/CERTIFICATIONS.md b/specs/CERTIFICATIONS.md index ea34956..96bc2ab 100644 --- a/specs/CERTIFICATIONS.md +++ b/specs/CERTIFICATIONS.md @@ -2,17 +2,17 @@ This document specifies the fixture products that certify selected tests of `specs/TEST-SPEC.md` under the certification protocol of TEST-SPEC.md §17 (C-1, C-2). A **conformer** conforms to `specs/SPEC.md` within its stated scope, with the simplest behavior that does so. A **violator** is its conformer with exactly one specified behavioral deviation. A test is **certified** when it passes against the conformer and fails against each violator that targets it. Fixtures are implemented as part of the test harness and are driven through the identical blackbox surfaces as the product (C-2: an executable/workspace binding and nothing else); this document describes them only in terms of SPEC.md's interfaces, contracts, seams, and observability features and prescribes no implementation details. -Selection is deliberately incomplete (PROCESS.md). A fixture exists here only where a vacuous pass is an elevated risk: negative and absence-of-effect tests whose staging, byte-compare, or form-decode wiring could silently miss the behavior under test, temporal behavior, tests routed through the `--test-hold` seam (SPEC.md 13.5), and the reachability of property tests' generated inputs. No Bug Report exists, so no fixture is justified empirically. Every test not named in an in-scope set below is deliberately uncertified (see Exclusions). There are no spec modules, so there are no module certification files. +Selection is deliberately incomplete (PROCESS.md). A fixture exists here only where a vacuous pass is an elevated risk: negative and absence-of-effect tests whose staging, byte-compare, or form-decode wiring could silently miss the behavior under test, temporal behavior, tests routed through the `--test-hold` seam (SPEC.md 13.5), and the reachability of property tests' generated inputs. Harness-side defects that make a conforming input or answer fail — builder, capture, decode, oracle, and driver defects — are spurious fails, not vacuous passes; TEST-SPEC.md gates them by self-test rather than certification (S-2, S-3, S-6, S-8), and no fixture here targets them. No Bug Report exists, so no fixture is justified empirically. Every test not named in an in-scope set below is deliberately uncertified (see Exclusions). There are no spec modules, so there are no module certification files. Each conformer entry states its **scope** — the SPEC.md behaviors and command surface it implements and the workspace shapes it accepts — and its **in-scope tests**: the named subset (C-1) the certification runner executes against the conformer and each of its violators. Every in-scope test passes against the conformer. Each violator entry states its scope (its conformer's), its single deviation, the tests it certifies, and its expected failures: exactly the certified tests fail against it, and every other in-scope test passes. Where TEST-SPEC.md leaves an in-scope test's fixture content open and an expected-failure set — or the conformer's ability to pass within scope — depends on the choice, the entry states that choice as a **staging constraint** — a condition certification imposes on the harness's fixture for the named test, binding alongside C-1. ## CONF-CORE — operational core: exclusion seam, journal, durable files, review reads -**Scope.** Workspaces with one configured spec group of `.mdx` sources without imports, embeddings, `d` props, or tags; no `code`, `markdown`, `coverage`, or `policy` keys; no git. Command surface: `build`; of 13.3's read commands, exactly `check` (no findings on valid state), `ids`, `show`, `query`, `coverage` (reporting zero profiles), the `review` read subcommands, and `impact --base` (without git, the exit-2 unreadable-baseline case of 6.3/12.0), each behaving per 12.0 over such workspaces — the 11.2 surfaces (`occurrences`, `view`, `at`) are outside this surface; `rename` and file-form `move` with journal append (6.1, 6.2); `review` with the `audit` strategy (10.6) through `create`, `resolve`, `split`, and the read subcommands, including read-time invalidation over the recorded state of 10.4. Staging constraints: every mutating command the in-scope 13.5 tests drive is drawn from this surface — `rename`, file-form `move` (never the section form), and the mutating `review` subcommands with `create` under `--strategy audit` (never `--base` or `--coverage`); every read command T13.4-5's byte-compares and T13.5-4's concurrent reads drive is drawn from the read surface enumerated above; and T13.5-2's modifies-nothing compare brackets the excluded command alone, its snapshot taken while command 1 is already held — the bracketing both VIOL-CORE-EARLYWRITE's and VIOL-CORE-CHATTYREADS's passing sides lean on. Contracts under certification: 6.1 journal form and write discipline, 10.4 read discipline, 13.4 durable-file protection, and 13.5 in full, `--test-hold` seam included. Content of derived files beyond path, byte-determinism, write discipline, and atomic visibility is out of scope. +**Scope.** Workspaces with one configured spec group of `.mdx` sources without imports, embeddings, `d` props, or tags; no `code`, `markdown`, `coverage`, or `policy` keys; no git. Command surface: `build`; of 13.3's read commands, exactly `check` (no findings on valid state), `ids`, `show`, `query`, `coverage` (reporting zero profiles), the `review` read subcommands, and `impact --base` (without git, the exit-2 unreadable-baseline case of 6.3/12.0), each behaving per 12.0 over such workspaces — the 11.2 surfaces (`occurrences`, `view`, `at`) are outside this surface; `rename` and file-form `move` with journal append (6.1, 6.2); `review` with the `audit` strategy (10.6) through `create`, `resolve`, `split`, and the read subcommands, including read-time invalidation over the recorded state of 10.4, and — on a workspace whose graph data is stale but whose sources are valid (a section's text edited after `build`) — the 13.3 refresh a mutating `review` subcommand performs after the hold and before its own writes, writing graph data byte-identical to what `build` writes (13.3, T10.1-1), as T13.5-1's stale-workspace arm observes it. Staging constraints: every mutating command the in-scope 13.5 tests drive is drawn from this surface — `rename`, file-form `move` (never the section form), and the mutating `review` subcommands with `create` under `--strategy audit` (never `--base` or `--coverage`); every read command T13.4-5's byte-compares and T13.5-4's concurrent reads drive is drawn from the read surface enumerated above; and T13.5-2's modifies-nothing compare brackets the excluded command alone, its snapshot taken while command 1 is already held — the bracketing both VIOL-CORE-EARLYWRITE's and VIOL-CORE-CHATTYREADS's passing sides lean on; and every other mutating command the in-scope tests start — T13.5-2's held and excluded commands, T13.5-3's killed and subsequent commands, T13.5-4's held mutator — starts on a freshly built workspace with no refresh pending, as T13.5-1's own basic arm stages itself (T10.1-1), T13.5-1's stale-workspace arm alone starting one on stale graph data — the freshness constraint VIOL-CORE-EARLYREFRESH's passing side leans on. Contracts under certification: 6.1 journal form and write discipline, 10.4 read discipline, 13.4 durable-file protection, and 13.5 in full, `--test-hold` seam included — its ordering before every modification, the 13.3 refresh included, and its refusal by non-mutating commands as an unknown flag (T13.5-1's interpretive pin). Content of derived files beyond path, byte-determinism, write discipline, and atomic visibility is out of scope. -**In-scope tests:** T6.1-1, T6.1-2, T10.4-5, T13.4-5, T13.5-1, T13.5-2, T13.5-3, T13.5-4, T13.5-5. +**In-scope tests:** T6.1-2, T10.4-5, T13.4-5, T13.5-1, T13.5-2, T13.5-3, T13.5-4, T13.5-5. -**Justification.** The in-scope 13.5 lock tests (T13.5-1–T13.5-4) route through the one seam SPEC.md declares, T13.5-5 through a concurrent polling reader, and all assert temporal behavior — contention, kill timing, write visibility — where a mis-choreographed harness passes against any product. T6.1-1, T13.4-5, and T10.4-5 are absence-of-effect assertions (byte-compares around commands), the suite's largest vacuous-pass surface: a mispositioned snapshot passes forever. +**Justification.** The in-scope 13.5 lock tests (T13.5-1–T13.5-4) route through the one seam SPEC.md declares, T13.5-5 through a concurrent polling reader, and all assert temporal behavior — contention, kill timing, write visibility — where a mis-choreographed harness passes against any product. T13.4-5 and T10.4-5 are absence-of-effect assertions (byte-compares around commands), the suite's largest vacuous-pass surface: a mispositioned snapshot passes forever. T13.5-1's stale-workspace arm is the seam's sharpest ordering case: the one file its pending refresh would touch is graph data, so a while-held snapshot scoped to sources and durable files passes the arm against every product, and no other in-scope failure isolates that omission. T6.1-1, in scope in this document's first pass, now sweeps every command surface and leaves it (see Exclusions). ### VIOL-CORE-NOLOCK @@ -26,7 +26,14 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command * **Scope:** CONF-CORE. * **Deviation:** A mutating command given `--test-hold` performs its workspace modifications before creating the hold file: it acquires exclusivity, completes the operation's writes (journal append included), then creates the hold file, waits for its deletion, and exits normally. An invocation without `--test-hold` creates no hold file (13.5) and degenerates to conforming behavior, where the deviation is unobservable. * **Certifies:** T13.5-1, T13.5-4. -* **Expected failures:** exactly T13.5-1 (the workspace is not byte-identical while held; modification precedes the hold) and T13.5-4 (read commands run while a command is held observe the operation's result, not the prior state). All other in-scope tests pass: exclusivity is still acquired first, so T13.5-2's excluded command still fails without modifying anything — under the scope's bracketing constraint (the compare's snapshot taken while command 1 is already held), this deviation's pre-hold writes stay outside the compare; a kill at the held point leaves the completed, consistent operation, and T13.5-3's subsequent mutating command — a staging constraint: one that succeeds whether or not the killed operation's writes landed, so not a retry of the same operation — still succeeds; writes remain atomic (T13.5-5); journal content, append-only form, and determinism are unchanged (T6.1-1, T6.1-2); durable-file and session-read discipline are unchanged (T13.4-5, T10.4-5). +* **Expected failures:** exactly T13.5-1 (the workspace is not byte-identical while held; modification precedes the hold — in the stale-workspace arm the refresh and the session write alike) and T13.5-4 (read commands run while a command is held observe the operation's result, not the prior state). All other in-scope tests pass: exclusivity is still acquired first, so T13.5-2's excluded command still fails without modifying anything — under the scope's bracketing constraint (the compare's snapshot taken while command 1 is already held), this deviation's pre-hold writes stay outside the compare; a kill at the held point leaves the completed, consistent operation, and T13.5-3's subsequent mutating command — a staging constraint: one that succeeds whether or not the killed operation's writes landed, so not a retry of the same operation — still succeeds; writes remain atomic (T13.5-5); journal content, append-only form, and determinism are unchanged (T6.1-2); durable-file and session-read discipline are unchanged (T13.4-5, T10.4-5). + +### VIOL-CORE-EARLYREFRESH + +* **Scope:** CONF-CORE. +* **Deviation:** The 13.3 refresh a mutating `review` subcommand performs on a stale workspace (T10.1-1) runs before workspace exclusivity is acquired — the refresh-before-dispatch of the read commands applied to the mutating subcommands too — so stale graph data is rewritten before the hold file is created. A single deviation: one ordering rule of 13.5 (the hold precedes every modification, the refresh included) broken for the refresh alone; the hold file is still created after exclusivity and before every other write — the session write, `rename`/`move`'s edits, journal appends, and the finishing regeneration of 6.4/6.5 — and a workspace whose graph data is current is refreshed by nothing, where the deviation is unobservable. +* **Certifies:** T13.5-1. +* **Expected failures:** exactly T13.5-1 (its stale-workspace arm: while `review create --strategy audit --test-hold` is held, graph data already holds the refreshed content, not its pre-invocation bytes; the arm's post-release observations, the seam-neutrality compare — both twins refresh, to identical final states — and every other arm, staged on freshly built workspaces, are unmoved, so the test fails on the stale arm's while-held compare alone). All other in-scope tests pass under the scope's freshness constraint: no other in-scope mutating command starts with a refresh pending, so none refreshes at all — and one that did would write graph data alone, outside T13.5-2's journal, sessions, and sources compare and byte-identical to what T13.5-4's concurrent reads would themselves write (13.3, 12.0) — while T6.1-2, T10.4-5, T13.4-5, and T13.5-5 observe journal, session, and derived-file behavior under commands whose ordering the deviation leaves unchanged. ### VIOL-CORE-STALELOCK @@ -46,9 +53,9 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command * **Scope:** CONF-CORE. * **Deviation:** `build` and the read commands modify the journal: each such invocation that is not refused as a usage or configuration error (exit 2) appends one fixed line to `.xspec/journal`, creating the file when absent. The appended line is inert to the fixture's own journal handling: read back as neither an identity mapping (5.4) nor a malformed entry (no 14.13, so no later command is gated by it, 13.3), with graph data and every other derived byte independent of how many such lines the journal holds (13.4). Mutating commands, and the entries `rename`/`move` append, are unchanged. -* **Certifies:** T6.1-1, T13.4-5. -* **Expected failures:** exactly T6.1-1 (a journal file exists after `build` in a fresh workspace; the byte-compares around `build`, `check`, `coverage`, and `query` observe modification — in this git-less scope the `impact` invocation is refused exit 2 and appends nothing, and `review`'s compare observes modification only where the read subcommand chosen exits 0, as `review list` does) and T13.4-5 (its journal byte-compares under `build` and read commands fail). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched, and its review reads answer ungated — the inert lines meet no 14.13, so 13.3's gate never turns them into exit-1 non-answers; the 13.5 tests' compares are unmoved by the appends: T13.5-1's seam-neutrality compare includes the journal, but its held run and no-hold twin drive the same command sequence — the seam flag rides the mutating command, which this deviation leaves unchanged — so the appends are byte-identical on both sides; T13.5-4's final-`build` byte-equality to a clean build resolves derived-file inconsistency, a comparison of derived files (13.4) the inertness condition keeps independent of the two workspaces' differing appended-line counts; and T13.5-2's byte-compare covers the refused mutating command alone (the scope's bracketing constraint), which appends nothing. -* **Note:** the representative value of this violator for the suite's other never-modifies assertions (T12.0-11, T12.1-4, T13.3-3, T6.4-3, T6.5-4, T6.6-2, and the answer-side compares of T11.2-1/T11.2-6/T11.6-4) holds insofar as those assertions share the compare-around-command machinery certified here; the Exclusions lean on exactly that condition. +* **Certifies:** T13.4-5. +* **Expected failures:** exactly T13.4-5 (its journal byte-compares under `build` and the read commands fail — each compared invocation the scope's read surface answers exit 0 appends; in this git-less scope `impact --base` is refused exit 2 and appends nothing, which leaves `build` and the remaining reads to fail the compare; its session-file compares and its never-regenerated arm are untouched). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched, and its review reads answer ungated — the inert lines meet no 14.13, so 13.3's gate never turns them into exit-1 non-answers; the 13.5 tests' compares are unmoved by the appends: T13.5-1's seam-neutrality compare includes the journal, but its held run and no-hold twin drive the same command sequence — the seam flag rides the mutating command, which this deviation leaves unchanged — so the appends are byte-identical on both sides; T13.5-4's final-`build` byte-equality to a clean build resolves derived-file inconsistency, a comparison of derived files (13.4) the inertness condition keeps independent of the two workspaces' differing appended-line counts; and T13.5-2's byte-compare covers the refused mutating command alone (the scope's bracketing constraint), which appends nothing. +* **Note:** the representative value of this violator for the suite's other never-modifies assertions (T6.1-1's journal sweep above all — see Exclusions — and T12.0-11, T12.1-4, T13.3-3, T6.4-3, T6.5-4, T6.6-2, and the answer-side compares of T11.2-1/T11.2-6/T11.6-4) holds insofar as those assertions share the compare-around-command machinery certified here; the Exclusions lean on exactly that condition. ### VIOL-CORE-PERSISTREADS @@ -59,7 +66,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ## CONF-VALID — segment and tag validity -**Scope.** Workspaces with one configured spec group of one or more `.mdx` sources whose sections carry `id` and `tags` props (multi-file: T1.3-5's cross-file duplicate-ID arm builds); no imports, embeddings, `d` props, code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `build` with the error reporting of 14 for conditions 14.1–14.4 — and 14.17 as T1.3-6's invalid-form arms stage it — (file, location, condition identity with its stable code where the report form carries one — 14, 12.7 — 14.2's statement of the expected form, exit codes per 12.0) and `query node`/`query nodes` reporting identity, tags, and metadataHash — `nodes` with the `--tag` tag-filtered selection (11.1) T2.6-1 asserts through. Contracts under certification: 1.3, 1.4 (with the exact character classes of SPEC.md 1.4), 2.6 tag splitting, and the masking rules of 14.1 and 14.17 over 14.2. +**Scope.** Workspaces with one configured spec group of one or more `.mdx` sources whose sections carry `id` and `tags` props — values in either quote kind (2.7), as P-1's staging discipline spells them, the bearers nested as deeply as P-1's `.`-bearing draws stage them (T1.3-2..4) — (multi-file: T1.3-5's cross-file duplicate-ID arm builds); no imports, embeddings, `d` props, code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `build` with the error reporting of 14 for conditions 14.1–14.4 — and 14.17 as T1.3-6's invalid-form arms stage it: a repeated `id`, a braced value, and the valueless bare name (`<S id>`), each condition 17 and never condition 1, each masking condition 2 for the bearer's immediate children as an absent `id` does (14.1, 14.2) — (file, location, condition identity with its stable code where the report form carries one — 14, 12.7 — 14.2's statement of the expected form, exit codes per 12.0) and `query node`/`query nodes` reporting identity, tags, and metadataHash — `nodes` with the `--tag` tag-filtered selection (11.1) T2.6-1 asserts through. Contracts under certification: 1.3, 1.4 (with the exact character classes of SPEC.md 1.4), 2.6 tag splitting, and the masking rules of 14.1 and 14.17 over 14.2. **In-scope tests:** T1.3-1, T1.3-2, T1.3-3, T1.3-4, T1.3-5, T1.3-6, T1.4-1, T1.4-2, T1.4-4, T2.6-1, T2.6-2, P-1. @@ -132,7 +139,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ## CONF-AVAIL — availability answers and JSON datum forms -**Scope.** Workspaces of configured spec groups of `.mdx` sources at valid-UTF-8, `#`-free workspace-relative paths — imports (2.1), `d` props, and `{text(...)}` embeddings as the in-scope fixtures stage them; no code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `view`, with and without `--text` — the bare whole-domain form (neither operands nor `--file`: every discovered spec source viewed, 11.4, as T11.4-1 drives it) and the operand and `--file` forms as staged — and `occurrences` — the bare unrestricted form (no `--file`: the entire discovered set, 11.3, as T11.2-4 and T11.3-4's unrestricted arm drive it) and `--file` and `--to` as staged — each answering in the form-exact 12.7 document forms. A staging constraint: every command the in-scope tests drive is drawn from this enumerated surface — in particular, no in-scope staging drives `at` (the 11.2 preamble's third surface, not served by this conformer), and T11.2-4's occurrence-record observations ride `occurrences` and `view`. Contracts under certification: the availability rules of 11.2 — parse-local structure and positional trees (a section inside an invalid non-section element parenting per 11.4), spelled-identity definedness, interpreted tags and coverage, resolution through defined identities, whole-value expansion poisoning with own and subtree text defined through the rules of 3 (1.6; emission out of scope), and removal classification by syntactic form — occurrence records per 5.7/11.3 with `source` withheld as one datum where undefined, the `--file` domain restriction and `--to` selection of 11.3, the raw attribute and import data of 11.4, findings accompanying per 11.2/14 with stable codes and located ranges for the staged conditions (14.1, 14.3, 14.4, 14.5, 14.6, 14.9, 14.15, 14.16, 14.17), and the exit discipline of 11.2 (any finding or explicitly-unavailable datum → exit 1 with the full answer emitted; complete and finding-free → exit 0). Graph-data content and refresh behavior beyond the answers are out of scope: no in-scope test observes either. A staging constraint: T11.4-1's fixtures stage no undefined datum — every node identity defined under 11.2's chain conditions: over valid paths, each section and each enclosing section (the positional enclosure of 11.4) spells an identity, every spelled identity well-formed (1.4), structurally conformant (1.3), and spelled by no other section of its file — its invalid-element arm (14.16) keeping every spelled identity defined — so its answers carry the unavailability marker nowhere. +**Scope.** Workspaces of configured spec groups of `.mdx` sources at valid-UTF-8, `#`-free workspace-relative paths — imports (2.1), `d` props, `{text(...)}` embeddings, and MDX comments (2.7) as the in-scope fixtures stage them, several of each in one file where T11.4-1's list-order arm stages them; no code groups, `markdown`, `coverage`, `policy`, or git. Command surface: `view`, with and without `--text` — the bare whole-domain form (neither operands nor `--file`: every discovered spec source viewed, 11.4, as T11.4-1 drives it) and the operand and `--file` forms as staged — and `occurrences` — the bare unrestricted form (no `--file`: the entire discovered set, 11.3, as T11.2-4 and T11.3-4's unrestricted arm drive it) and `--file` and `--to` as staged — each answering in the form-exact 12.7 document forms. A staging constraint: every command the in-scope tests drive is drawn from this enumerated surface — in particular, no in-scope staging drives `at` (the 11.2 preamble's third surface, not served by this conformer), and T11.2-4's occurrence-record observations ride `occurrences` and `view`. Contracts under certification: the availability rules of 11.2 — parse-local structure and positional trees (a section inside an invalid non-section element parenting per 11.4), spelled-identity definedness, interpreted tags and coverage, resolution through defined identities, whole-value expansion poisoning with own and subtree text defined through the rules of 3 (1.6; emission out of scope), and removal classification by syntactic form — occurrence records per 5.7/11.3 with `source` withheld as one datum where undefined, the `--file` domain restriction and `--to` selection of 11.3, the raw attribute and import data of 11.4 — every attribute a tag spells listed by form, a valueless bare name (`<S id>`, `<S id="x" tags>`) included, its interpreted datum unavailable and its finding condition 17, never condition 1 (T11.2-2, T11.4-3) — the comment ranges of 11.4, with `imports`, `comments`, and `occurrences` each in document order (11.4, 12.7), findings accompanying per 11.2/14 with stable codes and located ranges for the staged conditions (14.1, 14.3, 14.4, 14.5, 14.6, 14.9, 14.15, 14.16, 14.17), and the exit discipline of 11.2 (any finding or explicitly-unavailable datum → exit 1 with the full answer emitted; complete and finding-free → exit 0). Graph-data content and refresh behavior beyond the answers are out of scope: no in-scope test observes either. A staging constraint: T11.4-1's fixtures stage no undefined datum — every node identity defined under 11.2's chain conditions: over valid paths, each section and each enclosing section (the positional enclosure of 11.4) spells an identity, every spelled identity well-formed (1.4), structurally conformant (1.3), and spelled by no other section of its file — its invalid-element arm (14.16) keeping every spelled identity defined — so its answers carry the unavailability marker nowhere. **In-scope tests:** T11.2-2, T11.2-4, T11.3-4, T11.4-1, T11.4-3, T11.4-4. @@ -167,12 +174,15 @@ Considered against the selection criteria and deliberately left uncertified; eac * **P-7**: its capture half requires policy machinery out of any lean scope; the glob half's staging hazard is certified through CONF-DISC on T7-4. * **P-8, P-9, P-10**: P-8 sweeps every command, exceeding any narrow conformer scope; P-9 asserts consistency invariants anchored by the deterministic 10.x fixtures; P-10's single-mutator schedules and kill accounting admit no deviation with an unambiguous expected-failure set — its reader half shares T13.5-5's polling machinery, certified via VIOL-CORE-PARTIALWRITE, and its seam choreography is certified via the CONF-CORE lock violators. * **P-11, P-12**: P-11's imperfect-input classes are broad basins under P-8's mutators — nearly any byte mutation of a source lands in some finding family, unlike the boundary code points P-1 and P-2 must weight their generators toward — each class anchored pointwise by the deterministic 11.2 fixtures; its datum-form discipline is certified deterministically through the CONF-AVAIL datum-form violators, its termination, exit, and complete-document clauses are loud (S-3 captures exits and hangs; a partial document fails its own parse), and a conformer admitting it would need occurrence analysis over fuzzed TypeScript — P-8's scope argument. P-12 enumerates every offset of every file — reachability is total by construction — and its comparator is computed from the product's own `view` answers, anchored by T11.5-1's precomputed fixture, so there is no independent oracle to mis-trust. +* **The scale-capacity class (T1.3-7, P-8's giant-nesting floor, H-11, S-2's scale vectors, S-8)**: TEST-SPEC.md places it outside certification by construction — a harness that cannot stage, capture, decode, or walk a conforming input or answer at the staged scale fails spuriously against the conformer, never vacuously against a violator (S-8; S-2 on the input side) — and T1.3-7's assertions are positive counts and trees that fail loud against a product that truncates or refuses; a fixture nested to the floor would only re-prove the builder's and decoders' capacity, which S-2 and S-8 gate before any product exists. +* **The rewrite byte contracts of 6.4/6.5 (T6.4-2's keepable-form and whole-file arms, T6.5-1's specifier-rewrite contract, T6.5-7, T6.5-8, T6.5-10)**: positive, byte-asserted against expected files composed from the rules of 6.4/6.5 and 3 — the diff-isolated added run of T6.5-8 and T6.5-10(a) included, which is absent altogether, and the assertion with it, against a product that inserts nothing or more than one run — so their failure modes are loud; a conformer passing them carries the identity-continuity engine whole (the P-5 argument above), and their one staging hazard, the builder's fidelity to declared bytes, is S-2's. * **T13.5-6, T13.5-7** (workspace isolation; interrupted mutation): squarely in criterion (a)'s temporal class, but P-10's rationale extends to both — neither admits a deviation with an unambiguous expected-failure set. Cross-workspace interference surfaces only when schedules overlap, and post-release kill damage lands nondeterministically; T13.5-7's operative assertion is disjunctive for exactly that reason (`check` passes or reports findings), so no single deviation fails it deterministically, and its held-point choreography is certified via the CONF-CORE lock violators. T13.5-6's isolation is additionally the harness's own H-1 obligation, exercised on every parallel run (E-3). -* **Section 4 consumer-side and type-level tests**: their vacuous-pass hazard lives in the TypeScript tooling driver, which S-4 self-tests against a known non-xspec fixture; a conformer would need the full generated-module contract (skeleton, branding, documentation, navigation). The TypeScript-side occurrence and range enumerations (T1.7-2, T5.7-1 through T5.7-4, T11.3-1) sit behind the same wall — a conformer serving them carries the full 4.5/4.6 unit analysis — and their two-sided enumeration equalities fail loud, the record form they decode certified through the CONF-AVAIL datum-form violators. +* **T6.1-1** (journal lifecycle): certified through CONF-CORE in this document's first pass, it now byte-compares the journal around every command surface — `occurrences`, `view`, `at`, `inventory`, `version`, and the previews of 6.6 among them — so a conformer passing it whole would serve all of 11 and 6.6 (P-8's scope argument), and it leaves CONF-CORE's in-scope set. Its never-modified compares are the compare-around machinery VIOL-CORE-CHATTYREADS certifies over the same journal on T13.4-5, whose read set the scope constrains; its lifecycle negative — no journal after `build` in a fresh workspace — is braced in-test by the positive at the same path (the file appears at `.xspec/journal` with the first `rename`/`move`), so a mislocated absence check fails loud rather than passing silently. +* **Section 4 consumer-side and type-level tests**: their vacuous-pass hazard lives in the TypeScript tooling driver, which S-4 self-tests against a known non-xspec fixture; a conformer would need the full generated-module contract (skeleton, branding, documentation, navigation). The TypeScript-side occurrence and range enumerations (T1.7-2, T5.7-1 through T5.7-4, T11.3-1) sit behind the same wall — a conformer serving them carries the full 4.5/4.6 unit analysis — and their two-sided enumeration equalities fail loud, the record form they decode certified through the CONF-AVAIL datum-form violators. T6.5-9's compile-clean observation rides the same driver — S-4 checks each diagnostic kind's detection directly, the import-conflicts-with-local-declaration kind this test turns on among them — beside positive `query edges` assertions; and T4-4's type-only arms and T4.5-4's callee-side arm pair each no-edge negative with an in-test positive control (the ordinary-binding statements recording their edges; the condition-18 finding, and the `embeds` occurrence listed outside the shadowing scope), so a product resolving by name fails the paired positive rather than passing silently. * **T10.1-4 session-corruption arms**: the per-arm precision hazard is real, but the recorded-creation-parameters arm requires a baseline or coverage session, dragging git or coverage machinery into an otherwise lean scope. T10.1-5's and T12.0-10's gate-precedence pairs carry their own discriminating contrasts in-test (the `check`-vs-subcommand and valid-twin comparisons). * **The review-operation refusal negatives (T10.3-2's re-block refusal; T10.7-9/10's refused `split` and `resolve`)**: refusals of 10.7 carry no stable code (14), so a wrong-reason refusal is in-test indistinguishable for any product and the error-identity representatives reach none of them; what discriminates instead is asserted beside each refusal — the same test flips one condition and the same subcommand succeeds (T10.3-2's dependent resolves once its blocker is re-resolved; T10.7-9 asserts the successful split beside its other-kind and childless-root refusals; T10.7-10 asserts `resolve` succeeding on any unblocked item beside its refused blocked one) — so a wrong-reason refusal fails its paired positive arm rather than passing silently, and a violator would carry the blocking, decomposition, and re-derivation machinery those tests assert around the refusals for one exit-code assertion already braced in-test. * **Previews and the unreadable-record cluster (T6.6-2 through T6.6-6, T6.5-7; 14.23 at T12.2-2, T13.3-2, T11.6-4)**: T6.6-2's modifies-nothing compare shares the compare-around machinery certified via VIOL-CORE-CHATTYREADS (its note's condition), and T6.6-3's runs-while-held arm shares the drive-during-hold choreography certified via the CONF-CORE lock violators (T13.5-2's refused second command and T13.5-4's concurrent reads are the same staging); T6.6-4 and T6.5-7 are positive edits byte-asserted against precomputed offsets and independently composed expected files (loud); and the shape-blind 14.23 stagings are self-controlled — each state's reachability is positively asserted in-test or by its sibling on the same staging (the condition-23 finding's presence, `inventory`'s recorded-unavailable report, `check`'s unit-form finding), so a staging accident fails loud rather than passing silently. * **`inventory` and `version` (T11.6-1 through T11.6-4, T12.6-1/2)**: the anchoring, resolved-configuration, derived-map, occupancy, and listing arms are positive and byte-asserted; T11.6-4's no-parse/no-write negatives ride the certified compare-around machinery, and every broken state it must ignore is positively reported from the same staging by its home reporter (T13.3-3, T10.1-4, T12.2-2); T12.6-2 carries its own discriminating pair — `build` exits 2 on the very fixture `version` must answer from. -* **The 12.7 form sweeps and the code contracts (T12.7-1 through T12.7-3, T14-6, T14-7, T14-8)**: one form contract is asserted literally wherever the documents appear (H-3), so the decode rigor the sweeps depend on is certified representatively through the CONF-AVAIL datum-form violators, where the marker and the stated `null` are densest; staging a fixture per surface and per condition would be completeness. The stable-code, refusal-reason, and location-cardinality assertions are positive identity checks that fail loud when a staged condition does not fire — the finding is then absent altogether, and the assertion with it. +* **The 12.7 form sweeps and the code contracts (T12.7-1 through T12.7-3, T14-6, T14-7, T14-8)**: 12.7's value forms are universal (H-3) — asserted literally on the pinned document forms and, through the value-blind H-3 decode, on the unpinned surfaces T12.7-1 sweeps (`query` rows, `show --json`, the review payloads) — so the decode rigor the sweeps depend on is certified representatively through the CONF-AVAIL datum-form violators, where the marker and the stated `null` are densest: the two states a defaulting decoder collapses. A range carried as `[start, end]` or `{"from", "to"}` has no such collapse to hide behind — it fails the literal `{"start", "end"}` assertion unless an adapter re-maps it, the harness discipline H-3 forbids and S-5's wrong-shape feed probes — and staging a fixture per surface and per condition would be completeness. The stable-code, refusal-reason, and location-cardinality assertions (T6.4-3's two-bearer collision arm among them) are positive identity checks that fail loud when a staged condition does not fire — the finding is then absent altogether, and the assertion with it. * **The single-casing probes and the Windows leg (the casing arms of T7-4, T10.1-2, T10.1-3, T12.0-6; E-6)**: their discrimination is the Windows leg's to carry by design — on Linux the masking is by construction — and the non-discovery and exit-2 wiring they ride is certified via VIOL-DISC-DIALECT and the error-identity representatives; a case-folding fixture would re-prove that wiring on another rule. -* **The remaining negative matrices and refusals (2.1 import negatives, 2.4, 2.7, 4.x imports and markers, 5.3 cycles, 6.1 journal integrity — T6.1-3, 6.3 baseline failures, 6.4/6.5 refusals — T6.5-6's refused self-move with its journal compare included, 7.x configuration validation, 10.1 session-name and non-session negatives, 12.0 usage errors, the argument, spelling, and domain-and-exit matrices of the machine-interface surfaces — T11.2-5, T11.3-2/3, T11.4-2, T11.5-2, T12.0-13, and T11-6's unknown-unit and `@N` arms, their answer-side decode rigor the CONF-AVAIL-certified machinery and their exit assertions S-3's — T11.4-5's consultation-domain negatives, the 13.4 symlink write refusals and their byte-compares — T13.4-6, the 14.19/14.20 path and encoding negatives — T1.5-2, T1.6-5, and T11.2-3, whose byte-level staging is exactly what S-2's builder round-trip self-tests, and the masking and reporter-matrix contracts of 14 — T14-3, T14-4)** and the remaining absence-of-effect sweeps (T12.0-11, T12.1-4, T12.2-3, T13.3-3, T13.4-4's nothing-written-through-the-link compare, and the answer-side no-write compares of T11.2-1/T11.2-6): they share the double-invalidity, error-identity and masking, and compare-around structures certified representatively through CONF-VALID, VIOL-CORE-CHATTYREADS, and the CONF-AVAIL datum-form violators — representative insofar as those tests share the certified machinery (the VIOL-CORE-CHATTYREADS note's condition); certifying each would be completeness, which this document must not pursue. +* **The remaining negative matrices and refusals (2.1 import negatives, 2.4, 2.7, 4.x imports and markers, 5.3 cycles, 6.1 journal integrity — T6.1-3, 6.3 baseline failures, 6.4/6.5 refusals — T6.5-6's refused self-move with its journal compare included, 7.x configuration validation, 10.1 session-name and non-session negatives, 12.0 usage errors, the argument, spelling, and domain-and-exit matrices of the machine-interface surfaces — T11.2-5, T11.3-2/3, T11.4-2, T11.5-2, T12.0-13, and T11-6's unknown-unit and `@N` arms, their answer-side decode rigor the CONF-AVAIL-certified machinery and their exit assertions S-3's — T11.4-5's consultation-domain negatives, the 13.4 symlink write refusals and their byte-compares — T13.4-6, and T6.5-4's symbolic-link component arms, whose link-and-target byte-compares are braced by the refusal's stable code and exit 1, which a product writing through the link fails first — the 14.19/14.20 path and encoding negatives — T1.5-2, T1.6-5, and T11.2-3, whose byte-level staging is exactly what S-2's builder round-trip self-tests, and the masking and reporter-matrix contracts of 14 — T14-3, T14-4)** and the remaining absence-of-effect sweeps (T12.0-11, T12.1-4, T12.2-3, T13.3-3, T13.4-4's nothing-written-through-the-link compare, and the answer-side no-write compares of T11.2-1/T11.2-6): they share the double-invalidity, error-identity and masking, and compare-around structures certified representatively through CONF-VALID, VIOL-CORE-CHATTYREADS, and the CONF-AVAIL datum-form violators — representative insofar as those tests share the certified machinery (the VIOL-CORE-CHATTYREADS note's condition); certifying each would be completeness, which this document must not pursue. From 2911c149cbf56fc1a657e0544c5801815189ec8b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 02:01:49 +0000 Subject: [PATCH 180/226] =?UTF-8?q?sdg(phase-7):=20CERTIFICATIONS=20revisi?= =?UTF-8?q?t=20round=201=20=E2=80=94=20configuration=20location=20in=20the?= =?UTF-8?q?=20preamble,=20CONF-DISC=20code-group=20exclusion=20arm,=20CHAT?= =?UTF-8?q?TYREADS=20staging=20constraints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: I1(b), I2, I3, O1, O2, O3, O4, O5. Rejected: I1(a) — T13.4-5 and T13.5-4 enumerate no read set (T6.1-1 does, and left the scope for exactly that reason), so fixing the read set to CONF-CORE's surface chooses within the tests' latitude rather than removing an arm; TEST-SPEC itself treats T13.4-5 as one side of the never-modified clause (T6.1-1's cross-reference) and scopes T11.2-1's compare to derived files, so no requirement loses coverage; widening CONF-CORE to the 11.2 surfaces and a git-served `impact` is the P-8 scope argument in reverse. The constraint's nature is now stated explicitly in the scope instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/CERTIFICATIONS.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/specs/CERTIFICATIONS.md b/specs/CERTIFICATIONS.md index 96bc2ab..950d8e8 100644 --- a/specs/CERTIFICATIONS.md +++ b/specs/CERTIFICATIONS.md @@ -4,15 +4,15 @@ This document specifies the fixture products that certify selected tests of `spe Selection is deliberately incomplete (PROCESS.md). A fixture exists here only where a vacuous pass is an elevated risk: negative and absence-of-effect tests whose staging, byte-compare, or form-decode wiring could silently miss the behavior under test, temporal behavior, tests routed through the `--test-hold` seam (SPEC.md 13.5), and the reachability of property tests' generated inputs. Harness-side defects that make a conforming input or answer fail — builder, capture, decode, oracle, and driver defects — are spurious fails, not vacuous passes; TEST-SPEC.md gates them by self-test rather than certification (S-2, S-3, S-6, S-8), and no fixture here targets them. No Bug Report exists, so no fixture is justified empirically. Every test not named in an in-scope set below is deliberately uncertified (see Exclusions). There are no spec modules, so there are no module certification files. -Each conformer entry states its **scope** — the SPEC.md behaviors and command surface it implements and the workspace shapes it accepts — and its **in-scope tests**: the named subset (C-1) the certification runner executes against the conformer and each of its violators. Every in-scope test passes against the conformer. Each violator entry states its scope (its conformer's), its single deviation, the tests it certifies, and its expected failures: exactly the certified tests fail against it, and every other in-scope test passes. Where TEST-SPEC.md leaves an in-scope test's fixture content open and an expected-failure set — or the conformer's ability to pass within scope — depends on the choice, the entry states that choice as a **staging constraint** — a condition certification imposes on the harness's fixture for the named test, binding alongside C-1. +Each conformer entry states its **scope** — the SPEC.md behaviors and command surface it implements and the workspace shapes it accepts — and its **in-scope tests**: the named subset (C-1) the certification runner executes against the conformer and each of its violators. Every in-scope test passes against the conformer. Each violator entry states its scope (its conformer's), its single deviation, the tests it certifies, and its expected failures: exactly the certified tests fail against it, and every other in-scope test passes. Where TEST-SPEC.md leaves an in-scope test's fixture content open and an expected-failure set — or the conformer's ability to pass within scope — depends on the choice, the entry states that choice as a **staging constraint** — a condition certification imposes on the harness's fixture for the named test, binding alongside C-1. Every conformer, whatever its scope, locates configuration as 7 states — by upward search for `xspec.config.ts` from the working directory, or at the path `--config <path>` names, resolved against the working directory (12.0) — and resolves configured paths and globs relative to the configuration file's directory, the workspace root; `--test-hold <path>` likewise resolves against the working directory (12.0). An in-scope test may therefore run from any working directory H-2 lets it choose — T7-4's path-resolution staging from a subdirectory of the workspace included — and no fixture fails a test on that choice. ## CONF-CORE — operational core: exclusion seam, journal, durable files, review reads -**Scope.** Workspaces with one configured spec group of `.mdx` sources without imports, embeddings, `d` props, or tags; no `code`, `markdown`, `coverage`, or `policy` keys; no git. Command surface: `build`; of 13.3's read commands, exactly `check` (no findings on valid state), `ids`, `show`, `query`, `coverage` (reporting zero profiles), the `review` read subcommands, and `impact --base` (without git, the exit-2 unreadable-baseline case of 6.3/12.0), each behaving per 12.0 over such workspaces — the 11.2 surfaces (`occurrences`, `view`, `at`) are outside this surface; `rename` and file-form `move` with journal append (6.1, 6.2); `review` with the `audit` strategy (10.6) through `create`, `resolve`, `split`, and the read subcommands, including read-time invalidation over the recorded state of 10.4, and — on a workspace whose graph data is stale but whose sources are valid (a section's text edited after `build`) — the 13.3 refresh a mutating `review` subcommand performs after the hold and before its own writes, writing graph data byte-identical to what `build` writes (13.3, T10.1-1), as T13.5-1's stale-workspace arm observes it. Staging constraints: every mutating command the in-scope 13.5 tests drive is drawn from this surface — `rename`, file-form `move` (never the section form), and the mutating `review` subcommands with `create` under `--strategy audit` (never `--base` or `--coverage`); every read command T13.4-5's byte-compares and T13.5-4's concurrent reads drive is drawn from the read surface enumerated above; and T13.5-2's modifies-nothing compare brackets the excluded command alone, its snapshot taken while command 1 is already held — the bracketing both VIOL-CORE-EARLYWRITE's and VIOL-CORE-CHATTYREADS's passing sides lean on; and every other mutating command the in-scope tests start — T13.5-2's held and excluded commands, T13.5-3's killed and subsequent commands, T13.5-4's held mutator — starts on a freshly built workspace with no refresh pending, as T13.5-1's own basic arm stages itself (T10.1-1), T13.5-1's stale-workspace arm alone starting one on stale graph data — the freshness constraint VIOL-CORE-EARLYREFRESH's passing side leans on. Contracts under certification: 6.1 journal form and write discipline, 10.4 read discipline, 13.4 durable-file protection, and 13.5 in full, `--test-hold` seam included — its ordering before every modification, the 13.3 refresh included, and its refusal by non-mutating commands as an unknown flag (T13.5-1's interpretive pin). Content of derived files beyond path, byte-determinism, write discipline, and atomic visibility is out of scope. +**Scope.** Workspaces with one configured spec group of `.mdx` sources without imports, embeddings, `d` props, or tags; no `code`, `markdown`, `coverage`, or `policy` keys; no git. Command surface: `build`; of 13.3's read commands, exactly `check` (no findings on valid state), `ids`, `show`, `query`, `coverage` (reporting zero profiles), the `review` read subcommands, and `impact --base` (without git, the exit-2 unreadable-baseline case of 6.3/12.0), each behaving per 12.0 over such workspaces — the 11.2 surfaces (`occurrences`, `view`, `at`) are outside this surface; `rename` and file-form `move` with journal append (6.1, 6.2); `review` with the `audit` strategy (10.6) through `create`, `resolve`, `split`, and the read subcommands, including read-time invalidation over the recorded state of 10.4, and — on a workspace whose graph data is stale but whose sources are valid (a section's text edited after `build`) — the 13.3 refresh a mutating `review` subcommand performs after the hold and before its own writes, writing graph data byte-identical to what `build` writes (13.3, T10.1-1), as T13.5-1's stale-workspace arm observes it. Staging constraints: every mutating command the in-scope 13.5 tests drive is drawn from this surface — `rename`, file-form `move` (never the section form), and the mutating `review` subcommands with `create` under `--strategy audit` (never `--base` or `--coverage`); every read command T13.4-5's byte-compares and T13.5-4's concurrent reads drive is drawn from the read surface enumerated above — a choice within the tests' latitude, neither enumerating its reads; the journal's sweep of every command surface is T6.1-1's (see Exclusions); and T13.5-2's modifies-nothing compare brackets the excluded command alone, its snapshot taken while command 1 is already held — the bracketing both VIOL-CORE-EARLYWRITE's and VIOL-CORE-CHATTYREADS's passing sides lean on; and every other mutating command the in-scope tests start — T13.5-2's held and excluded commands, T13.5-3's killed and subsequent commands, T13.5-4's held mutator — starts on a freshly built workspace with no refresh pending, as T13.5-1's own basic arm stages itself (T10.1-1), T13.5-1's stale-workspace arm alone starting one on stale graph data — the freshness constraint VIOL-CORE-EARLYREFRESH's passing side leans on. Contracts under certification: 6.1 journal form and write discipline, 10.4 read discipline, 13.4 durable-file protection, and 13.5 in full, `--test-hold` seam included — its ordering before every modification, the 13.3 refresh included, and its refusal by non-mutating commands as an unknown flag (T13.5-1's interpretive pin). Content of derived files beyond path, byte-determinism, write discipline, and atomic visibility is out of scope. **In-scope tests:** T6.1-2, T10.4-5, T13.4-5, T13.5-1, T13.5-2, T13.5-3, T13.5-4, T13.5-5. -**Justification.** The in-scope 13.5 lock tests (T13.5-1–T13.5-4) route through the one seam SPEC.md declares, T13.5-5 through a concurrent polling reader, and all assert temporal behavior — contention, kill timing, write visibility — where a mis-choreographed harness passes against any product. T13.4-5 and T10.4-5 are absence-of-effect assertions (byte-compares around commands), the suite's largest vacuous-pass surface: a mispositioned snapshot passes forever. T13.5-1's stale-workspace arm is the seam's sharpest ordering case: the one file its pending refresh would touch is graph data, so a while-held snapshot scoped to sources and durable files passes the arm against every product, and no other in-scope failure isolates that omission. T6.1-1, in scope in this document's first pass, now sweeps every command surface and leaves it (see Exclusions). +**Justification.** The in-scope 13.5 lock tests (T13.5-1–T13.5-4) route through the one seam SPEC.md declares, T13.5-5 through a concurrent polling reader, and all assert temporal behavior — contention, kill timing, write visibility — where a mis-choreographed harness passes against any product. T13.4-5 and T10.4-5 are absence-of-effect assertions (byte-compares around commands), the suite's largest vacuous-pass surface: a mispositioned snapshot passes forever. T13.5-1's stale-workspace arm is the seam's sharpest ordering case: the one file its pending refresh would touch is graph data, so a while-held snapshot scoped to sources and durable files passes the arm against every product, and no other in-scope failure isolates that omission. T6.1-1 sweeps every command surface and lies outside this scope (see Exclusions). ### VIOL-CORE-NOLOCK @@ -24,16 +24,16 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ### VIOL-CORE-EARLYWRITE * **Scope:** CONF-CORE. -* **Deviation:** A mutating command given `--test-hold` performs its workspace modifications before creating the hold file: it acquires exclusivity, completes the operation's writes (journal append included), then creates the hold file, waits for its deletion, and exits normally. An invocation without `--test-hold` creates no hold file (13.5) and degenerates to conforming behavior, where the deviation is unobservable. +* **Deviation:** A mutating command given `--test-hold` performs its workspace modifications before creating the hold file: it acquires exclusivity, completes the operation's writes (journal append included), then creates the hold file — creation failing on an occupied path as 13.5 states, the exit-2 refusal then following the completed writes — waits for its deletion, and exits normally. An invocation without `--test-hold` creates no hold file (13.5) and degenerates to conforming behavior, where the deviation is unobservable. * **Certifies:** T13.5-1, T13.5-4. -* **Expected failures:** exactly T13.5-1 (the workspace is not byte-identical while held; modification precedes the hold — in the stale-workspace arm the refresh and the session write alike) and T13.5-4 (read commands run while a command is held observe the operation's result, not the prior state). All other in-scope tests pass: exclusivity is still acquired first, so T13.5-2's excluded command still fails without modifying anything — under the scope's bracketing constraint (the compare's snapshot taken while command 1 is already held), this deviation's pre-hold writes stay outside the compare; a kill at the held point leaves the completed, consistent operation, and T13.5-3's subsequent mutating command — a staging constraint: one that succeeds whether or not the killed operation's writes landed, so not a retry of the same operation — still succeeds; writes remain atomic (T13.5-5); journal content, append-only form, and determinism are unchanged (T6.1-2); durable-file and session-read discipline are unchanged (T13.4-5, T10.4-5). +* **Expected failures:** exactly T13.5-1 (the workspace is not byte-identical while held; modification precedes the hold — in the stale-workspace arm the refresh and the session write alike — and its occupied-hold-path arm fails too: the operation's writes land before hold creation fails, so the exit-2 refusal leaves a modified workspace where the arm asserts nothing modified) and T13.5-4 (read commands run while a command is held observe the operation's result, not the prior state). All other in-scope tests pass: exclusivity is still acquired first, so T13.5-2's excluded command still fails without modifying anything — under the scope's bracketing constraint (the compare's snapshot taken while command 1 is already held), this deviation's pre-hold writes stay outside the compare; a kill at the held point leaves the completed, consistent operation, and T13.5-3's subsequent mutating command — a staging constraint: one that succeeds whether or not the killed operation's writes landed, so not a retry of the same operation — still succeeds; writes remain atomic (T13.5-5); journal content, append-only form, and determinism are unchanged (T6.1-2); durable-file and session-read discipline are unchanged (T13.4-5, T10.4-5). ### VIOL-CORE-EARLYREFRESH * **Scope:** CONF-CORE. -* **Deviation:** The 13.3 refresh a mutating `review` subcommand performs on a stale workspace (T10.1-1) runs before workspace exclusivity is acquired — the refresh-before-dispatch of the read commands applied to the mutating subcommands too — so stale graph data is rewritten before the hold file is created. A single deviation: one ordering rule of 13.5 (the hold precedes every modification, the refresh included) broken for the refresh alone; the hold file is still created after exclusivity and before every other write — the session write, `rename`/`move`'s edits, journal appends, and the finishing regeneration of 6.4/6.5 — and a workspace whose graph data is current is refreshed by nothing, where the deviation is unobservable. +* **Deviation:** The 13.3 refresh a mutating `review` subcommand performs on a stale workspace (T10.1-1) runs before workspace exclusivity is acquired, so stale graph data is rewritten before the hold file is created. A single deviation: one ordering rule of 13.5 (the hold precedes every modification, the refresh included) broken for the refresh alone; the hold file is still created after exclusivity and before every other write — the session write, `rename`/`move`'s edits, journal appends, and the finishing regeneration of 6.4/6.5 — and a workspace whose graph data is current is refreshed by nothing, where the deviation is unobservable. * **Certifies:** T13.5-1. -* **Expected failures:** exactly T13.5-1 (its stale-workspace arm: while `review create --strategy audit --test-hold` is held, graph data already holds the refreshed content, not its pre-invocation bytes; the arm's post-release observations, the seam-neutrality compare — both twins refresh, to identical final states — and every other arm, staged on freshly built workspaces, are unmoved, so the test fails on the stale arm's while-held compare alone). All other in-scope tests pass under the scope's freshness constraint: no other in-scope mutating command starts with a refresh pending, so none refreshes at all — and one that did would write graph data alone, outside T13.5-2's journal, sessions, and sources compare and byte-identical to what T13.5-4's concurrent reads would themselves write (13.3, 12.0) — while T6.1-2, T10.4-5, T13.4-5, and T13.5-5 observe journal, session, and derived-file behavior under commands whose ordering the deviation leaves unchanged. +* **Expected failures:** exactly T13.5-1 (its stale-workspace arm: while `review create --strategy audit --test-hold` is held, graph data already holds the refreshed content, not its pre-invocation bytes; the arm's post-release observations, the seam-neutrality compare — both twins refresh, to identical final states — and every other arm, staged on freshly built workspaces, are unmoved, so the test fails on the stale arm's while-held compare alone). All other in-scope tests pass under the scope's freshness constraint: no other in-scope mutating command starts with a refresh pending, so none refreshes at all — and one that did would write graph data alone, outside T13.5-2's journal, sessions, and sources compare and byte-identical to what T13.5-4's concurrent reads would themselves write (13.3, 12.0) — while T6.1-2, T10.4-5, T13.4-5, and T13.5-5 observe journal, session, and derived-file behavior where the ordering is unobservable: `build` and the reads refresh as the conformer does, and a mutating `review` subcommand driven on stale graph data — T10.4-5's `resolve`, when it follows the staleness-inducing edit before any read has refreshed — refreshes early but, with no seam and no concurrent command in play, writes exactly the bytes the conformer writes after its hold (13.3), to the same final state. ### VIOL-CORE-STALELOCK @@ -54,7 +54,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command * **Scope:** CONF-CORE. * **Deviation:** `build` and the read commands modify the journal: each such invocation that is not refused as a usage or configuration error (exit 2) appends one fixed line to `.xspec/journal`, creating the file when absent. The appended line is inert to the fixture's own journal handling: read back as neither an identity mapping (5.4) nor a malformed entry (no 14.13, so no later command is gated by it, 13.3), with graph data and every other derived byte independent of how many such lines the journal holds (13.4). Mutating commands, and the entries `rename`/`move` append, are unchanged. * **Certifies:** T13.4-5. -* **Expected failures:** exactly T13.4-5 (its journal byte-compares under `build` and the read commands fail — each compared invocation the scope's read surface answers exit 0 appends; in this git-less scope `impact --base` is refused exit 2 and appends nothing, which leaves `build` and the remaining reads to fail the compare; its session-file compares and its never-regenerated arm are untouched). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched, and its review reads answer ungated — the inert lines meet no 14.13, so 13.3's gate never turns them into exit-1 non-answers; the 13.5 tests' compares are unmoved by the appends: T13.5-1's seam-neutrality compare includes the journal, but its held run and no-hold twin drive the same command sequence — the seam flag rides the mutating command, which this deviation leaves unchanged — so the appends are byte-identical on both sides; T13.5-4's final-`build` byte-equality to a clean build resolves derived-file inconsistency, a comparison of derived files (13.4) the inertness condition keeps independent of the two workspaces' differing appended-line counts; and T13.5-2's byte-compare covers the refused mutating command alone (the scope's bracketing constraint), which appends nothing. +* **Expected failures:** exactly T13.4-5 (its journal byte-compares under `build` and the read commands fail — each compared invocation the scope's read surface answers exit 0 appends; in this git-less scope `impact --base` is refused exit 2 and appends nothing, which leaves `build` and the remaining reads to fail the compare; its session-file compares and its never-regenerated arm are untouched). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched, and its review reads answer ungated — the inert lines meet no 14.13, so 13.3's gate never turns them into exit-1 non-answers; the 13.5 tests' compares are unmoved by the appends under two staging constraints: T13.5-1's held run and its no-hold twin drive the same command sequence, and neither drives `build` or a read command between the mutating command's start and the final-state compare — the seam flag rides the mutating command, which this deviation leaves unchanged, so the appends are byte-identical on both sides, and no read while held breaks the while-held byte-identity or desynchronizes the twins' journals; and T13.5-4's final-`build` byte-equality to a clean build is a comparison of derived files (13.4) alone — the inconsistency it resolves is derived-file inconsistency — which the inertness condition keeps independent of the two workspaces' differing appended-line counts, where a whole-workspace compare would see the storm's many inert lines against the clean twin's one; T13.5-2's byte-compare covers the refused mutating command alone (the scope's bracketing constraint), which appends nothing. * **Note:** the representative value of this violator for the suite's other never-modifies assertions (T6.1-1's journal sweep above all — see Exclusions — and T12.0-11, T12.1-4, T13.3-3, T6.4-3, T6.5-4, T6.6-2, and the answer-side compares of T11.2-1/T11.2-6/T11.6-4) holds insofar as those assertions share the compare-around-command machinery certified here; the Exclusions lean on exactly that condition. ### VIOL-CORE-PERSISTREADS @@ -92,7 +92,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command **In-scope tests:** T3-1, T3-2, T3-3, T3-4, T3-5, T3-6, P-2, P-3. -**Justification.** The line-drop rule is the subtlest pure contract in SPEC.md, and its discriminating fixtures depend on exact exotic bytes (boundary code points, lone-CR terminators) that tooling silently normalizes — a corrupted fixture passes vacuously in both directions. P-2's oracle is trusted by the property suite (S-6 checks its vectors; certification checks that generated documents actually reach the discriminating classes and that the property fails when the product deviates). T3-6's negative half — no `.md` emitted with `markdown` absent or `emit: false` — is an in-scope absence-of-effect observation deliberately certified by no violator: its destination computation is positively anchored by the sibling §3 tests' byte-asserted emissions at the same next-to-source destinations under `emit: true`, and by its own `emit: true` half (13.2). +**Justification.** The line-drop rule is the subtlest pure contract in SPEC.md, and its discriminating fixtures depend on exact exotic bytes (boundary code points, lone-CR terminators) that tooling silently normalizes — a corrupted fixture passes vacuously in both directions. P-2's oracle is trusted by the property suite (S-6 checks its vectors; certification checks that generated documents actually reach the discriminating classes and that the property fails when the product deviates). T3-6's negative half — no `.md` emitted with `markdown` absent or `emit: false` — is an in-scope absence-of-effect observation deliberately certified by no violator: its destination computation is positively anchored by the sibling §3 tests' byte-asserted emissions at the same next-to-source destinations under `emit: true`, and by its own `emit: true` half (13.2). P-3 is likewise in scope and targeted by no violator: its equalities compare the product's answers to each other — `query node`'s subtree text against the product's own compiled output and against its children's texts (1.6) — so no harness oracle stands between them to mis-trust and a divergence is loud, not vacuous; the two violators are deliberately consistent across Markdown output and text so that P-3 passes under both, its passing side anchoring that the harness compares the product to itself rather than to an expectation of its own. ### VIOL-MD-CLASS @@ -110,18 +110,18 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ## CONF-DISC — configuration-driven discovery -**Scope.** Workspaces of trivial single-section `.mdx` sources whose file and directory names carry glob-significant bytes, plus, as T7-6 stages them, files at derived-classified paths and an import target unmatched by every group; spec groups with the glob grammar of 7 (a no-match group, and the empty `specs` and `code` maps, are valid with zero sources); imports of 2.1's single-default-binding form, resolving against the importing file's directory to a discovered source, an undiscovered target failing with 14.15; `markdown` with `emit: true` as T7-6's destination arm stages it, destinations classified by configuration alone (7.3); symbolic links present in the tree; no code groups (`code` appears only as the empty map), `coverage`, `policy`, or git; content of derived and emitted files beyond path is out of scope. A staging constraint: T7-6's exclusion arms are staged over spec groups — its `code` arm is the empty map — so the one exclusion rule of 13.4 is certified on its spec-group side. Command surface: `build` and `ids` (12.3) as the observation of the discovered set, the configuration-error behavior of 14.14/12.0 for patterns resolving outside the workspace root, and the source-error reporting of 14.15 and 14.19 — the 14.19 path dormant in conforming behavior, since the 13.4 exclusion keeps the derived-classified matches, the only staged matches lacking `.mdx`, out of the discovered set. Contracts under certification: glob semantics of 7 — `*`, `?`, `**`, byte-wise case-sensitive matching, dot-segment rule, every other character a literal — discovery's refusal to follow symbolic links, and the source exclusion of 13.4 (`.xspec.` names, `.xspec/` paths, and enabled emit destinations in no group). +**Scope.** Workspaces of trivial single-section `.mdx` sources whose file and directory names carry glob-significant bytes, plus, as T7-6 stages them, files at derived-classified paths and an import target unmatched by every group; spec groups and, as T7-6's code-group exclusion arm stages them, code groups (7.2) of well-formed `.ts` sources spelling no marker, spec-module import, or `text` call (4) — each discovered code source an edgeless whole-file code location (4.6), nothing in scope giving a code file an edge — both kinds under the glob grammar of 7 (a no-match group, and the empty `specs` and `code` maps, are valid with zero sources); imports of 2.1's single-default-binding form, resolving against the importing file's directory to a discovered source, an undiscovered target failing with 14.15; `markdown` with `emit: true` as T7-6's destination arm stages it, destinations classified by configuration alone (7.3); symbolic links present in the tree; no `coverage`, `policy`, or git; content of derived and emitted files beyond path is out of scope. Staging constraints: T7-6's exclusion arms are staged on both group sides — the spec side observed through `ids`, the code side through `query edges --from <path>` (11.1): on a workspace passing `build`'s validations, each excluded path a code glob matches — the module `build` generated next to its source (13.1) above all, a file under `.xspec/`, and an enabled emit destination — is refused as a path in no configured group (exit 2, 12.0), beside a discovered code source's whole-file location answering exit 0, T7-3's idiom for code discovery; and the staged code globs match, beyond those derived-classified paths, only the well-formed `.ts` sources above, no spec-group file among them (14.14's both-groups rule dormant). Command surface: `build` and `ids` (12.3) as the observation of the discovered spec set; `query edges --from <path>` (11.1) as the observation of the discovered code set — for a discovered code source's whole-file location, exit 0 with its empty edge enumeration, the JSON document 11 makes its only output form; for a path in no configured group, an excluded derived path included, the usage error of 12.0 (exit 2, the error document of 12.7), a check preceding the gate of 13.3 (12.0), which the staging keeps dormant; the configuration-error behavior of 14.14/12.0 for patterns resolving outside the workspace root, and the source-error reporting of 14.15 and 14.19 — the 14.19 path dormant in conforming behavior, since the 13.4 exclusion keeps the derived-classified matches, the only staged matches lacking `.mdx`, out of the discovered set. Contracts under certification: glob semantics of 7 — `*`, `?`, `**`, byte-wise case-sensitive matching, dot-segment rule, every other character a literal — discovery's refusal to follow symbolic links, and the source exclusion of 13.4 (`.xspec.` names, `.xspec/` paths, and enabled emit destinations in no spec or code group). **In-scope tests:** T7-4, T7-5, T7-6. -**Justification.** All three assert non-discovery — negative observations that pass vacuously when the staged names (bracket-bearing, multi-byte, dot-prefixed, link-mediated, derived-classified) never reach the matcher at all. They are also the canonical stock-dependency hazard: a product delegating to a common glob or filesystem-walking dialect satisfies every positive arm while violating the negative ones. T7-6's exclusion arms are the sharpest case: they carry no positive control — nothing observable separates matched-but-excluded from never-matched, so a glob that misses the staged derived paths (a wildcard stopped by the dot-segment rule short of `.xspec/`) passes forever — and they are the sole carrier of 13.4's source exclusion (T13.4-7 delegates wholly to T7-6). +**Justification.** All three assert non-discovery — negative observations that pass vacuously when the staged names (bracket-bearing, multi-byte, dot-prefixed, link-mediated, derived-classified) never reach the matcher at all. They are also the canonical stock-dependency hazard: a product delegating to a common glob or filesystem-walking dialect satisfies every positive arm while violating the negative ones. T7-6's exclusion arms are the sharpest case: on either group side nothing observable separates matched-but-excluded from never-matched — the code side's discovered-source control shows the group live, not that the derived path reached the matcher — so a glob that misses the staged derived paths (a wildcard stopped by the dot-segment rule short of `.xspec/`) passes forever; and they are the sole carrier of 13.4's source exclusion (T13.4-7 delegates wholly to T7-6), the product's own generated modules under an everyday code glob among the paths it must exclude. ### VIOL-DISC-DIALECT * **Scope:** CONF-DISC. * **Deviation:** Glob patterns are interpreted in a common dialect in which `[` `]` bracket expressions and `{` `}` brace alternations are active metacharacters, instead of the literals 7 requires — a single deviation: one rule of 7 (every character outside `*`, `?`, and `**` is a literal) broken for one dialect's metacharacter subset. `*`, `?`, `**`, case sensitivity, and the dot-segment rule are unchanged. * **Certifies:** T7-4. -* **Expected failures:** exactly T7-4 (its literal-metacharacter arms: `a[1].mdx` matches `a1.mdx` and fails to match the file named `a[1].mdx`; `b{a,c}.mdx` matches `ba.mdx`/`bc.mdx` and not the literal name). T7-5 and T7-6 pass under a staging constraint: their fixtures' patterns carry no bracket or brace characters, so their matching — and T7-6's exclusion — is unchanged. +* **Expected failures:** exactly T7-4 (its literal-metacharacter arms: `a[1].mdx` matches `a1.mdx` and fails to match the file named `a[1].mdx`; `b{a,c}.mdx` matches `ba.mdx`/`bc.mdx` and not the literal name). T7-5 and T7-6 pass under a staging constraint: their fixtures' patterns, T7-6's code globs included, carry no bracket or brace characters, so their matching — and T7-6's exclusion on both group sides — is unchanged. ### VIOL-DISC-SYMLINK @@ -133,9 +133,9 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command ### VIOL-DISC-DERIVED * **Scope:** CONF-DISC. -* **Deviation:** Discovery does not apply the source exclusion of 13.4: a path whose file name contains `.xspec.`, a file under `.xspec/`, or a file at an enabled Markdown emit destination, when matched by a spec-group glob, is treated as an ordinary match — a single deviation: one rule of 13.4 (derived files are never sources) dropped. Glob semantics, the dot-segment rule, link behavior, 14.19 for non-`.mdx` matches, and the import and empty-map rules are unchanged. +* **Deviation:** Discovery does not apply the source exclusion of 13.4: a path whose file name contains `.xspec.`, a file under `.xspec/`, or a file at an enabled Markdown emit destination, when matched by a spec-group or code-group glob, is treated as an ordinary match — on the code side an edgeless whole-file location, as every discovered code source of the scope is — a single deviation: one rule of 13.4 (derived files are never sources) dropped. Glob semantics, the dot-segment rule, link behavior, 14.19 for non-`.mdx` matches, and the import and empty-map rules are unchanged. * **Certifies:** T7-6. -* **Expected failures:** exactly T7-6 (its exclusion arms: a staged `.xspec.`-named `.mdx` file matched by a glob enters the discovered set; a file under `.xspec/` is discovered where a pattern spells the dot segment literally; with emission enabled, a glob-matched file at a source's destination — always a non-`.mdx` name, destinations ending `.md` (13.2) — is reported as 14.19 — each observably failing the arm's no-error non-discovery assertion; the import and zero-source arms are untouched, and the test fails on the exclusion arms alone). T7-4 and T7-5 pass under a staging constraint: their fixtures stage no `.xspec.`-bearing names, write no pattern naming `.xspec/`, leave `markdown` absent, and observe discovery only while no generated file exists — each arm's observation is a first `build`, or an `ids` no `build` precedes (refresh writes graph data alone, 13.3) — so no pattern whose reach exceeds `.mdx` names (T7-4's bare `*`) ever confronts the conformer's own next-to-source output (13.1), and their wildcard patterns cannot reach its graph data past the unchanged dot-segment rule. +* **Expected failures:** exactly T7-6 (its exclusion arms: a staged `.xspec.`-named `.mdx` file matched by a glob enters the discovered set; a file under `.xspec/` is discovered where a pattern spells the dot segment literally; with emission enabled, a glob-matched file at a source's destination — always a non-`.mdx` name, destinations ending `.md` (13.2) — is reported as 14.19 — each observably failing the arm's no-error non-discovery assertion; on the code-group side, each excluded path the code glob matches — the module `build` generated next to its source, the staged file under `.xspec/`, the enabled destination — enters the discovered code set, so `query edges --from` answers it exit 0 where the arm asserts the unknown-path refusal (12.0); the import and zero-source arms are untouched, and the test fails on the exclusion arms alone). T7-4 and T7-5 pass under a staging constraint: their fixtures declare no code group, stage no `.xspec.`-bearing names, write no pattern naming `.xspec/`, leave `markdown` absent, and observe discovery only while no generated file exists — each arm's observation is a first `build`, or an `ids` no `build` precedes (refresh writes graph data alone, 13.3) — so no pattern whose reach exceeds `.mdx` names (T7-4's bare `*`) ever confronts the conformer's own next-to-source output (13.1), and their wildcard patterns cannot reach its graph data past the unchanged dot-segment rule. ## CONF-AVAIL — availability answers and JSON datum forms @@ -176,8 +176,8 @@ Considered against the selection criteria and deliberately left uncertified; eac * **P-11, P-12**: P-11's imperfect-input classes are broad basins under P-8's mutators — nearly any byte mutation of a source lands in some finding family, unlike the boundary code points P-1 and P-2 must weight their generators toward — each class anchored pointwise by the deterministic 11.2 fixtures; its datum-form discipline is certified deterministically through the CONF-AVAIL datum-form violators, its termination, exit, and complete-document clauses are loud (S-3 captures exits and hangs; a partial document fails its own parse), and a conformer admitting it would need occurrence analysis over fuzzed TypeScript — P-8's scope argument. P-12 enumerates every offset of every file — reachability is total by construction — and its comparator is computed from the product's own `view` answers, anchored by T11.5-1's precomputed fixture, so there is no independent oracle to mis-trust. * **The scale-capacity class (T1.3-7, P-8's giant-nesting floor, H-11, S-2's scale vectors, S-8)**: TEST-SPEC.md places it outside certification by construction — a harness that cannot stage, capture, decode, or walk a conforming input or answer at the staged scale fails spuriously against the conformer, never vacuously against a violator (S-8; S-2 on the input side) — and T1.3-7's assertions are positive counts and trees that fail loud against a product that truncates or refuses; a fixture nested to the floor would only re-prove the builder's and decoders' capacity, which S-2 and S-8 gate before any product exists. * **The rewrite byte contracts of 6.4/6.5 (T6.4-2's keepable-form and whole-file arms, T6.5-1's specifier-rewrite contract, T6.5-7, T6.5-8, T6.5-10)**: positive, byte-asserted against expected files composed from the rules of 6.4/6.5 and 3 — the diff-isolated added run of T6.5-8 and T6.5-10(a) included, which is absent altogether, and the assertion with it, against a product that inserts nothing or more than one run — so their failure modes are loud; a conformer passing them carries the identity-continuity engine whole (the P-5 argument above), and their one staging hazard, the builder's fidelity to declared bytes, is S-2's. -* **T13.5-6, T13.5-7** (workspace isolation; interrupted mutation): squarely in criterion (a)'s temporal class, but P-10's rationale extends to both — neither admits a deviation with an unambiguous expected-failure set. Cross-workspace interference surfaces only when schedules overlap, and post-release kill damage lands nondeterministically; T13.5-7's operative assertion is disjunctive for exactly that reason (`check` passes or reports findings), so no single deviation fails it deterministically, and its held-point choreography is certified via the CONF-CORE lock violators. T13.5-6's isolation is additionally the harness's own H-1 obligation, exercised on every parallel run (E-3). -* **T6.1-1** (journal lifecycle): certified through CONF-CORE in this document's first pass, it now byte-compares the journal around every command surface — `occurrences`, `view`, `at`, `inventory`, `version`, and the previews of 6.6 among them — so a conformer passing it whole would serve all of 11 and 6.6 (P-8's scope argument), and it leaves CONF-CORE's in-scope set. Its never-modified compares are the compare-around machinery VIOL-CORE-CHATTYREADS certifies over the same journal on T13.4-5, whose read set the scope constrains; its lifecycle negative — no journal after `build` in a fresh workspace — is braced in-test by the positive at the same path (the file appears at `.xspec/journal` with the first `rename`/`move`), so a mislocated absence check fails loud rather than passing silently. +* **T13.5-6, T13.5-7** (workspace isolation; interrupted mutation): squarely in criterion (a)'s temporal class, but P-10's rationale extends to both. T13.5-6 admits no deviation with an unambiguous expected-failure set — cross-workspace interference surfaces only when schedules overlap — and its isolation is additionally the harness's own H-1 obligation, exercised on every parallel run (E-3). T13.5-7's post-release kill damage lands nondeterministically, and its operative assertion is disjunctive for exactly that reason (`check` passes or reports findings), so no deviation fails that assertion deterministically; its held-point assertion (`check` passes there) would fail deterministically under a product writing part of a multi-file `rename` before creating the hold file — but that is the hazard VIOL-CORE-EARLYWRITE certifies on T13.5-1's while-held byte-identity at the same held point, so a violator here would re-prove the held-point choreography already certified via the CONF-CORE lock violators. +* **T6.1-1** (journal lifecycle): it byte-compares the journal around every command surface — `occurrences`, `view`, `at`, `inventory`, `version`, and the previews of 6.6 among them — so a conformer passing it whole would serve all of 11 and 6.6 (P-8's scope argument), outside every scope here. Its never-modified compares are the compare-around machinery VIOL-CORE-CHATTYREADS certifies over the same journal on T13.4-5, whose read set the scope constrains; its lifecycle negative — no journal after `build` in a fresh workspace — is braced in-test by the positive at the same path (the file appears at `.xspec/journal` with the first `rename`/`move`), so a mislocated absence check fails loud rather than passing silently. * **Section 4 consumer-side and type-level tests**: their vacuous-pass hazard lives in the TypeScript tooling driver, which S-4 self-tests against a known non-xspec fixture; a conformer would need the full generated-module contract (skeleton, branding, documentation, navigation). The TypeScript-side occurrence and range enumerations (T1.7-2, T5.7-1 through T5.7-4, T11.3-1) sit behind the same wall — a conformer serving them carries the full 4.5/4.6 unit analysis — and their two-sided enumeration equalities fail loud, the record form they decode certified through the CONF-AVAIL datum-form violators. T6.5-9's compile-clean observation rides the same driver — S-4 checks each diagnostic kind's detection directly, the import-conflicts-with-local-declaration kind this test turns on among them — beside positive `query edges` assertions; and T4-4's type-only arms and T4.5-4's callee-side arm pair each no-edge negative with an in-test positive control (the ordinary-binding statements recording their edges; the condition-18 finding, and the `embeds` occurrence listed outside the shadowing scope), so a product resolving by name fails the paired positive rather than passing silently. * **T10.1-4 session-corruption arms**: the per-arm precision hazard is real, but the recorded-creation-parameters arm requires a baseline or coverage session, dragging git or coverage machinery into an otherwise lean scope. T10.1-5's and T12.0-10's gate-precedence pairs carry their own discriminating contrasts in-test (the `check`-vs-subcommand and valid-twin comparisons). * **The review-operation refusal negatives (T10.3-2's re-block refusal; T10.7-9/10's refused `split` and `resolve`)**: refusals of 10.7 carry no stable code (14), so a wrong-reason refusal is in-test indistinguishable for any product and the error-identity representatives reach none of them; what discriminates instead is asserted beside each refusal — the same test flips one condition and the same subcommand succeeds (T10.3-2's dependent resolves once its blocker is re-resolved; T10.7-9 asserts the successful split beside its other-kind and childless-root refusals; T10.7-10 asserts `resolve` succeeding on any unblocked item beside its refused blocked one) — so a wrong-reason refusal fails its paired positive arm rather than passing silently, and a violator would carry the blocking, decomposition, and re-derivation machinery those tests assert around the refusals for one exit-code assertion already braced in-test. From 3b61122726c31a0025c5e66c840ca4fd21fb527d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 03:22:38 +0000 Subject: [PATCH 181/226] =?UTF-8?q?sdg(phase-7):=20CERTIFICATIONS=20revisi?= =?UTF-8?q?t=20round=202=20=E2=80=94=20T13.5-4=20compare-scope=20staging?= =?UTF-8?q?=20constraint=20under=20CHATTYREADS;=20control-test=20notes=20i?= =?UTF-8?q?n=20CONF-CORE=20and=20CONF-VALID=20justifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: O1 (VIOL-CORE-CHATTYREADS: T13.5-4's final-build byte-equality compares derived files alone is now stated as a staging constraint on T13.5-4, parallel to the T13.5-1 constraint beside it), O2 (CONF-CORE and CONF-VALID justifications now explain their in-scope tests targeted by no violator — T6.1-2; T1.3-1..6 and T2.6-1/2 — as passing-side controls with loud failure modes, parallel to CONF-MD's treatment of T3-6 and P-3). Rejected: none. No Critical or Important items were reported. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/CERTIFICATIONS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specs/CERTIFICATIONS.md b/specs/CERTIFICATIONS.md index 950d8e8..9d21ad5 100644 --- a/specs/CERTIFICATIONS.md +++ b/specs/CERTIFICATIONS.md @@ -12,7 +12,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command **In-scope tests:** T6.1-2, T10.4-5, T13.4-5, T13.5-1, T13.5-2, T13.5-3, T13.5-4, T13.5-5. -**Justification.** The in-scope 13.5 lock tests (T13.5-1–T13.5-4) route through the one seam SPEC.md declares, T13.5-5 through a concurrent polling reader, and all assert temporal behavior — contention, kill timing, write visibility — where a mis-choreographed harness passes against any product. T13.4-5 and T10.4-5 are absence-of-effect assertions (byte-compares around commands), the suite's largest vacuous-pass surface: a mispositioned snapshot passes forever. T13.5-1's stale-workspace arm is the seam's sharpest ordering case: the one file its pending refresh would touch is graph data, so a while-held snapshot scoped to sources and durable files passes the arm against every product, and no other in-scope failure isolates that omission. T6.1-1 sweeps every command surface and lies outside this scope (see Exclusions). +**Justification.** The in-scope 13.5 lock tests (T13.5-1–T13.5-4) route through the one seam SPEC.md declares, T13.5-5 through a concurrent polling reader, and all assert temporal behavior — contention, kill timing, write visibility — where a mis-choreographed harness passes against any product. T13.4-5 and T10.4-5 are absence-of-effect assertions (byte-compares around commands), the suite's largest vacuous-pass surface: a mispositioned snapshot passes forever. T13.5-1's stale-workspace arm is the seam's sharpest ordering case: the one file its pending refresh would touch is graph data, so a while-held snapshot scoped to sources and durable files passes the arm against every product, and no other in-scope failure isolates that omission. T6.1-2 is in scope as the journal-write control the lock and journal violators' passing sides cite (its determinism compare unchanged under VIOL-CORE-EARLYWRITE, VIOL-CORE-EARLYREFRESH, and VIOL-CORE-CHATTYREADS) and is targeted by no violator: it asserts a positive byte-equality between two product-written entries — neither negative, temporal, nor seam-routed — so a deviation fails it loud, not vacuously. T6.1-1 sweeps every command surface and lies outside this scope (see Exclusions). ### VIOL-CORE-NOLOCK @@ -54,7 +54,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command * **Scope:** CONF-CORE. * **Deviation:** `build` and the read commands modify the journal: each such invocation that is not refused as a usage or configuration error (exit 2) appends one fixed line to `.xspec/journal`, creating the file when absent. The appended line is inert to the fixture's own journal handling: read back as neither an identity mapping (5.4) nor a malformed entry (no 14.13, so no later command is gated by it, 13.3), with graph data and every other derived byte independent of how many such lines the journal holds (13.4). Mutating commands, and the entries `rename`/`move` append, are unchanged. * **Certifies:** T13.4-5. -* **Expected failures:** exactly T13.4-5 (its journal byte-compares under `build` and the read commands fail — each compared invocation the scope's read surface answers exit 0 appends; in this git-less scope `impact --base` is refused exit 2 and appends nothing, which leaves `build` and the remaining reads to fail the compare; its session-file compares and its never-regenerated arm are untouched). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched, and its review reads answer ungated — the inert lines meet no 14.13, so 13.3's gate never turns them into exit-1 non-answers; the 13.5 tests' compares are unmoved by the appends under two staging constraints: T13.5-1's held run and its no-hold twin drive the same command sequence, and neither drives `build` or a read command between the mutating command's start and the final-state compare — the seam flag rides the mutating command, which this deviation leaves unchanged, so the appends are byte-identical on both sides, and no read while held breaks the while-held byte-identity or desynchronizes the twins' journals; and T13.5-4's final-`build` byte-equality to a clean build is a comparison of derived files (13.4) alone — the inconsistency it resolves is derived-file inconsistency — which the inertness condition keeps independent of the two workspaces' differing appended-line counts, where a whole-workspace compare would see the storm's many inert lines against the clean twin's one; T13.5-2's byte-compare covers the refused mutating command alone (the scope's bracketing constraint), which appends nothing. +* **Expected failures:** exactly T13.4-5 (its journal byte-compares under `build` and the read commands fail — each compared invocation the scope's read surface answers exit 0 appends; in this git-less scope `impact --base` is refused exit 2 and appends nothing, which leaves `build` and the remaining reads to fail the compare; its session-file compares and its never-regenerated arm are untouched). All other in-scope tests pass: T6.1-2 compares the entries of the same operation on identical workspace states, which remain byte-identical; T10.4-5 byte-compares the session file, which is untouched, and its review reads answer ungated — the inert lines meet no 14.13, so 13.3's gate never turns them into exit-1 non-answers; the 13.5 tests' compares are unmoved by the appends under two staging constraints — one on T13.5-1: its held run and its no-hold twin drive the same command sequence, and neither drives `build` or a read command between the mutating command's start and the final-state compare — the seam flag rides the mutating command, which this deviation leaves unchanged, so the appends are byte-identical on both sides, and no read while held breaks the while-held byte-identity or desynchronizes the twins' journals; and one on T13.5-4: its final-`build` byte-equality to a clean build compares derived files (13.4) alone — the inconsistency it resolves is derived-file inconsistency (13.5), and the compare's extent is otherwise within the test's latitude — which the inertness condition keeps independent of the two workspaces' differing appended-line counts, where a whole-workspace compare would see the storm's many inert lines against the clean twin's one and fail the test against this violator; T13.5-2's byte-compare covers the refused mutating command alone (the scope's bracketing constraint), which appends nothing. * **Note:** the representative value of this violator for the suite's other never-modifies assertions (T6.1-1's journal sweep above all — see Exclusions — and T12.0-11, T12.1-4, T13.3-3, T6.4-3, T6.5-4, T6.6-2, and the answer-side compares of T11.2-1/T11.2-6/T11.6-4) holds insofar as those assertions share the compare-around-command machinery certified here; the Exclusions lean on exactly that condition. ### VIOL-CORE-PERSISTREADS @@ -70,7 +70,7 @@ Each conformer entry states its **scope** — the SPEC.md behaviors and command **In-scope tests:** T1.3-1, T1.3-2, T1.3-3, T1.3-4, T1.3-5, T1.3-6, T1.4-1, T1.4-2, T1.4-4, T2.6-1, T2.6-2, P-1. -**Justification.** The 1.4 matrix is the suite's most staging-fragile negative surface: its fixtures place control characters, exotic whitespace, and boundary code points inside source bytes that tooling silently normalizes, and its negative arms assert one condition (14.4) for every 1.4 class — a corrupted arm whose byte lands in a different rejected class still sees 14.4 and passes vacuously against a product that never validates the class under test. Certification makes each corruption loud: staging that yields 14.20 or a clean build fails the arm against the conformer, and staging that no longer reaches the deviation's class leaves the violator's expected failure unmaterialized. P-1's value rests entirely on its generator reaching those classes — the criterion-(a) reachability case. +**Justification.** The 1.4 matrix is the suite's most staging-fragile negative surface: its fixtures place control characters, exotic whitespace, and boundary code points inside source bytes that tooling silently normalizes, and its negative arms assert one condition (14.4) for every 1.4 class — a corrupted arm whose byte lands in a different rejected class still sees 14.4 and passes vacuously against a product that never validates the class under test. Certification makes each corruption loud: staging that yields 14.20 or a clean build fails the arm against the conformer, and staging that no longer reaches the deviation's class leaves the violator's expected failure unmaterialized. P-1's value rests entirely on its generator reaching those classes — the criterion-(a) reachability case. T1.3-1–T1.3-6 and T2.6-1/T2.6-2 are in scope as controls — the structural, duplicate, masking, and tag-splitting behavior the two violators' passing sides cite as unchanged — and are targeted by no violator: T2.6's assertions are positive, and the 1.3 negatives lack 1.4's hazard — their staging is plain nesting and repeated or duplicated `id`s, which no tooling silently normalizes, their conditions are distinct per rule or carry the expected form (14.1–14.3, 14.17), and T1.3-6's masking absences are braced in-test by the findings asserted beside them — so a staging miss fails loud rather than passing vacuously. ### VIOL-VALID-CTRL From 831df39d266ed8c42f13a4d78f1cbb6ad1d7b78c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 03:31:28 +0000 Subject: [PATCH 182/226] sdg(phase-9): retire the Phase 10 sweep task C7 and delete the leftover FIX_PLAN.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C7 was the last task of the interrupted Phase 10 plan: a product-green full-suite sweep whose own rules forbid harness edits. It is wrong for this phase (9, harness scope: product tests may fail, product code untouched), and its sole residue was harness work — the recursive decodeViewNodeForm in test/helpers/adapters/forms.ts overflows on the depth-2048 view tree P-11 draws (CI seed 271828183, trial 1; run 33298889697 at e239f0c: harness-self green, suite-linux 636/637 with only that harness error red, Windows E-6 8/9 with its byte-identity arm starved of the exchange artifact). Since e239f0c only the specs moved: TEST-SPEC.md (eight Phase 6 iterations) added H-11 (answer-scale capacity), S-8 (its self-test: synthetic documents at the maximum answer scale, capture gated through S-3's stand-in), T1.3-7 (the depth-2048 anchor), T6.5-8..10, and rewrote about 45 tests; CERTIFICATIONS.md (three Phase 7 revisits) added VIOL-CORE-EARLYREFRESH, moved T6.1-1 to Exclusions, and refreshed the VALID/AVAIL scopes. test/ and src/ are unchanged, so the harness gap is far wider than the one decoder: the Phase 9 compliance determination (next iteration, no plan present) produces this phase's plan against the revised documents. The one durable CI fact from C7's note — the e6-linux-outputs upload is skipped whenever a Linux test fails — moves to AGENTS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 2 +- specs/tmp/FIX_PLAN.md | 421 ------------------------------------------ 2 files changed, 1 insertion(+), 422 deletions(-) delete mode 100644 specs/tmp/FIX_PLAN.md diff --git a/AGENTS.md b/AGENTS.md index 5c8fefe..5dfaaf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,4 +17,4 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Local-only suite (TEST-SPEC E-2; separately invocable, never run in CI, currently empty): `npm run test:local`. - Property tests (TEST-SPEC 16; machinery in `test/helpers/property.ts`) run a fixed seed set by default — the CI mode, fully deterministic. To rerun with a specific seed: `XSPEC_PROPERTY_SEED=<uint32 from the failure message>`. Optional randomized local mode: `XSPEC_PROPERTY_SEED=random` (each property reports its seed for replay). Never set the variable in CI. Vitest intercepts the seed reports (`console.info`); add `--disable-console-intercept` to the vitest invocation to see them — but place any test-file filter argument *before* that flag (a file path following `--disable-console-intercept` is not treated as a filter and the whole project runs). - Format code (Prettier, default config, `src/` and `test/` only): `npm run format`; verify: `npm run format:check`. -- CI: `.github/workflows/ci.yml` — harness-self (Linux), full suite (Linux, network disabled after setup via `.github/scripts/run-without-network.sh`), and the Windows E-6 leg on every pull request. +- CI: `.github/workflows/ci.yml` — harness-self (Linux), full suite (Linux, network disabled after setup via `.github/scripts/run-without-network.sh`), and the Windows E-6 leg on every pull request. The `e6-linux-outputs` upload step of the full-suite job has no `if: always()`, so it is skipped whenever any Linux test fails (the run ends with no artifact): the Windows byte-identity test then fails loudly on the missing manifest until suite-linux is green — that arm's verdict is meaningful only on a green Linux run. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md deleted file mode 100644 index 4ac171f..0000000 --- a/specs/tmp/FIX_PLAN.md +++ /dev/null @@ -1,421 +0,0 @@ -# FIX_PLAN — Phase 10: product adherence to `specs/SPEC.md` - -Source: two compliance reviews of the product against SPEC.md plus a red verify run -(142 failed / 495 passed; branch `claude/xspec-ui-apis-4df8fa`, PR #7). Goal: every -test passes (`npm test` locally and in CI, including the Windows E-6 leg). - -**Rules for every task (read once per spawn):** - -- Phase 10: never modify the test harness (`test/`). Product code (`src/`) only. - Never couple product code to harness internals. -- Respect `specs/IMPLEMENTATION.md` (three layers: pure `core`, I/O `workspace`, - rendering `cli`; one canonical JSON serializer; findings built as data, rendered - once per output form; no new runtime dependencies). -- Work top to bottom. The stages are ordered by dependency: Stage A rebuilds the - SPEC 12.7 finding/error-document forms and the stable-code model — single choke - points that ~90 failures decode through; Stage B adds the missing command - surfaces; Stage C makes localized behavioral fixes. A task lists its hard - prerequisites; do not pick a task whose prerequisites are still in this file. -- Build first (`npm run build`), then run the named tests: - `npx vitest run --config test/vitest.config.ts --project suite test/suite/<file>` - (see `AGENTS.md`). A task's named tests are its verification; other tests may - stay red until later tasks land. Before committing: `npm run typecheck` and - `npm run format`. Commit `sdg(phase-10): <imperative summary>`, push. -- When a task is done, remove it from this file in the same commit. If a task - turns out too large for one spawn, land a coherent part, and replace the task - with precise remainder task(s) here. When the last task is removed, delete - this file. - ---- - -## Stage A — SPEC 12.7 report forms and the stable-code model - -(A2 landed: the shared path-value representation and renderer are -`src/core/path-text.ts` — `PathText` is `string | PathBytes` (a tagged -wrapper holding a non-UTF-8 path's exact bytes, constructed only through -`pathTextOf` so a valid-UTF-8 path can never take the marked form); -`pathTextJson` renders the 12.7 value form, `renderPathText` the -deterministic human spelling `<bytes HEX>`, `comparePathTexts` the one byte -order over both forms, used by `compareFindings`/`compareLocations`. Finding -`locations[].file` and `path` are `PathText` now; Stage B surfaces reuse the -same renderer for their own path members.) - -(A3 landed: jointly-violated conditions carry every participant's location — -duplicate IDs one 14.3 finding per identity locating every bearer (`mdx.ts` -`validateStructure`), import-binding collisions one 14.15 locating every -colliding declaration (`spec-references.ts` `analyzeSpecImports`, -`code-analysis.ts` `scanModuleLinks`), cycles located through their full -in-source path with identities dropped — `graph.ts` keeps an `edgeSpellings` -map (requirement-side depends/embeds edge → its 5.7 spelling spans; `d` = -the entry's own expression, MDX embedding = the full braced container), -which B8's `refused-cycle` can reuse; import cycles locate every -participating import declaration. Embedding-form no-occurrence findings -(14.6, 14.8) span the full braced container, `SpecEmbedding.range`.) - -(A4 landed: the exit-2 JSON error document. The 11.6 anchoring spelling is -the shared helper `anchoredPathSpelling` in `src/workspace/anchor.ts` (B7's -inventory reuses it for `root`/`config`); `LocatedWorkspace`/`LoadedWorkspace` -carry `configAnchor`, the concerned path of every condition-14 finding. -JSON-in-effect is `jsonOutputInEffect(invocation)` in `src/cli/args.ts` — -`--json` or a `CommandSpec.jsonOnly` surface; Stage B registrations -(`version`, `occurrences`, `view`, `at`, `inventory`) must set -`jsonOnly: true` and force `{...invocation, json: true}` for their exit-1 -reports as `query`/`review export` do. `usageError(invocation, io, message)` -in `src/cli/commands/common.ts` and `emitConfigurationErrors(io, jsonInEffect, -configAnchor, findings)` in `src/cli/report.ts` are the exit-2 choke points — -route every new exit-2 outcome through them.) - -(A5 landed: review-operation refusals are code-less findings — -`emitReviewRefusal(json, stdout, message, identities)` in -`src/cli/commands/review-session.ts` emits `{"findings": [...]}` through -`emitFindingsReport`, one finding with `code`/`path` null, `locations` empty, -`identities` the session name (+ item id / colliding name) — informational.) - -(A6 landed: a successful `rename`/`move` reports its applied mapping — -`emitAppliedMappingReport(json, stdout, mapping)` in `src/cli/report.ts`, -fed `plan.entry.mapping` (the journal entry's canonical `from`-byte order): -JSON `{"findings": [], "mapping": [{"from", "to"}...]}` — the preview -`mapping` member encoding of 12.7, which B9's success document can reuse — -human one `FROM -> TO` line per pair plus a count line.) - ---- - -## Stage B — missing command surfaces (patch 0001) - -(B2a landed: 14.19 files enter per-file analysis. Discovery carries them as -`classification.invalidSources` (`{path: PathText, bytes, kind, groups}`, -`src/core/discovery.ts`); the pipeline parses them into -`WorkspaceAnalysis.invalidPathSpecs`/`invalidPathCode` — ordinary -`SpecFileAnalysis`/`CodeAnalysis` values whose `document.file` / -`analysis.file` is the real `PathText` while `path` is a never-rendered -lossy stand-in — their per-file findings reported beside the 14.19; they -feed no nodes, hashes, or recorded inputs. Import designation consults the -whole discovered set through `SpecSourceDomain` -(`src/core/spec-references.ts`; byte-space designators for byte-path -importers, `WorkspaceContent.readInvalidSource` reads content by exact -bytes): a valid import of a 14.19 member is no finding, -`SpecImport.targetFile`/`CodeImport.targetFile` carry every valid import's -member as `PathText`, bindings of such members are `undefined-module` (MDX) -/ `target: {defined: false}` (TS), and references rooted there report their -14.5/14.6/14.7 at analysis time. References *from* invalid-path files -resolve in `buildWorkspaceGraph` (the `invalidPathSpecs`/`invalidPathCode` -inputs — findings only, local form never resolves); spec import cycles run -over path bytes with invalid-path files participating. B4/B5 answer for -these files from `invalidPathSpecs`/`invalidPathCode`; B2's occurrence -recording for them hooks into the graph's invalid-path resolution pass, -source datum explicitly unavailable.) - -(B2 landed: reference occurrences are computed in the graph and persisted. -`WorkspaceGraph.occurrences` (`src/core/graph.ts`) holds one -`ReferenceOccurrence` per resolving dependency-kind spelling in occurrence -order (file path bytes via `comparePathTexts`, then range start, then end): -`file` is the referencing file's real `PathText`; `range` is the exact 5.7 -span (a `d` entry's own expression; an MDX embedding's full braced -container; a TS `text(...)` call's whole call expression via the new -`CodeReference.occurrenceRange`, `src/core/code-analysis.ts`; a marker's -bare chain); `kind` is the `DependencyEdgeKind`; `source` is the source -node's IDENTITY or null (= the 11.2 explicitly-unavailable datum: sections -without a usable identity, every node of an invalid-path file — those -occurrences are recorded in the graph's invalid-path resolution pass); -`target` the resolved identity. The source datum's RANGE half joins through -the node itself: a requirement source's is `RequirementNode.section.range` -(root = whole file); a code source's range is B3's deliverable — B4 renders -`{"identity", "range"}` from the graph node, or `{"unavailable": true}` for -null. Persisted as `GraphSnapshot.occurrences` (`StoredOccurrence`, -`src/core/graph-data.ts`, format version 4 since B3): valid workspaces only, so -stored `file` is a plain string; round-trips byte-deterministically.) - -(B3 landed: every code location carries its SPEC 1.7 source range. -`CodeUnit.range` (`src/core/code-analysis.ts`) is the construct binding the -unit's name — a variable declaration's own node (name through initializer, -never the multi-declaration statement), dotted-namespace units sharing the -chain's outermost declaration, a named default export the construct's own -range with the `export default ` prefix excluded (`async`/`abstract` kept), -an anonymous one's `default` unit the whole export declaration, `@N` units -their own occurrence's construct. `CodeLocationNode.range` -(`src/core/graph.ts`) carries it into the graph (whole-file location = -`0..utf8Length(text)`), persisted as `StoredCodeLocation.range` -(`src/core/graph-data.ts`, format version 4 — an old store reads as -malformed → mismatch). Verified against T1.7-2's fixture offsets at the -core level; nothing presents the range yet — B4's occurrence records and -C6's review payloads are the two presentation points, and T1.7-2 goes -green with them.) - -(B4 landed: `xspec occurrences` and the shared 11.2 layer. -`src/core/availability.ts` — `discoveredDomain(classification, glob?)` builds -the `ConsultedDomain` (byte-keyed membership over the entire discovered set, -invalid-path members matched by their exact bytes), `accompanyingFindings` -selects a domain's findings (location file or concerned path in domain — -jointly-violated conditions accompany whole), `nodeSpellingProblem` is 11.3's -syntactic `--to`/node-spelling well-formedness, `selectOccurrences(graph, -domain, to?)` yields `ResolvedOccurrence`s (the source datum's range joined -through the graph node — requirement `section.range`, root = whole file; code -`range`), `availabilityExit` the any-finding-or-unavailable → 1 rule. -Identity definedness (11.2) is `definedIdentitySections(document)` in -`src/core/mdx.ts` — spells + well-formed + structural, chain-inherited, -uniqueness own-only — and graph node construction now builds nodes for -exactly those sections (winner-picking removed: on failing workspaces, -references to duplicate/malformed/structurally-invalid bearers report -14.5–14.7 and record nothing); B5's `view` per-node identity datum and B6's -`at` reuse it. Pre-answer step `prepareWorkspaceForAvailability` -(`src/workspace/availability.ts`): config errors exit 2; a failing workspace -(analysis findings, or 14.22 symlink findings over build's full write set) → -answer from the current analysis, no store or journal consequence, no write; -passing → the 13.3 refresh participation. CLI plumbing: -`prepareAnalysisForAvailability` (`src/cli/prepare.ts`); -`occurrenceRecordJson`/`unavailableJson` (`src/cli/report.ts`) — `view`/`at` -render occurrence records and unavailability markers through these.) - -(B5 landed: `xspec view` (`src/cli/commands/view.ts`), registered JSON-only -with variadic `<file>` positionals — `variadicPositionals` and -`positionalConflicts` in `src/cli/args.ts` make combining operands with -`--file` a parse-level usage error. The 11.2 pre-answer step is split so -discovery-consulting argument checks precede answering AND the refresh: -`analyzeWorkspaceForAvailability`/`finishAvailabilityRefresh` -(`src/workspace/availability.ts`; `prepareWorkspaceForAvailability` still -composes both for `occurrences`), CLI face `analyzeAnalysisForAvailability` -(`src/cli/prepare.ts`) — B6's `at` must reuse this: operand membership by -`pathTextKey` over `classification` spec/code/invalid sources (unknown / -wrong-kind → exit 2), then `finishAvailabilityRefresh`, then answer. New -parse-local data: `SpecSection.attributes` (raw `{name, range, text}` -entries, spread name null) and `tagsDefined`/`coverageDefined` (11.2 -three-state interpreted datums) in `src/core/mdx.ts`; -`SpecImport.designatedFile` (`src/core/spec-references.ts`) is the 11.4 -import-target datum (specifier form + discovery alone, binding validity -notwithstanding). `src/core/availability.ts` adds `expansionConsultedFiles` -(the `--text` domain walk over occurrence-recording embeddings) and -`TextAvailability` (per-node own/subtree-text definedness — unresolved -spelling or embedding cycle poisons the whole value); the graph's -embedding index now also covers invalid-path files' embeddings, so the -text model expands them where resolution holds. A file's own occurrence -records = `selectOccurrences(graph, new ConsultedDomain([file]))` — B6's -`occurrence` member reuses this. P-12 stays red until B6's `at` lands, as -its own verify line records.) - -(B6 landed: `xspec at` (`src/cli/commands/at.ts`), registered JSON-only with -`<file>` `<offset>` positionals. Membership mirrors `view`'s operand check -(spec/code/invalid sources by `pathTextKey`); the offset-spelling check is -syntactic and precedes the analysis; the offset bound is judged against the -parsed root's range end or, for an unparseable named file, the byte length -read via `readSourceByteLength` (`src/workspace/availability.ts`); the -unparseable file answers `{"unavailable": true}` beside its 14.20, and -identity/occurrence rendering reuses `definedIdentitySections` / -`selectOccurrences` / `occurrenceRecordJson`. Performance: the full path -costs ~0.5s per invocation (the pinned TS-compiler config parse), which -would blow P-12's 600s exhaustive sweep, so `at` also answers from a -verified store — `tryFastAt` in `src/cli/commands/at-fast.ts`, wired in -`main` beside `tryFastQuery` over `workspace/fast-read.ts`, ~0.12s, -byte-identical to the full path (usage diagnostics shared through -`src/cli/commands/at-common.ts`). If another exhaustive sweep nears its -timeout, this store-backed fast-path pattern is the lever.) - -(B7 landed: `xspec inventory` (`src/cli/commands/inventory.ts`), registered -JSON-only between `at` and `rename`. The shared three-way record read is -`readDerivedFileRecord(root)` in `src/workspace/graph-data.ts` — -`DerivedFileRecord` is `{state: "absent"} | {state: "readable", paths} | -{state: "unreadable"}` (absent = empty record; a non-plain occupant or -unparseable bytes = unreadable, condition 23) — B10's delta and C4's `check` -staleness arm reuse it. `GRAPH_DATA_AREA` (`.xspec`, no trailing separator) -is exported from `src/core/graph-data.ts`: the concerned path of every 14.23 -finding, and the path C5's 14.10 unit forms must switch to. Other reusable -pieces: `specSourceDerivedPaths(sourceBytes, configuration)` in -`src/core/discovery.ts` (per-source module/Markdown paths by the `NAME.mdx` -byte shape alone, total over invalid paths), `journalOccupied(root)` in -`src/workspace/journal.ts` (presence alone, lstat), `listSessionFilePaths` -in `src/workspace/reviews.ts` (well-formed session file names by name alone, -byte order of file name), and the discovery-level 14.14 exit-2 routing -inside the handler. T11.6-1..4 green on Linux; T12.2-2/3 and T13.3-2's -corrupt-record arms stay red on C4/C5 defects as their notes say.) - -(B8 landed: the refusal contract. `src/core/refusal.ts` is the one shared -evaluation — `evaluateRenameRefusals` / `evaluateMoveFileRefusals` / -`evaluateMoveSectionRefusals` return every applicable reason together as -`Finding[]` (one finding per reason, stable codes, 12.7 concerns: -identity-concerning reasons carry `file#id` in `identities`, collisions -locate every bearer, destination reasons carry the `path` member), emitted -through `emitFindingsReport` as `{"findings": [...]}` exit 1; the -invalid-workspace precondition still reports the analysis findings alone -before any reason is evaluated. Would-be cycles and unresolvable -references are evaluated in identity space over the CURRENT graph's -edges/occurrences with the mapping applied (section form re-parented; -`findCycles` exported from `graph.ts`), locating participants at -pre-operation coordinates — reanalysis no longer leaks numbered -conditions and stays only as an unreachable-guard on the success path. -Destination facts: `assessDestinationPath` (core; pure causes + -`componentProbePaths` — destination plus its would-be Markdown emit path) -with `probeOccupant`/`nonDirectoryComponents` (`src/workspace/writes.ts`; -lstat-classified, ENOENT/ENOTDIR/ELOOP → absent) feeding the one -`refused-invalid-destination` finding — obstructed destination-side -components included, never 14.22. B9's `--preview` must call exactly this -evaluation (the CLI face is `assessAndProbeDestination` + the evaluate -functions in `rename.ts`/`move.ts`) for its refusal equivalence.) - -(B9+B10 landed together: `--preview` for `rename`/`move`, delta included. -The classed edit model is `src/core/preview.ts` — the ten `PreviewEditClass` -names, `PreviewCollector` (files by path bytes, edits by start/end/class -bytes), `derivedFileDelta`. The planners collect preview edits in the same -pass as the applied edits (`RenamePlan.previewFiles`, `MoveFilePlan.…`, -`MoveSectionPlan.…`): reference rewrites span the 5.7 occurrence (a `d` -entry's `reference.range`, an embedding's `embedding.range`, -`CodeReference.occurrenceRange`), id-rewrites the attribute's own -`attributeRange`, removals the extended span (`removalSpan` over the shared -line-drop machinery), and import additions one deterministic offset shared -by preview and real edit — `offsetAfterLine`/`importAdditionEdit` in -`core/move.ts`: after the last surviving import's line, at the removed -block's line start, or offset 0 for an import-less file (the applied edit -inserts `decl\n` at exactly the previewed offset, 6.5; an import line -directly before/after JSX parses fine, so no blank-line separator). CLI: -`--preview` in `args.ts`; the handlers thread a `preview` flag through the -shared validation (every findings-refusal emits the four-member document -with `mapping`/`files`/`delta` null via `emitRefusedPreview`; the preview -returns before the unreachable-guard reanalysis and takes no exclusivity; -`--test-hold`+`--preview` exits 2 before any lock). Success is -`emitSuccessfulPreview` (`src/cli/commands/preview.ts`): B7's -`readDerivedFileRecord` (the one record consult), post-op generation set -via `generatedDerivedPaths` (`core/build.ts` — post spec paths with the -operation's path substitution applied), 14.23 → delta unavailable beside -the shared `unreadableRecordFinding` (`core/graph-data.ts`; inventory now -reuses it), exit 1 with everything else in full. T6.6-2/4/5/6 and T12.7-3 -green; T6.6-3 red only on the C1-shared arms below.) - ---- - -## Stage C — localized behavioral fixes - -(C1 landed: move operand classification is parse-level — -`moveOperandsProblem` in `src/cli/args.ts` rejects, inside `parseArgv` -(syntax-determined class: reported without loading configuration, before -any lock or hold), a move operand with more than one `#` (malformed value) -and a mixed-synopsis invocation, both directions; the non-UTF-8 positional -exemption is removed, so every argument value is UTF-8-checked. The -handler's one-direction mixed check and its non-UTF-8 `<new-id>` refusal -are gone — unreachable, guarded by internal errors. T6.5-5, T6.6-3, and -T12.0-13's move arms behave; T12.0-13 still aborts earlier, at its `show -a#b#c` arm — C2's scope, see its note.) - -(C2 landed: gated-read argument checks precede the gate. The read -pre-answer step is split — `analyzeWorkspaceForRead` (pure) + -`assessWorkspaceRead` (gate decision with a deferred `commit()` write) in -`src/workspace/refresh.ts`, CLI faces `analyzeGraphForRead` / -`finishGraphForRead` in `src/cli/prepare.ts` — so handlers run identity -checks between analysis and gate report. The parse-local judgments are -`nodeOperandProblem`/`graphNodeValueProblem` in -`src/cli/commands/gated-args.ts` (discovery kind → spelled identities / -named units; unparseable named file masks the id/unit half), sharing -query-core's exported message builders so the store-backed fast path -reports byte-identically. `loadSessionForCommand` now orders: name → -analyze → existence by directory entry (`sessionOccupied`, -`workspace/reviews.ts`, no content read) → gate (assess; findings exit 1, -session unread) → load (corrupt → 14.21, passing workspaces only) → -recorded-baseline resolution → `commit()`. Multi-`#` `<node>`/`<graph-node>` -values are parse-level malformed values (`identityValueProblem` in -`src/cli/args.ts`, `identityPositionals`/`identityValue` marks on `show`, -`query node`/`subtree`/`ancestors`, `edges`/`reachable` `--from`/`--to`) — -reported without loading configuration.) - -(C2b landed: baseline resolution is split around the gate — -`readBaseline` (ref resolution, tree listing, journal prefix/replay; -failures exit 2 pre-gate) and `validateBaselineContent` (baseline content -parsed and validated as a workspace; exit 2, reachable only past the gate -and before the refresh `commit()`), both `src/workspace/baseline.ts`; -`resolveBaseline` remains their composition for the post-gate -recorded-baseline call site (`loadSessionForCommand`). `impact` and -`review create` sequence: read → `analyzeGraphForRead` → -`assessWorkspaceRead` (findings exit 1) → validate content → -`commit()`. `computeJournalReplay` (`src/core/journal.ts`) now judges only -replay-suffix lines — `ParsedJournal.findings` are `PositionedJournalFinding` -(line-carrying; the extra member never renders) — so a garbage line inside -the shared prefix is the gate's 14.13, never a replay failure. T6.3-1..4 -green; T13.3-3 green in full since C3; T13.3-2's corrupt-record arm stays -red on C4, as its note says.) - -(C3 landed: 14.22 covers every non-directory component. -`obstructedWritePathFindings`/`obstructedComponentOf` -(`src/workspace/writes.ts`) judge each write path's components -shallowest-first, stopping at the first non-directory or missing one; one -finding per distinct offending component, concerned path the component, -findings in component byte order. `classifyOccupant` maps ENOTDIR/ELOOP to -"absent" (nothing occupies a path below a non-directory — `probeOccupant` -is now its alias), so `check`'s per-file staleness compare reports the -unreachable derived file "missing" instead of crashing; the removal helpers -skip any obstructed path. Gate side: `assessWorkspaceRead` -(`src/workspace/refresh.ts`) computes build with a null store and evaluates -the FULL `build.writePaths` before any store read — obstructions are the -gate's findings, exit 1, store unconsulted; the store-backed fast paths -(`verifyStoreForRead`, `src/workspace/fast-read.ts`, shared by `query` and -`at`) verify the write set unobstructed as step 5 (`generatedDerivedPaths` -+ `GRAPH_DATA_PATH`), falling back to the full path otherwise.) - -(C4 landed: the loaded store is three-state — `LoadedGraphData.state` is -`"absent" | "readable" | "unreadable"` (`src/workspace/graph-data.ts`; -`readDerivedFileRecord` now derives from the same `loadGraphData`, one -classification rule — a non-plain occupant, non-UTF-8, or unparseable -bytes are "unreadable", a path below a non-directory "absent"). Both -refresh points — `assessWorkspaceRead` (`src/workspace/refresh.ts`) and -`finishAvailabilityRefresh` (`src/workspace/availability.ts`) — skip the -predicate and the write on "unreadable": the read answers from current -analysis at exit 0, reports no finding, and the store stays byte-for-byte -(symlink occupants included) until `build` or a finishing regeneration -replaces it. `check`'s `stalenessFindings` (`src/workspace/check.ts`) -branches unreadable → `unreadableRecordStaleFinding()` (one condition-10 -finding, concerned path `GRAPH_DATA_AREA`, locations empty), else the -mismatch predicate — never both, and orphan reporting is empty by -construction there (null stored data feeds `computeBuildOutputs`). -T13.3-2 and section-6.6/11.6/14 files green; T12.2-2's family 4 conforms -(verified by hand) but the test aborts earlier, at its family-3 missing -arm, on C5's defect.) - -(C5 landed: both 14.10 unit forms concern the graph-data area — -`mismatchedGraphDataStaleFinding()` in `src/workspace/check.ts` replaces -the per-file `staleFinding(GRAPH_DATA_PATH, …)` arm: one condition-10 -finding, concerned path `GRAPH_DATA_AREA` (`.xspec`), locations empty, -covering missing and mismatch as one form beside C4's unreadable arm. -Section-12.1-12.2, section-13.3, and section-14 files all green.) - -(C6 landed: review payloads carry source ranges for every present node — -`nodeStateJson` (`src/cli/commands/review-session.ts`) presents a present -code-location scope as identity + presence + `sourceRange` -(`CodeLocationNode.range`, B3), and `originEntryJson` puts a -currently-present origin node's `sourceRange` on the entry itself (the -after side is the current graph's; a currently-absent node carries none). -Section-10.7 files, 1.6-1.7, and 10.2-10.3 all green.) - -### C7. Full-suite verification sweep - -Prereq: all tasks above removed. Run `npm run typecheck`, `npm run format:check`, -`npm run build`, `npm test` (Linux full suite) — every test must pass. Push and -confirm the branch-head CI runs: harness-self, full suite (Linux), and the -Windows E-6 leg (its byte-identity test consumes the Linux run's exchange -artifact; see `AGENTS.md`). Diagnose any residual failure against SPEC.md -(property seeds are replayable: `XSPEC_PROPERTY_SEED=<seed from the failure>`); -fix small residues directly, or append precise tasks here for anything larger. -Product green + this file emptied ends the phase (delete this file when its last -task is removed). - -(2026-08-30 sweep, product side landed: the sweep found two reds, P-11 and -P-12, both rooted in per-invocation cost, and fixed the product half. The -canonical serializer (`src/core/canonical-json.ts`) is now an iterative -single-buffer emitter — linear time, no recursion — with pretty indentation -bounded at 32 levels (`MAX_INDENT_LEVELS`): byte-identical output for every -document nested within the bound (verified over view/query/check/graph-data on -a staged workspace), and a depth-4096 fuzz tower's `view` answer is ~35 MB in -~4 s instead of quadratic-indentation gigabytes; `view`'s node-tree walk -(`src/cli/commands/view.ts` `nodeJson`) builds iteratively, children before -parents, so depth-4096 nesting no longer overflows the call stack. The -TypeScript compiler module loads once through `createRequire` -(`src/core/ts-module.ts`; all four former ESM import sites route through it), -cutting every configuration-parsing invocation ~0.5 s → ~0.26 s — that took -P-12 from a 600 s timeout to 321 s green locally. Remaining red: P-11 alone, -now failing INSIDE THE HARNESS — its recursive `decodeViewNodeForm` -(`test/helpers/adapters/forms.ts`) overflows on the depth-2048 view tree its -own generator draws (CI seed 271828183, trial 1) after the product answers -conformingly; no product change can clear it (SPEC 11.4 mandates the full -tree). Logged 2026-08-30 in `specs/tmp/TEST-SPEC-PROBLEMS.md`. CI at e239f0c (run -33298889697) confirms: harness-self green; suite-linux 636/637 with P-11's -harness error the sole red; Windows E-6 leg 8/9, its byte-identity arm -starved of the exchange artifact — the suite-linux upload step has no -`if: always()`, so any Linux test failure skips the upload (0 artifacts on -the run); that wiring self-heals the moment suite-linux is green, so the -Windows byte-identity signal arrives only then. When the harness-side fix -lands, rerun this sweep — expected then: full suite green, all three CI -legs green, empty this file.) From 1f74dd127fc92240b8a54ed687ddd4e4f86f32b8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 04:07:06 +0000 Subject: [PATCH 183/226] =?UTF-8?q?sdg(phase-9):=20plan=20the=20harness=20?= =?UTF-8?q?re-descent=20=E2=80=94=20FIX=5FPLAN.md=20with=2047=20tasks=20fr?= =?UTF-8?q?om=20the=20compliance=20determination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the three compliance reviews (TEST-SPEC §0-8, §9-16, §17-18 + CERTIFICATIONS.md) and the red VERIFY report at 831df39 into a standalone task list: certification gate (CONF-CORE refresh, T13.5-1 stale arm, VIOL-CORE-EARLYREFRESH, manifest/pins), H-11 capacity (non-recursive decoders, S-8, S-2, T1.3-7), the CONF-DISC code-group surface, and the remaining suite gaps in section order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 1209 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1209 insertions(+) create mode 100644 specs/tmp/FIX_PLAN.md diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md new file mode 100644 index 0000000..ee09a5b --- /dev/null +++ b/specs/tmp/FIX_PLAN.md @@ -0,0 +1,1209 @@ +# FIX_PLAN — Phase 9 (re-descent): harness adherence to `specs/TEST-SPEC.md` + `specs/CERTIFICATIONS.md` + +Source: the Phase 9 compliance determination at 831df39 (three reviewers over +TEST-SPEC §0–8, §9–16, §17–18 + CERTIFICATIONS.md, plus a red VERIFY run: +`npm test` 634 passed / 3 failed — self `certification-document.test.ts` ×2 and +suite `section-16-p11.test.ts` as an H-8 harness error). The Phase 6/7 revisit +of TEST-SPEC.md and CERTIFICATIONS.md (commits 22e5ead..897bfea, 587edca..3b61122) +changed or added the requirements below; `test/` was untouched since, so every +task here is harness work. Goal of this phase: all harness self-tests and +certifications pass (`npm run test:self` green, C-1 exact); product tests may +fail, but only as diagnosed product failures (H-8) — never as harness errors. + +**Rules for every task (read once per spawn):** + +- Phase 9: never modify product code (`src/`). Harness only: `test/`, `.github/`, + harness configuration. Never modify any `specs/` document except this file and + the problems files (`specs/tmp/*-PROBLEMS.md`, dated, only for a genuine spec + defect that blocks you — never work around one silently). Never read + `specs/PHILOSOPHY.md`. +- The harness never imports product code; it drives the built `xspec` executable + (or a certification fixture) as a subprocess through the one `ProductBinding` + path (TEST-SPEC C-2). Keep every new arm on that path. +- Product-test bodies live in `test/suite/registry/section-*.ts` (each module + exports an array in canonical ID order), aggregated by `test/suite/registry/ + index.ts` (`ProductTestSuite`; duplicate IDs fail at import time) and run by + the thin wrappers `test/suite/section-*.test.ts` (`declareProductTests`). + Every test ID needs its SPEC-section entry in the H-7 map + `test/suite/registry/traceability.ts`, checked by `test/self/s1-traceability.test.ts` + (and `s7-red-green-sweep.test.ts` sweeps the registry) — a new test ID is not + done until the ID is registered, mapped, and the self project is green. +- Certification (TEST-SPEC C-1): a test is certified when it passes against its + conformer and fails against each violator that certifies it, and every + violator fails exactly its certified set. The manifest is + `test/self/certification-fixtures.ts`; the whole-document gate is + `test/self/certification-document.test.ts`; fixtures are plain Node ESM + programs under `test/fixtures/<conf>/` (`bin.mjs`, `bin-<deviation>.mjs`, + shared `product.mjs` with one `deviations` switch per violator) — run by hand as + `node test/fixtures/<conf>/bin.mjs <command> …` in a staged workspace. +- Build first (`npm run build`; run `npm ci` if `node_modules` is missing), then + run the named tests: `npx vitest run --config test/vitest.config.ts --project + suite test/suite/<file>.test.ts`; self-tests + certification: `npm run + test:self`; property seeds: `XSPEC_PROPERTY_SEED=<uint32>` (see `AGENTS.md`). + Before committing: `npm run typecheck` and `npm run format`. +- A task's named tests are its verification. For a changed or new product test: + run it against the built product — it passes, or fails as a diagnosed product + failure; a harness error (crash, stack overflow, exhausted limit, decode + exception outside the assertion protocol) is never acceptable. For a test in + a certification scope, additionally run `npm run test:self` and confirm C-1. +- Work top to bottom: Stage A turns the self project green (its four tasks form + one dependency chain); Stage B removes the H-11 harness error; Stage C adds the + CONF-DISC code-group surface; Stage D is the remaining suite gaps in section + order (independent of each other unless a task names a prerequisite). +- Commit `sdg(phase-9): <imperative summary>`, ending every commit message with + the two trailer lines + `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>` and + `Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2`; + push with `git push -u origin claude/xspec-ui-apis-4df8fa` (retry on network + errors with 2s/4s/8s/16s backoff). Never merge or fetch `main`. +- When a task is done, remove it from this file in the same commit. If a task is + too large for one spawn, land a coherent part and replace the task with precise + remainder task(s). When the last task is removed, delete this file. Record any + new build/lint/run knowledge in `AGENTS.md` (nothing else belongs there). + +--- + +## Stage A — certification gate: CONF-CORE refresh, T13.5-1 stale arm, VIOL-CORE-EARLYREFRESH, manifest + +### Task 1 — CONF-CORE conformer: 13.3 refresh inside mutating `review` subcommands, after the hold + +Cites: CERTIFICATIONS.md §CONF-CORE Scope ("on a workspace whose graph data is +stale but whose sources are valid … the 13.3 refresh a mutating `review` +subcommand performs after the hold and before its own writes, writing graph +data byte-identical to what `build` writes (13.3, T10.1-1), as T13.5-1's +stale-workspace arm observes it"); §VIOL-CORE-EARLYREFRESH (whose passing side +needs the conformer's refresh to be distinguishable); TEST-SPEC T13.5-1 +stale-workspace arm; SPEC 13.3, 13.5. + +Now: in `test/fixtures/conf-core/product.mjs` graph data (`.xspec/graph.json`) +is written only by `regenerate()` (≈ line 1112), whose call sites are `build` +(≈ 2021), `rename` (≈ 2390) and file-form `move` (≈ 2452). `review create`, +`resolve`, `split` call `loadGraph` only (≈ 2545, 2626, 2724), so a stale +workspace stays stale across a mutating `review` subcommand and the conformer +would fail T13.5-1's stale arm (Task 2). + +Do: in `runMutating` (≈ 1971) — or in each mutating `review` subcommand's +`operate` body — after exclusivity is acquired and the hold has been created and +released (`holdIfRequested`, the `writesBeforeHold` deviation's ordering +untouched), and before the subcommand's own writes (session file, journal), +refresh stale graph data: compare the stored graph against the current sources +exactly as the read path decides staleness, and when stale write graph data +byte-identical to what `build` writes (reuse `regenerate` or the same +serialization; nothing else written). A workspace whose graph data is current +must be refreshed by nothing (byte-identical graph data). Keep `rename`/`move` +and every read command as they are. Add a short comment naming the CERTIFICATIONS +§CONF-CORE scope sentence. Design the insertion so Task 3's single deviation +switch can move the refresh to before exclusivity is acquired. + +Verify: stage by hand a workspace (one spec group, two `.mdx` sections), `node +test/fixtures/conf-core/bin.mjs build`, edit a section's text, run `… review +create --name n --strategy audit --test-hold <path outside the workspace>` in +the background: while the hold file exists, `.xspec/graph.json` is byte-identical +to its pre-invocation bytes; delete the hold file; after exit 0 the graph data +equals the bytes a fresh `build` on a twin workspace writes and the session +exists. `npm run test:self` shows no new failures (the two +`certification-document` failures remain until Task 4). + +### Task 2 — T13.5-1: stale-workspace arm (`review create --strategy audit --test-hold` on stale graph data) + +Cites: TEST-SPEC T13.5-1 ("Stale-workspace arm (13.5: the hold precedes every +modification, the 13.3 refresh included): on a workspace whose graph data is +stale — a section's text edited after `build`, the workspace still valid — +`review create --strategy audit --test-hold`: while held, graph data, and every +other workspace file, is byte-identical to its pre-invocation state; after +release the session is created and the graph data refreshed (T10.1-1) — +discriminating a product that refreshes before acquiring exclusivity"); +CERTIFICATIONS.md §CONF-CORE staging constraints (the stale arm is the only +in-scope mutating command started on stale graph data; the basic arm and every +other held command start on a freshly built workspace with no refresh pending); +§VIOL-CORE-EARLYREFRESH expected failures (fails on the while-held compare of +this arm alone). SPEC 13.3, 13.5. + +Now: `test/suite/registry/section-13.5.ts` T13.5-1 has the basic, seam-neutrality, +occupied-hold-path and unknown-flag arms; no stale/refresh staging anywhere in +the file. + +Do: add the arm to T13.5-1 (same ID, same registered body): build a workspace +drawn from CONF-CORE's surface (one spec group, plain `.mdx` sections, no +imports/`d`/embeddings/tags, no git, no `code`/`markdown`/`coverage`/`policy`), +`build` it, edit one section's own text so the workspace stays valid, snapshot +every workspace file (`.xspec/` included), then run `review create --name <n> +--strategy audit --test-hold <path outside the workspace>` through the +existing hold choreography; while held assert the whole workspace byte-identical +to the snapshot (graph data included — the compare must be scoped to include +`.xspec/graph.json`, the one file a pre-hold refresh would change); release; +assert exit 0, the session file exists, and graph data now equals what `build` +writes on an identical twin (product-to-itself compare under H-4/H-6, as the +seam-neutrality arm already does). Keep every other arm's fixture freshly built +(the CERTIFICATIONS freshness constraint). + +Verify: `npx vitest run --config test/vitest.config.ts --project suite +test/suite/section-13.5.test.ts` — T13.5-1 passes against the built product or +fails as a diagnosed product failure (report which); `npm run test:self`: the +CONF-CORE conformer (Task 1) passes T13.5-1, VIOL-CORE-EARLYWRITE still fails it, +no other certification change. + +### Task 3 — VIOL-CORE-EARLYREFRESH executable and deviation switch + +Cites: CERTIFICATIONS.md §VIOL-CORE-EARLYREFRESH (Scope CONF-CORE; Deviation: +"The 13.3 refresh a mutating `review` subcommand performs on a stale workspace +(T10.1-1) runs before workspace exclusivity is acquired, so stale graph data is +rewritten before the hold file is created. A single deviation … the hold file is +still created after exclusivity and before every other write … and a workspace +whose graph data is current is refreshed by nothing"; Certifies: T13.5-1; +Expected failures: exactly T13.5-1, on the stale arm's while-held compare). + +Now: no `test/fixtures/conf-core/bin-earlyrefresh.mjs`; `product.mjs` has no +corresponding switch (existing switches: `noMutualExclusion`, `writesBeforeHold`, +`staleLockBlocks`, `partialDerivedWrites`, `chattyReads`, `persistReadInvalidation`). + +Do: add a `deviations.refreshBeforeExclusivity` (name free) switch in +`product.mjs` consumed at exactly one point: in the mutating `review` +subcommands, run Task 1's refresh before exclusivity is acquired (before the +lock and the hold) instead of after the hold; everything else identical to the +conformer. Add `bin-earlyrefresh.mjs` mirroring `bin-earlywrite.mjs` (header +comment quoting the deviation and the certified set; `runXspec(argv, cwd, +{ <switch>: true })`). + +Verify: by hand, on a stale workspace as in Task 1, `node +test/fixtures/conf-core/bin-earlyrefresh.mjs review create --name n --strategy +audit --test-hold <path>`: while held, `.xspec/graph.json` already holds the +refreshed bytes; on a current workspace nothing differs from `bin.mjs`. Then run +T13.5-1 (Task 2) against this binding through the certification runner or a +one-off binding: it fails on the stale arm's while-held compare; T6.1-2, +T10.4-5, T13.4-5, T13.5-2..5 pass. (The manifest entry is Task 4.) + +### Task 4 — Certification manifest and whole-document pins for the revised CERTIFICATIONS.md + +Cites: TEST-SPEC C-1 (whole-document gate); CERTIFICATIONS.md §CONF-CORE +"In-scope tests: T6.1-2, T10.4-5, T13.4-5, T13.5-1, T13.5-2, T13.5-3, T13.5-4, +T13.5-5" (T6.1-1 excluded — see its Exclusions entry); §VIOL-CORE-CHATTYREADS +"Certifies: T13.4-5"; §VIOL-CORE-EARLYREFRESH "Certifies: T13.5-1", placed in +document order after VIOL-CORE-EARLYWRITE and before VIOL-CORE-STALELOCK; the +document now defines 5 conformers and 17 violators. + +Now: `test/self/certification-fixtures.ts` lists T6.1-1 in CONF-CORE's in-scope +set and in CHATTYREADS's certified set and has no EARLYREFRESH entry; +`test/self/certification-document.test.ts` pins `EXPECTED_VIOLATORS = 16` (line +35) and its test title says "16 violators" (line 268). Both gate tests fail. + +Do: edit the manifest verbatim to the document (drop T6.1-1 from both sets; add +`violator("VIOL-CORE-EARLYREFRESH", "conf-core/bin-earlyrefresh.mjs", +["T13.5-1"])` between EARLYWRITE and STALELOCK); set the pin to 17 and the title +to match; re-read the manifest header comment and the per-fixture generation in +`certification.test.ts` for any other count or ID pin. Prerequisites: Tasks 1–3. + +Verify: `npm run test:self` fully green — the document gate, the manifest +equality, and every per-fixture certification (EARLYREFRESH fails exactly +T13.5-1; CHATTYREADS fails exactly T13.4-5; CONF-CORE passes its eight in-scope +tests). + +## Stage B — H-11 answer-scale capacity (P-11 harness error), S-8, S-2, T1.3-7 + +### Task 5 — Make `decodeViewNodeForm` non-recursive (H-11) + +Cites: TEST-SPEC §0 H-11 ("the H-3/12.7 decoding, and every subsequent +per-datum traversal of an answer document … succeed, with harness-internal +capacity limits … dimensioned to the scales the suite itself stages … A +harness-side failure while capturing or evaluating an answer — a crash, hang, or +exhausted internal limit — is reported as a defect in the harness"); §16 P-8 +(staged nesting floor ≥ 2048; the suite stages `NESTING_DEPTHS = [512, 2048, +4096]` in `test/suite/registry/section-16-p8.ts` ≈ line 286) and P-11; §1.3 +T1.3-7; §17 S-8. + +Now: `test/helpers/adapters/forms.ts` `decodeViewNodeForm` (≈ lines 1623–1695) +calls itself once per `children` level, building nested `DecodeSite`s through +`at(site, …)`; at the depth-2048 tower it throws `RangeError: Maximum call +stack size exceeded` (frames `forms.ts:1694 → :1650 → decodeRangeForm :161`). +Reproduce: `npm run build && XSPEC_PROPERTY_SEED=271828183 npx vitest run +--config test/vitest.config.ts --project suite test/suite/section-16-p11.test.ts` +(trial 1, "specs/B.mdx: append a depth-2048 balanced section tower", arm `view`). + +Do: rewrite the `view` node decoding as an explicit-stack (iterative) traversal +that performs exactly the same checks in the same order per node — member +allow-list with/without `--text`, identity datum never `null`, range, opening/ +closing, attributes in tag order strictly ascending, tags/coverage three-state +datums, children in document order strictly ascending — and produces the same +`ViewNode` tree and the same `formFail` sites/messages (S-5's output-adapter +self-tests must stay green). Node's V8 `JSON.parse` is iterative, but confirm +with the depth-4096 vector that no other step in `decodeViewReport` (path +building, `at`, error rendering, `structuredClone`/`JSON.stringify` on failure) +recurses per level. No depth cap of any kind. + +Verify: the reproduction above no longer reports a harness error (P-11 passes +or fails as a diagnosed product failure); `npm run test:self` green +(`s5-output-adapters.test.ts` in particular); `npm run typecheck`. + +### Task 6 — De-recurse every other per-datum walk over answer documents (H-11 audit) + +Cites: TEST-SPEC §0 H-11 (every subsequent per-datum traversal, "16's property +walks included"); §16 P-8, P-11; §17 S-8. + +Now: at least two more walks recurse natively over `view` trees: +`assertUnavailabilityMarkerForms` in `test/helpers/adapters/forms.ts` (the +`unavailable`-exclusivity walk) and `documentCarriesUnavailability` in +`test/suite/registry/section-16-p11.ts`. Others may exist (Markdown/oracle +walks over nested sections, `query subtree` row handling, the E-6 comparators, +fixture generators that emit nested towers recursively, `test/helpers/ +property.ts` shrinkers). + +Do: grep `test/helpers/` and `test/suite/registry/` for functions that call +themselves (or mutually) per nesting level of an answer document or of a staged +tree, and convert each to an explicit stack/queue with identical semantics and +ordering. Cover the two named walks first. Dimension: depth 4096 (the largest +staged nesting) must pass with margin; do not introduce depth caps. Leave a +one-line comment at each converted site citing H-11. + +Verify: `npm run test:self` green; the P-11 reproduction from Task 5 and +`test/suite/section-16-p8.test.ts` run without harness errors; `npm run +typecheck`. + +### Task 7 — S-8 answer-scale capacity self-test (decoders, walks, capture gate) + +Cites: TEST-SPEC §17 S-8 ("The H-3/12.7 decoders and every answer-document +walk the suite performs succeed, every datum evaluated without harness error, +on synthetic conforming-form documents at the maximum answer scale H-11 +obliges … expansion blowup included, a `view --text` answer multiplying +embedded subtree text through each expansion level past its staged input's own +size … among them a `view` document nested at least as deep as P-8's +giant-nesting floor. Capture is gated at the same scale through S-3's stand-in +mechanism: a stand-in command emitting the largest of these synthetic documents +on standard output is driven through the H-2 capture path product invocations +use, and the captured bytes MUST be complete and identical to what the stand-in +emitted"); §0 H-11 (an exhausted capture limit must surface as a loud harness +error, never silent truncation). + +Now: no `test/self/s8-*` file; nothing under `test/` references S-8 or H-11. +The capture cap is `DEFAULT_MAX_OUTPUT_BYTES` (64 MiB) in +`test/helpers/subprocess.ts` ≈ line 52; overflow raises +`ProductRunOutputOverflowError` (plain `Error`, runner outcome "error" — the +H-11 classification, already correct). S-3's stand-in pattern is in +`test/self/s3-subprocess-driver.test.ts` (`STANDIN_SOURCE`, `standin()`). + +Do: add `test/self/s8-answer-scale-capacity.test.ts` (S-8 in its header +comment). (a) Derive and document, in the test, the maximum answer scale over +the suite's staged inputs: the deepest staged nesting (4096 from P-8's +`NESTING_DEPTHS`; floor 2048 from T1.3-7), the largest staged document size +(sweep deterministic fixtures and every generator's size bounds in +`test/suite/registry/section-16-*.ts` and `test/helpers/`), and the `view +--text` expansion blowup (embedded subtree text re-emitted at each expansion +level: compute the bound from the deepest/largest embedding chain any staged +input can hold, per SPEC 11.4). (b) Synthesize conforming-form documents at +those scales in memory (a `view` document nested ≥ 4096 levels; a `view --text` +document at the blowup size; the largest `query`/`occurrences`/`build --json` +shapes if any walk consumes them at scale) and drive each through every H-3/12.7 +decoder in `test/helpers/adapters/` and every answer-document walk the suite +performs (the P-11 walk, `assertUnavailabilityMarkerForms`, any decoder-level +ordering checks), asserting no exception and the expected datum count. +(c) Capture gate: a stand-in Node script that writes the largest synthetic +document to stdout (streamed in chunks, then exit 0), driven through the same +`ProductBinding`/run path product invocations use; assert the captured stdout +bytes are complete and byte-identical to the emitted document and that the +run's outcome is a normal completion. If the document exceeds +`DEFAULT_MAX_OUTPUT_BYTES`, raise the default (H-11: dimensioned to staged +scales) and keep a separate assertion that an over-cap stand-in surfaces as +`ProductRunOutputOverflowError` (loud, never truncation). Prerequisites: Tasks +5–6 (otherwise the gate is red). + +Verify: `npm run test:self` green including the new file; the test runs in the +`self` project under the CI `harness-self` job (check `test/vitest.config.ts` +project globs include it); `npm run typecheck`. + +### Task 8 — S-2 scale vectors: ≥ 2048-deep document and the largest staged document, read back byte-complete + +Cites: TEST-SPEC §17 S-2 ("with scale vectors at the suite's staged maxima — a +document nested at least at P-8's giant-nesting floor and one at the largest +document size the suite stages (deterministic fixtures and generator draws +alike, 16), each read back byte-complete — so a truncating writer or +recursion-limited serializer cannot silently stage shallower or smaller inputs +than declared"); §16 P-8. + +Now: `test/self/s2-workspace-builder.test.ts` covers newline kinds, BOMs, +invalid UTF-8, tree fidelity, scripted git, root escape, dispose — no nesting or +size vector. + +Do: add two vectors to that file: (a) a workspace whose one `.mdx` file nests +sections 4096 levels deep (build the bytes iteratively; reuse or mirror the +generator P-8 uses so the vector is the tower the suite actually stages), and +(b) a file at the largest document size the suite stages (state the derivation +in a comment: the max over deterministic fixtures and generator size bounds, +sharing Task 7's derivation if it landed — otherwise compute it here and let +Task 7 reuse it). Write each through `test/helpers/workspace.ts`'s builder and +read it back from disk with plain `fs`, asserting byte-for-byte equality and +the declared byte length; assert the builder's own file/tree listing reports the +file once with the full size. No recursion in the vector construction. + +Verify: `npm run test:self` green; `npm run typecheck`. + +### Task 9 — T1.3-7 Depth: deterministic ≥ 2048-deep workspace (new test) + +Cites: TEST-SPEC T1.3-7 ("a valid workspace whose one file nests sections at +least 2048 levels deep (P-8's giant-nesting floor, 16) builds with exit 0; +`query subtree` on the root returns the root plus every section, in document +order, the count asserted; `view` serves the full positional tree. The +deterministic anchor of P-8's floor outside the generator machinery, and a +deterministic exercise of the harness's answer-scale capacity (H-11, S-8)"); +SPEC 1.3, 11.1, 11.4. + +Now: no registry entry, no H-7 map entry, nothing named T1.3-7 under `test/`. + +Do: in `test/suite/registry/section-1.3.ts` add a `T1.3-7` body: stage one +`.mdx` file with a 2048-deep chain of nested sections (distinct valid IDs; +build the bytes iteratively; keep every other section-1.3 fixture untouched), +`build` → exit 0 (no findings); `query subtree` on the root (existing +query-adapter decoding) → exactly the root plus 2048 sections, in document +order, IDs asserted by count and by first/last/sampled identities; `view` on +the file → the full positional tree decoded through `decodeViewReport`, depth +asserted by iterative walk. Register the ID in the module's exported array, +add `"T1.3-7": ["1.3"]` (plus 11.1/11.4 if the H-7 map convention lists every +cited section — follow the neighbouring entries) to `traceability.ts`. +Prerequisite: Task 5 (the `view` decode must not overflow). + +Verify: `npx vitest run --config test/vitest.config.ts --project suite +test/suite/section-1.3.test.ts` — passes against the built product or fails as +a diagnosed product failure, never a harness error; `npm run test:self` green +(S-1, S-7). + +## Stage C — CONF-DISC code-group surface (fixture and suite) + +### Task 10 — CONF-DISC conformer: code groups of well-formed `.ts` sources and `query edges --from <path>` + +Cites: CERTIFICATIONS.md §CONF-DISC Scope (revised): "code groups (7.2) of +well-formed `.ts` sources spelling no marker, spec-module import, or `text` +call (4) — each discovered code source an edgeless whole-file code location +(4.6)"; command surface "`query edges --from <path>` (11.1) as the observation +of the discovered code set — for a discovered code source's whole-file +location, exit 0 with its empty edge enumeration, the JSON document 11 makes +its only output form; for a path in no configured group, an excluded derived +path included, the usage error of 12.0 (exit 2, the error document of 12.7), a +check preceding the gate of 13.3"; the 13.4 exclusion applies to code globs too +(the module `build` generates next to a source, files under `.xspec/`, enabled +emit destinations). SPEC 7.2, 11.1, 12.0, 12.7, 13.1, 13.4. + +Now: `test/fixtures/conf-disc/product.mjs` refuses any non-empty `code` group +("outside this fixture's scope", ≈ line 526) and implements no `query` +command; `build` on a discovered spec source generates the next-to-source +module (13.1) that a code glob would match. + +Do: extend the conformer: accept `code` groups (same glob grammar, dot-segment +rule, no link following, byte-wise matching as the spec side; patterns +resolving outside the root → 14.14), discover `.ts` matches, exclude 13.4's +derived paths from the code set exactly as from the spec set (generated +modules next to sources, `.xspec/` paths, enabled emit destinations), treat +each discovered code source as an edgeless whole-file location (no parsing +needed beyond that the file is a well-formed `.ts` the scope stages; keep +14.14's both-groups rule dormant but implemented if cheap). Add `query edges +--from <path>`: for a discovered code source's whole-file location, exit 0 and +the empty edge enumeration in the exact 11/12.7 JSON form the harness's query +adapter decodes (`test/helpers/adapters/query.ts`; match the real product's +form byte-for-byte for the empty case); for a path in no configured group +(derived paths included), exit 2 with the 12.7 error document (stable code and +concerned path as the existing usage-error path renders them), decided before +any 13.3 gate. Keep every existing behavior and the other three violators' +switches unchanged. Update the file header's scope description. + +Verify: by hand on a staged workspace with a spec group and a code glob such as +`src/**/*.ts` that also matches `specs/*.ts` generated modules: after `build`, +`query edges --from src/a.ts` → exit 0, empty enumeration; `--from +specs/A.xspec.ts` (or whatever 13.1 name the fixture generates) → exit 2 error +document; `--from .xspec/graph.json` → exit 2. `npm run test:self` stays green +(T7-4..T7-6 as currently implemented still pass; each DISC violator still fails +exactly its set). + +### Task 11 — VIOL-DISC-DERIVED: code-group side of the dropped 13.4 exclusion + +Cites: CERTIFICATIONS.md §VIOL-DISC-DERIVED Deviation (revised): "a path … +when matched by a spec-group or code-group glob, is treated as an ordinary +match — on the code side an edgeless whole-file location"; Expected failures: +"on the code-group side, each excluded path the code glob matches — the module +`build` generated next to its source, the staged file under `.xspec/`, the +enabled destination — enters the discovered code set, so `query edges --from` +answers it exit 0 where the arm asserts the unknown-path refusal (12.0)". + +Now: the `noDerivedExclusion` switch in `test/fixtures/conf-disc/product.mjs` +(consumed ≈ line 1040) drops the exclusion on the spec side only; the +code-group side does not exist before Task 10. + +Do: make the same single switch also skip the 13.4 exclusion for code-group +matches (Task 10's code discovery), so `query edges --from <derived path +matched by the code glob>` answers exit 0 with an empty enumeration under +`bin-derived.mjs`; update `bin-derived.mjs`'s header comment to the revised +deviation text. No other behavior changes. Prerequisite: Task 10. + +Verify: by hand (Task 10's workspace): `node test/fixtures/conf-disc/ +bin-derived.mjs query edges --from specs/<generated module>` → exit 0, while +`bin.mjs` → exit 2; `npm run test:self` green. Once Task 12 lands, the +certification shows DERIVED failing T7-6 on both group sides. + +### Task 12 — T7-6: code-group exclusion arm observed through `query edges --from` + +Cites: TEST-SPEC T7-6 ("Derived files are never discovered as sources even when +globs match them (`.xspec.` names, `.xspec/` paths, Markdown emit destinations +while emission is enabled, 13.4)" — for every group kind); CERTIFICATIONS.md +§CONF-DISC staging constraints ("T7-6's exclusion arms are staged on both group +sides — the spec side observed through `ids`, the code side through `query +edges --from <path>` (11.1): on a workspace passing `build`'s validations, each +excluded path a code glob matches — the module `build` generated next to its +source (13.1) above all, a file under `.xspec/`, and an enabled emit +destination — is refused as a path in no configured group (exit 2, 12.0), +beside a discovered code source's whole-file location answering exit 0, T7-3's +idiom for code discovery; and the staged code globs match, beyond those +derived-classified paths, only the well-formed `.ts` sources above, no +spec-group file among them"); SPEC 7.2, 11.1, 12.0, 13.1, 13.4. + +Now: `test/suite/registry/section-7-discovery.ts` T7-6 (≈ lines 599–812) has +derived-file exclusion arms observed through `ids` only (spec side), the +import arms, the no-match and empty-map arms; no code group, no `query edges`. + +Do: add a code-group exclusion arm to T7-6: configuration with a spec group +and a code group whose glob (e.g. `**/*.ts` under the root, or a pattern +covering `specs/` and `src/`) matches one well-formed `.ts` code source +(no marker, no spec import, no `text` call), the module `build` generates next +to the spec source, a staged `.ts` file under `.xspec/`, and, with `markdown` +emission enabled, a staged file at a source's emit destination — and matches no +spec-group file; `build` → exit 0; then `query edges --from <code source>` → +exit 0 with an empty edge enumeration (decoded through the query adapter); +`query edges --from <each excluded path>` → exit 2 with the 12.7 error document +(the existing usage-error decoding), never a finding, nothing modified. Use +the query adapter's existing `edges` decoding; do not add product-specific +wording assertions. + +Verify: `npx vitest run --config test/vitest.config.ts --project suite +test/suite/section-7-discovery.test.ts` (passes against the built product or a +diagnosed product failure); `npm run test:self`: CONF-DISC conformer passes +T7-6 (Task 10), VIOL-DISC-DERIVED fails exactly T7-6 (Task 11), DIALECT and +SYMLINK unchanged. + +## Stage D — remaining suite gaps, in TEST-SPEC section order + +### Task 13 — T1.2-3: locate the root's ignored reason through the adapter, not a literal + +Cites: TEST-SPEC T1.2-3 (the root's reason is adapter-located; no wording is +pinned — the same discipline T8.2-1 applies through `classifyIgnoredReasons`); +SPEC 1.2, 8. + +Now: `test/suite/registry/section-1.1-1.2.ts` ≈ lines 742–745 asserts the reason +list literally as `["root node"]`. + +Do: replace the literal with the adapter classification used by T8.2-1 +(`classifyIgnoredReasons` or the helper the coverage adapter exports): assert +the root's entry classifies as the root-node reason and nothing else; keep the +count/identity assertions. + +Verify: `npx vitest run --config test/vitest.config.ts --project suite +test/suite/section-1.1-1.2.test.ts`; `npm run test:self` green. + +### Task 14 — T1.3-6: valueless-`id` (`<S id>`) masking arm — bearer's own code is 14.17, never 14.1 + +Cites: TEST-SPEC T1.3-6 (masking: a bearer whose `id` is not a plain static +string withdraws identity for its subtree; the valueless spelling `<S id>` is +the 2.7 form-invalid prop — condition 14.17 on the bearer, and never 14.1); +SPEC 1.3, 2.7, 14.1, 14.17. + +Now: `test/suite/registry/section-1.3.ts` ≈ lines 409–414 stages only the +repeated-`id` and braced-`id` bearers. + +Do: add a third bearer arm in the same fixture family: `<S id>` (no value) +enclosing a valid descendant; `build` → the bearer reports exactly one 14.17 +finding located at the prop/tag as the existing arms locate theirs, no 14.1 +anywhere, and the descendants' masking outcome identical to the other two +arms (same identities/unavailability). Use exact per-condition counts as +`section-11.2.ts` 968–971 does. + +Verify: `npx vitest run … test/suite/section-1.3.test.ts`; `npm run test:self` +green (CONF-VALID's in-scope T1.3-6 still passes the conformer, both VALID +violators still fail exactly their sets). + +### Task 15 — T2.7-3: valueless `<S id>`, `<S id="x" coverage>`, `<S id="x" tags>` arms; export the shared `tags` fixture + +Cites: TEST-SPEC T2.7-3 (every prop of 2.7 spelled valueless is form-invalid — +14.17; `<S id>` reports 14.17 and no 14.1); T11.4-3 ("the valueless prop must be +staged as T2.7-3's `<S id="x" tags>` — one fixture shared by build and view"); +SPEC 2.5, 2.6, 2.7, 14.17. + +Now: `test/suite/registry/section-2.7.ts` stages only valueless `d`. + +Do: add three arms — valueless `id` (exactly one 14.17 on the bearer, zero +14.1), valueless `coverage` on `<S id="x" coverage>`, valueless `tags` on +`<S id="x" tags>` — each exit 1 with exactly one 14.17 finding located at the +prop, no other finding. Define the `<S id="x" tags>` source bytes as an +exported constant (with its expected finding location) so +`section-11.4.ts` (Task 39) stages byte-identical content for `view`; keep +the module's canonical ID ordering. + +Verify: `npx vitest run … test/suite/section-2.7.test.ts`; `npm run test:self` +green (CONF-AVAIL's revised bare-valueless → 14.17 behavior is in scope for +T11.4-3; nothing to change for T2.7-3 itself). + +### Task 16 — T4.5-4: callee-side arm — a shadowing local `text` makes `text(SPEC.a)` a condition-18 finding + +Cites: TEST-SPEC T4.5-4 (callee side: an inner-scope `function text(x: +unknown) {}` shadows the imported `text`; `text(SPEC.a)` inside that scope is +a condition-18 finding at that use; `build`/`check` exit 1; no `embeds` edge +or occurrence for it; `occurrences --file` answers on the failing workspace +with the finding; a control call outside the scope lists its `embeds` +occurrence); SPEC 4.5, 5.7, 11.3, 14.18. + +Now: `test/suite/registry/section-4.5.ts` ≈ lines 772–830 hold only the +`const SPEC` (binding-side) arm. + +Do: add the callee-side arm: a code file importing `SPEC` and `text`, with a +block/function that declares `function text(x: unknown) {}` and calls +`text(SPEC.a)` inside it, plus one control `text(SPEC.b)` at module scope. +Assert: `build` and `check` exit 1 with exactly one condition-18 finding at +the shadowed use's range; `query edges` (or the graph read the file already +uses) shows no `embeds` edge from the shadowed call and one from the control; +`occurrences --file <code file>` (11.2: answers per file on a failing +workspace) lists the control's `embeds` occurrence and reports the finding +for the shadowed call, in the adapter's decoded form. + +Verify: `npx vitest run … test/suite/section-4.5.test.ts`; `npm run test:self` +green. + +### Task 17 — T4.6-3: value-side boundary — `const s = text(SPEC.a)` attributes to `path#f` inside `f`, `path` at top level, never `path#s` + +Cites: TEST-SPEC T4.6-3 (attribution boundary: a `text(...)` call whose value +is bound by a `const` is attributed to the enclosing function's unit — `path#f` +inside `f`, the whole-file `path` at top level — never to the constant's own +name); SPEC 4.6. + +Now: `test/suite/registry/section-4.6.ts` has no value-side arm. + +Do: add the arm: one code file with `function f() { const s = text(SPEC.a); }` +and a top-level `const t = text(SPEC.b);`; after `build`, the graph/edge +enumeration (through the query adapter) attributes the first embedding to +`<path>#f` and the second to `<path>`, and no location `<path>#s` or `<path>#t` +exists anywhere in the answer. + +Verify: `npx vitest run … test/suite/section-4.6.test.ts`; `npm run test:self` +green. + +### Task 18 — T6.1-1: never-modifies sweep over every command surface + +Cites: TEST-SPEC T6.1-1 (the journal is appended only by `rename`/`move`; +every other command leaves the journal-bearing workspace byte-identical: +`build`, `check`, `coverage`, `impact --base HEAD`, `review list`, `query +nodes`, plus `ids`, `show`, `review create/resolve/split`, `occurrences`, +`view`, `at`, `inventory`, `version`, and `rename --preview` / `move +--preview`, each on a journal-bearing workspace); CERTIFICATIONS.md Exclusions +(T6.1-1 sweeps every surface — outside CONF-CORE); SPEC 6.1, 6.6. + +Now: `test/suite/registry/section-6.1.ts` ≈ lines 311–316 byte-compare only the +first six commands. + +Do: extend the sweep so each listed command runs on a workspace holding a +non-empty journal (and, for the review subcommands, an `audit` session and an +unblocked item; for `impact`, a resolvable commit) and the journal file is +byte-identical before/after each invocation (compare the journal — and, where +the existing sweep compares more, the same set); the `--preview` runs must +leave the journal and every source byte-identical. Keep each invocation's +own exit/answer decoded through the adapters (no assertion beyond a normal +completion is required here). + +Verify: `npx vitest run … test/suite/section-6.1.test.ts`; `npm run test:self` +green. + +### Task 19 — T6.4-2: keepable reference forms, whole-file byte contract, single-quoted `id` attributes + +Cites: TEST-SPEC T6.4-2 (revised: minimal in-place edits — a computed +double-quoted access `BASE["login-v2"]` → `BASE["login2"]` stays computed and +double-quoted; single-quoted `BASE['login-v2']` → `BASE['login2']` and → +`BASE['login-v3']` keep single quotes; dot access stays dot; `{text('login-v2')}` +and a `d` array entry `'login-v2'` keep single quotes; every rewritten `.mdx` +and `.ts` file — marker and `text` rewrites included — is asserted byte-equal to +an expected whole file; `id` attributes spelled single-quoted, on the renamed +section and on one descendant, stay single-quoted); SPEC 6.4, 2.7, 3. + +Now: `test/suite/registry/section-6.4.ts` ≈ lines 1017–1019 implement the +pre-revisit text (spot assertions on rewritten spellings only). + +Do: restage T6.4-2 with fixtures carrying each listed form (in `.mdx` `d` +props/arrays, `{text(...)}` embeddings, and a `.ts` file with markers and +`text` calls through both access forms), compose the expected post-rename +bytes for every touched file from SPEC 6.4's rules (only the ID segment's +characters change; quote kind and access form kept), and assert each rewritten +file byte-equal to its composed expectation (files untouched by the rename +byte-identical). Include the two single-quoted `id` attributes. Keep the +existing preview/journal assertions. + +Verify: `npx vitest run … test/suite/section-6.4.test.ts`; `npm run test:self` +green. + +### Task 20 — T6.4-3: two-bearer collision — one `refused-id-collision` locating both `b` and `b.c` + +Cites: TEST-SPEC T6.4-3 (rename `a`→`b` where `a.c` exists beside `b` and `b.c`: +refused with one `refused-id-collision` finding locating every colliding +bearer — `b` and `b.c`; nothing modified); T14-7 (the finding locates every +colliding bearer); SPEC 6.4, 14. + +Now: `test/suite/registry/section-6.4.ts` ≈ lines 1060–1130 stage the single +bearer `a.sib` only. + +Do: add the arm (or restage the existing one) with sections `a`, `a.c`, `b`, +`b.c` in one or two files; `rename a b` → exit 1, exactly one +`refused-id-collision` finding whose locations are exactly the two bearers +`b` and `b.c` (tag ranges as the adapter decodes them), journal and sources +byte-identical. Export the fixture (source bytes and the two expected +locations) so Task 46 (T14-7) asserts the same collision without a second +staging. + +Verify: `npx vitest run … test/suite/section-6.4.test.ts`; `npm run test:self` +green. + +### Task 21 — T6.4-4: nonexistent `<file>` spelled as an `.mdx` present on disk but in no spec group (exit 2) + +Cites: TEST-SPEC T6.4-4 (a `<file>` operand naming no discovered spec source is +a usage error, exit 2, in both spellings: absent on disk, and present on disk +but matched by no configured spec group); SPEC 6.4, 12.0. + +Now: `test/suite/registry/section-6.4.ts` ≈ lines 1352–1354 stage only the +absent-on-disk spelling. + +Do: add the second spelling: write a valid `.mdx` outside every spec-group +glob (e.g. `docs/stray.mdx` with globs `specs/**/*.mdx`), `rename docs/stray.mdx +<id> <new>` → exit 2 with the 12.7 error document (decoded through the +existing usage-error adapter), nothing modified. + +Verify: `npx vitest run … test/suite/section-6.4.test.ts`. + +### Task 22 — T6.4-5: `move` arm — a `typeof` reference to a section-moved node stays byte-unchanged + +Cites: TEST-SPEC T6.4-5 (revised: a code-file `typeof` reference to a node is +not a reference the operation rewrites — after a section move of that node +the referencing `.ts` file is byte-unchanged, the workspace valid, and the +applied mapping and journal entry identical to the same move on a twin +workspace without the reference); SPEC 6.4, 6.5, 4.5. + +Now: `test/suite/registry/section-6.4.ts` ≈ lines 1575–1600 cover `rename` +only. + +Do: add the move arm mirroring the rename arm: twin workspaces differing only +by a `.ts` file holding a `typeof SPEC.a.b` reference; section-move `a.b` in +both; assert the `.ts` file byte-identical to its staged bytes, `check` clean, +and the journal's appended entry and the preview's mapping byte-identical +across the twins (H-4/H-6 product-to-itself compare). + +Verify: `npx vitest run … test/suite/section-6.4.test.ts`. + +### Task 23 — T6.5-1: specifier-rewrite byte contract for the file-form move + +Cites: TEST-SPEC T6.5-1 (revised: after a file move, every importing `.ts` +and the moved file are byte-identical outside the `import-specifier-rewrite` +ranges reported by a `--preview` taken on a copy, and each such range holds a +2.1-form specifier designating the moved module); SPEC 6.5, 6.6, 2.1. + +Now: `test/suite/registry/section-6.5.ts` ≈ lines 770–772 assert resolution +only. + +Do: on a copy of the fixture take `move --preview` and decode its +`import-specifier-rewrite` ranges (existing preview adapter); run the real move +on the original; for each importing `.ts` and for the moved file, assert the +bytes outside the reported ranges equal the pre-move bytes at the same +positions (splice check: pre-move bytes with the ranges replaced by the +post-move contents equals the post-move file) and that each range's new +content is a string-literal specifier that resolves (2.1 relative form) to +the moved module; nothing else changed. + +Verify: `npx vitest run … test/suite/section-6.5.test.ts`. + +### Task 24 — T6.5-3: third-file arm — a third spec source's `d`-chain reference rewritten to the target module + +Cites: TEST-SPEC T6.5-3 third-file arm ("a spec source that is neither origin +nor target, importing the origin module and referencing a moved node through +it (a `d` chain), has that reference rewritten to the target module under an +import of it added there (bytes per T6.5-8's discipline), the origin import +removed when the moved reference was its binding's last and kept when another +reference through it remains (one arm each); `query edges` reports the third +file's edge under the moved node's new identity and `check` is clean"); SPEC +6.5, 2.1. + +Now: `test/suite/registry/section-6.5.ts` ≈ lines 1321–1323 have no third +file. + +Do: two arms over three spec sources (origin, target, third): (a) the third +file's only reference through the origin binding is to the moved node → after +the section move the reference is rewritten to the target module under an +added import (added-import bytes asserted with T6.5-8's discipline: isolate the +single added run by diff, value-unpinned identifier, `\n` rules), the origin +import removed with 6.5's exact extent; (b) the third file also references an +unmoved origin node → origin import kept byte-for-byte, only the moved +reference rewritten. In both: `query edges` lists the third file's `depends` +edge under the moved node's new identity, `check` clean, journal mapping as the +existing arm asserts. Share the diff-isolation helper with Task 28 (T6.5-8) if +it already landed; otherwise write it in `test/helpers/` for both. + +Verify: `npx vitest run … test/suite/section-6.5.test.ts`. + +### Task 25 — T6.5-4: destination occupied by a directory; symbolic-link path components → `refused-invalid-destination` + +Cites: TEST-SPEC T6.5-4 (revised: (a) file-form destination occupied by a +directory is refused like the plain-file/symlink/broken-symlink occupants; +(b) a destination path with a symbolic-link component — file-form move to +`specs/sub/b.mdx` and section-form move creating `specs/sub/new.mdx`, with +`specs/sub` a symlink to a real empty directory, staged once inside and once +outside the workspace root — is `refused-invalid-destination`, never 14.22, +exit 1, nothing modified, the link and its target byte-identical; and +`<outDir>/new` staged as such a link beside the derived-path arm); SPEC 6.5, +14. + +Now: `test/suite/registry/section-6.5.ts` ≈ lines 1664–1666 (title 2003) +stage plain file, symlink, broken symlink only; no link-component arms. + +Do: add the directory-occupant arm and the four link-component arms (file +form × {inside, outside root}, section form × {inside, outside root}) using the +workspace builder's symlink support (S-2 covers it), plus the `<outDir>/new` +link beside the derived-path arm. Each: exit 1, exactly one +`refused-invalid-destination` finding (never 14.22), sources/journal/link/ +target byte-identical after the run, the link still a link. + +Verify: `npx vitest run … test/suite/section-6.5.test.ts`. + +### Task 26 — T6.5-5: origin `<file>` present on disk but undiscovered (both forms) + +Cites: TEST-SPEC T6.5-5 (an origin `<file>` naming no discovered spec source is +a usage error, exit 2, in both spellings — absent, and present but matched by +no spec group — for the file form and the section form); SPEC 6.5, 12.0. + +Now: `test/suite/registry/section-6.5.ts` ≈ lines 2322–2324 stage only the +absent spelling. + +Do: add the present-but-undiscovered spelling for each form (as Task 21 does +for `rename`): a valid `.mdx` outside every glob; `move docs/stray.mdx …` and +`move docs/stray.mdx#a …` → exit 2 with the 12.7 error document, nothing +modified. + +Verify: `npx vitest run … test/suite/section-6.5.test.ts`. + +### Task 27 — T6.5-7: single-quoted descendant `id`; code-source counterpart (import removal in `.ts`, byte-composed) + +Cites: TEST-SPEC T6.5-7 (revised: the moved subtree "spells that descendant's +`id` attribute single-quoted (2.7)" and after the move "the local reference and +the single-quoted `id` attribute are each re-identified by prefix replacement +with their single-quote spellings preserved"; and "The code-source counterpart, +fully composed (no import added, so no latitude): a `.ts` file importing the +origin module, the target module, and a retained third module — the third +module's declaration and the origin's sharing one line in a second variant, +the origin's following it — whose only references through the origin binding +are markers on nodes of the moved subtree, beside a marker through the target +binding and one through the third; after the section move, the origin-module +import … is removed with 6.5's exact extent (own-line: the line dropped with +its terminator; shared-line: the declaration's own characters alone deleted, +the retained declaration kept byte-for-byte), the moved markers are rewritten +through the existing target binding, and the file is asserted byte-equal to +expected bytes composed from the rules of 6.4/6.5 and 3"); SPEC 6.4, 6.5, 2.7, +4.5. + +Now: `test/suite/registry/section-6.5.ts` ≈ lines 2723–2910 (T6.5-7) stage the +MDX origin/target with double-quoted `id`s and no code file. + +Do: (a) spell the moved descendant's `id` attribute single-quoted in the +fixture and compose the expected target bytes with the re-identified value +still single-quoted; (b) add the code-source arm in two variants (own-line +origin import; origin import following the third-module import on a shared +line) with markers through all three bindings; after the move assert the +`.ts` file byte-equal to the composed expectation (origin import removed with +exact extent, moved markers rewritten through the existing target binding, +everything else byte-identical); `check` clean. + +Verify: `npx vitest run … test/suite/section-6.5.test.ts`. + +### Task 28 — T6.5-8 Added-import insertion discipline (new test: TS, MDX-origin, MDX-target arms) + +Cites: TEST-SPEC T6.5-8 (full text in §6.5): an added import is a line of its +own — declaration + U+000A, preceded by U+000A when the insertion offset is not +at a line start — with identifier and offset left free; three section-move +arms: TS (code file imports the origin, references one moved and one unmoved +node → gains a target-module import, origin import stays), MDX origin (origin +holds a retained third-module import and a local string reference to a moved +descendant → origin gains the target module's import, the reference converting +to imported form in 6.4's pinned spellings), MDX target (moved subtree holds a +local `d` reference to an unmoved origin node with a non-identifier segment → +the target file gains the origin module's import; dot access / double-quoted +computed access; origin loses the section and gains no import); in each arm +the harness isolates the single added byte run by diff against the composed +bytes and asserts exactly `decl + \n` at a line-start offset or `\n + decl + +\n` otherwise, the declaration one import of the needed module's specifier +binding one fresh identifier (value unpinned) that the rewritten references +use, no other byte inserted. SPEC 6.5, 6.4, 2.1, 3. + +Now: not implemented; no registry entry, no H-7 map entry. + +Do: in `test/suite/registry/section-6.5.ts` add `T6.5-8` with the three arms. +Write (or reuse from Task 24) a helper in `test/helpers/` that, given the +composed expected bytes with two unknowns and the actual post-move bytes, +(1) recovers the fresh identifier from the rewritten references, (2) locates +the single inserted run by diff, and (3) checks the `\n` discipline by the +offset's line position and that the run's declaration is a 2.1-form import of +the expected module binding that identifier; everything else byte-identical. +Register the ID and add `"T6.5-8": ["6.5"]` to `traceability.ts`. + +Verify: `npx vitest run … test/suite/section-6.5.test.ts` (pass or diagnosed +product failure, never a harness error); `npm run test:self` green (S-1, S-7). + +### Task 29 — T6.5-9 Fresh identifiers in code (new test: pre-empted local bindings, compile-clean after the move) + +Cites: TEST-SPEC T6.5-9 (full text in §6.5): T6.5-8's TS arm re-staged with a +receiving code file that also declares at module scope a local `const`, a +`function`, a `class`, a `type` alias, and a non-spec import binding whose +names pre-empt the identifiers a product would plausibly derive — the target +file's basename as written, lower-cased, upper-cased, `Spec`- and +`SPEC`-suffixed, and the origin binding's name with a digit and with an +underscore appended — the file compiling clean before the move under standard +tooling; after the move, through H-2's standard-tooling channel +(`test/helpers/tooling.ts`), the rewritten file compiles with no diagnostics; +`query edges` reports the moved markers' `references` edges to the new +identities and the unmoved marker's edge through the retained origin binding; +`check` clean. SPEC 6.5, 2.1, 4, 4.5. + +Now: not implemented. + +Do: add `T6.5-9` to `section-6.5.ts` (prerequisite: Task 28's TS arm fixture +to re-stage): stage the pre-empting declarations (exactly the enumerated +derivations; each binding used trivially so the file is not just declarations), +compile before the move (no diagnostics — a fixture self-check), section-move, +compile after (no diagnostics), then the `query edges` and `check` assertions. +Register and map the ID. + +Verify: `npx vitest run … test/suite/section-6.5.test.ts`; `npm run test:self` +green (S-1, S-4 tooling driver unaffected). + +### Task 30 — T6.5-10 Third-module bindings carried with moved text (new test: arms (a) value-blind and (b) byte-composable) + +Cites: TEST-SPEC T6.5-10 (full text in §6.5): three spec sources `a.mdx` +(origin), `b.mdx` (target), `x.mdx` in one directory; the moved subtree holds a +`d` reference and a `{text(...)}` embedding through the origin's `X` binding, +one through `X["bar-baz"]`; (a) target lacks an import of `x.mdx` and the +origin's only `X` references lie in the moved subtree → target gains exactly +one import declaration under T6.5-8's line discipline (specifier of 2.1's +form designating `x.mdx`, relative spelling free), each moved reference rooted +at the fresh identifier (`X` itself admissible) with access form kept, moved +text otherwise byte-identical; origin loses the section and its own-line `X` +declaration with the terminator, otherwise byte-identical; (b) target already +imports `x.mdx` as `Z` (referenced by its own section) and the origin keeps an +`X` reference outside the subtree → no import added, `X.foo` → `Z.foo`, +`X["bar-baz"]` → `Z["bar-baz"]`, origin's `X` declaration kept, both files +byte-equal to composed expectations; in both arms `query edges` reports the +moved nodes' `depends` and `embeds` edges under their new identities to +`x.mdx`'s unchanged nodes, `build` and `check` clean. SPEC 6.5, 6.4, 2.1, 3. + +Now: not implemented. + +Do: add `T6.5-10` to `section-6.5.ts` using Task 28's diff-isolation helper +for arm (a) and whole-file byte compares for arm (b); register and map the ID. + +Verify: `npx vitest run … test/suite/section-6.5.test.ts`; `npm run test:self` +green. + +### Task 31 — T6.6-4: rename-preview mapping-order fixture with descendants in document order opposite to byte order + +Cites: TEST-SPEC T6.6-4 (the preview's mapping lists the renamed section's +descendants in an order the decoder checks by `from` byte order, and the +fixture must be able to discriminate a document-order product: descendants +whose document order differs from the byte order of their `from` spellings — +`a.z` before `a.c`); SPEC 6.6, 6.4. + +Now: `test/suite/registry/section-6.6.ts` ≈ lines 508–553 rename `core.mid` +with the single descendant `core.mid.leaf`, so the ordering check is vacuous. + +Do: restage with at least two descendants whose document order is `a.z` then +`a.c` (byte order of the mapping's `from` values reversed relative to document +order); keep the decoder's check and assert the mapping's order per the spec's +pinned tie-break; keep every other assertion of the test. + +Verify: `npx vitest run … test/suite/section-6.6.test.ts`. + +### Task 32 — T6.6-5: lagging-record counterpart (emission enabled after the build, no rebuild) + +Cites: TEST-SPEC T6.6-5 (revised: with Markdown emission enabled after the +last `build` and no rebuild, the move preview's `generated` set is exactly the +destination's module + companions + Markdown emit destination plus every other +spec source's Markdown emit destination, and `removed` is exactly the recorded +pre-move module and companions); SPEC 6.6, 13.1, 13.2, 13.4. + +Now: `test/suite/registry/section-6.6.ts` ≈ lines 2673–2675 cover the +current-record case only. + +Do: add the arm: build, then enable `markdown.emit` in the configuration +without rebuilding, take `move --preview`, decode `generated`/`removed` +(existing preview adapter) and assert the exact sets above (paths composed +from SPEC 13.1/13.2's naming rules), nothing written. + +Verify: `npx vitest run … test/suite/section-6.6.test.ts`. + +### Task 33 — T7-1: `--config <nonexistent file>` is configuration error 14.14 (exit 2, concerned path per T12.7-3) + +Cites: TEST-SPEC T7-1 (an explicit `--config` naming no file is reported as +14.14 — exit 2 with the 12.7 error document naming the concerned path as +T12.7-3 spells it — never a plain usage error); SPEC 7, 12.0, 12.7, 14.14. + +Now: `test/suite/registry/section-7-basics.ts` ≈ line 228 has only the +override run. + +Do: add the arm: `build --config <missing path>` → exit 2, error document +decoded through the existing error adapter with the 14.14 stable code and the +concerned path spelled as given (relative to the invocation directory, per +T12.7-3 — see Task 43 for the sibling-directory ascent form, which this arm +need not repeat); nothing written. + +Verify: `npx vitest run … test/suite/section-7-basics.test.ts`. + +### Task 34 — T7-3: configuration value-shape arms → 14.14 + +Cites: TEST-SPEC T7-3 (revised: a spec or code group whose value is a single +string; a glob list holding `true`; `coverage: {}` / `policy: {}`; `specs` or +`code` given as lists — each a 14.14 configuration error, exit 2); SPEC 7, +14.14. + +Now: `test/suite/registry/section-7-basics.ts` ≈ lines 896–901 lack these +shapes. + +Do: add one arm per shape (six configurations), each `build` → exit 2 with the +14.14 error document naming the configuration file; nothing written. + +Verify: `npx vitest run … test/suite/section-7-basics.test.ts`. + +### Task 35 — T7.4-1: `edgeKinds` non-subset and non-string `targetTags` element → 14.14 + +Cites: TEST-SPEC T7.4-1 (revised: `edgeKinds` values `"contains"`, `"depend"`, +and `true` — one arm each — and a `targetTags` list holding `true` are 14.14 +configuration errors); SPEC 7.4, 14.14. + +Now: `test/suite/registry/section-7.4-7.5.ts` ≈ lines 542–550 lack them. + +Do: add the four arms, each `build` → exit 2, 14.14 error document, nothing +written. + +Verify: `npx vitest run … test/suite/section-7.4-7.5.test.ts`. + +### Task 36 — T7.5-1: policy `kinds` non-subset and non-string selector `tags` element → 14.14 + +Cites: TEST-SPEC T7.5-1 (revised: `kinds` values `"contains"`, `"depend"`, +`true`, and a selector `tags` list holding `true` are 14.14 configuration +errors); SPEC 7.5, 14.14. + +Now: `test/suite/registry/section-7.4-7.5.ts` ≈ lines 1055–1061 lack them. + +Do: add the four arms as in Task 35. + +Verify: `npx vitest run … test/suite/section-7.4-7.5.test.ts`. + +### Task 37 — T10.5-1: `parent-consistency` context sets over A → B → C with only C changed + +Cites: TEST-SPEC T10.5-1 (revised: with A → B → C and only C `changed`, A's +`parent-consistency` context is exactly `{B}` — never `C` — and B's exactly +`{C}`; identities asserted); SPEC 10.5, 5.6. + +Now: `test/suite/registry/section-10.5.ts` T10.5-1 lacks the chain arm; T10.5-2's +`a > a.b > a.b.c` fixture asserts blocking only, no context sets. + +Do: add the arm to T10.5-1: three sections with `d` edges A→B→C (or the +section nesting the spec's arm names), a session, then edit C; decode the +review payload (existing review adapter) and assert each item's +`parent-consistency` context set by identity: A's `{B}`, B's `{C}`, C's +empty/absent as the spec states. + +Verify: `npx vitest run … test/suite/section-10.5.test.ts`. + +### Task 38 — T11-6: `query subtree` / `query ancestors` on a code-group `path` or `path#unit` → exit 2 + +Cites: TEST-SPEC T11-6 (§11.1: wrong-kind operands — `subtree` and +`ancestors` take a spec node; given a code-group whole-file `path` or a +`path#unit` location they are usage errors, exit 2); SPEC 11.1, 12.0. + +Now: `test/suite/registry/section-11.ts` T11-6 (≈ line 1466) covers `query +node` and `show` only; every `subtree`/`ancestors` invocation names a spec +source. + +Do: add four arms (`subtree`/`ancestors` × `path`/`path#unit`) on a workspace +with a discovered code file holding one unit; each → exit 2 with the 12.7 +error document, nothing modified. + +Verify: `npx vitest run … test/suite/section-11.test.ts`. + +### Task 39 — T11.4-3: stage the valueless prop as T2.7-3's shared `<S id="x" tags>` fixture + +Cites: TEST-SPEC T11.4-3 (§11.4: the valueless-prop case is staged as T2.7-3's +`<S id="x" tags>` — one fixture shared by `build` and `view` — so that `view`'s +answer for a section whose identity is well-formed but which carries a +form-invalid valueless prop is asserted as the spec states: identity present, +the attribute listed in tag order, no unavailability where 11.2 defines none); +CERTIFICATIONS.md §CONF-AVAIL (bare valueless attributes → 14.17; +document-order listings); SPEC 11.2, 11.4, 2.7. + +Now: `test/suite/registry/section-11.4.ts` T11.4-3 (≈ line 1274) stages +valueless `tags` only on `<S id="dup" id="dup" note="mystery" {...extras} +tags>`, whose identity is already unavailable, so a product withdrawing +identity on the valueless prop alone is not discriminated; nothing is shared +with `section-2.7.ts`. + +Do: import Task 15's exported fixture constant and add a `view` arm on that +exact file: decode through `decodeViewReport` and assert the node's identity +is the plain `x`, its `attributes` list the valueless `tags` entry in tag +order with the datum form T11.4-3 states, and the per-node fields 11.2 leaves +defined are present; keep the existing compound arm. Prerequisite: Task 15. + +Verify: `npx vitest run … test/suite/section-11.4.test.ts`; `npm run +test:self` green — CONF-AVAIL passes T11.4-3; NULLMARKER and OMIT still fail +exactly their sets (both list T11.4-3). + +### Task 40 — T12.0-10: missing required flag/argument rows in the syntax class (no configuration load) + +Cites: TEST-SPEC T12.0-10 (§12.0 revised: within class 2 the rows include "a +missing required flag or argument" — `review create --name n` with none of +`--base`/`--strategy audit`/`--coverage`, and `at <file>` alone — each reported +as a usage error without loading configuration); SPEC 12.0. + +Now: `test/suite/registry/section-12.0-ii.ts` `syntaxRows` (≈ lines +1920–1940) hold only unknown command, repeated flag, and `show a#b#c`. + +Do: add the two rows to `syntaxRows` so they run under the same +no-configuration-load proof the existing rows use (a deliberately broken +configuration that must not be reported): exit 2, 12.7 error document, nothing +modified. + +Verify: `npx vitest run … test/suite/section-12.0-ii.test.ts`. + +### Task 41 — T12.7-1: `unavailable`-marker exclusivity walk on every captured JSON document; unpinned-surface range arms + +Cites: TEST-SPEC T12.7-1 and the §11 preamble (the marker-exclusivity walk +covers every captured JSON document, unpinned surfaces included; on unpinned +surfaces a range is decoded only as `{"start","end"}` — arms: `query node`, +`nodes`/`subtree`/`ancestors` rows, `show --json`, and the review payload's +present scope node and present code-impact location); H-3 (fail loudly); +SPEC 11.2, 12.7. + +Now: `assertUnavailabilityMarkerForms` (`test/helpers/adapters/forms.ts`) is +called only inside forms.ts; `adapters/query.ts`, `review.ts`, `model.ts` +never run it. T12.7-1's run body (`section-12.7.ts` ≈ 2236–2254) calls only the +located-findings, policy, cross-module, review-refusal and byte-paths arms; +`decodeSourceRange` (query.ts ≈ 64) is already form-exact. + +Do: (a) invoke the walk (its iterative form, Task 6) on every captured JSON +document at the single decode entry of each adapter (query, review, model, +and any other adapter that parses product JSON), so an `unavailable` marker in +a non-exclusive position fails loudly everywhere; (b) add the listed +unpinned-surface arms to T12.7-1 asserting each present range decodes as +exactly `{"start","end"}` (no extra member) through the existing decoders. + +Verify: `npx vitest run … test/suite/section-12.7.test.ts` and the full +`--project suite` run (the new walk must not misfire on any surface: any +failure it raises must be a diagnosed product form failure, never a harness +error); `npm run test:self` green (S-5). + +### Task 42 — T12.7-2: clean-workspace `build --json` is exactly the findings-only form `{"findings": []}` + +Cites: TEST-SPEC T12.7-2 (§12.7: on a clean workspace `build --json` emits +exactly `{"findings": []}` — the findings-only form with an empty list and no +other member); SPEC 12.1, 12.7. + +Now: T12.7-2 (`section-12.7.ts` ≈ line 1777) asserts only `check --json` and +runs `build` without `--json`; `support.ts` `buildFindings` expects exit 1. + +Do: add the `build --json` run to T12.7-2 on the clean workspace: exit 0, the +decoded document has exactly one member `findings` holding an empty list (and +the byte form T12.7-2 pins, if it pins one); add or extend a `support.ts` +helper for the exit-0 findings-only decode so other tests can reuse it. + +Verify: `npx vitest run … test/suite/section-12.7.test.ts`. + +### Task 43 — T12.7-3: `--config ../cfg/xspec.config.ts` from a sibling directory, reported as spelled + +Cites: TEST-SPEC T12.7-3 (§12.7 revised: the nonexistent and the malformed +`--config <path>` cases are each staged from a sibling directory as `--config +../cfg/xspec.config.ts` and the error document reports the concerned path as +`../cfg/xspec.config.ts` — the ascent form); SPEC 12.7, 11.6, 14.14. + +Now: `runErrorConfigPathsArm` (`section-12.7.ts` ≈ 2051, 2063) stages +`./cfg/broken.config.ts` and `missing.config.ts` from the root — no ascent +spelling. + +Do: restage both cases: invoke from a sibling directory (`<root>/work`) with +`--config ../cfg/xspec.config.ts` — once absent, once malformed — and assert +the decoded error document's concerned path is exactly `../cfg/xspec.config.ts` +(exit 2, nothing written). Keep the root-relative cases if the spec still +lists them. + +Verify: `npx vitest run … test/suite/section-12.7.test.ts`. + +### Task 44 — T13.3-3: `impact --base` leaves the journal-error staging; stays in the obstructed-write staging only + +Cites: TEST-SPEC T13.3-3 (revised: on the garbage-journal fixture each of +`ids`, `show`, `coverage`, `review status`, `query` reports the 14.13 journal +finding, exit 1; "`impact` is absent from the journal-error staging by +necessity" — a garbage line meets baseline resolution first (exit 2 per +6.3/T6.3-4); `impact --base` is driven "in the obstructed-write staging alone … +against a commit taken before the obstruction was staged, its baseline +resolving and 14.22 the operative gate finding"); SPEC 13.3, 6.3, 12.0. + +Now: `section-13.3.ts` `gatedReadInvocations` (≈ line 2086) includes `impact +--base` and feeds both whole-gate arms; the garbage-journal arm (≈ 2576) +expects `impact` to exit 1 with 14.13 — contradicting the spec. + +Do: split the invocation list: the journal-error arm drives the five read +commands only; the obstructed-write arm drives the five plus `impact --base +<commit before the obstruction>` expecting 14.22 as the operative finding. +Keep both fixtures' `audit` session and the modifies-nothing compares. + +Verify: `npx vitest run … test/suite/section-13.3.test.ts`. + +### Task 45 — T13.4-3: unreadable-record half — corrupted record before the configuration change + +Cites: TEST-SPEC T13.4-3 (§13.4: with the record made unreadable — corrupted +shape-blind — before the configuration change, `build` replaces the record and +leaves the previous derived file as an orphan, the missing-record half's twin); +SPEC 13.4. + +Now: `section-13.4.ts` covers only the missing-record half (no +`record-staging` import, no corruption arm). + +Do: add the arm using `test/helpers/adapters/record-staging.ts` (H-3's +shape-blind garbage over the operational path set): corrupt the record, change +the configuration so the derived path moves, `build` → the record is +rewritten (valid, current), the old derived file still present (orphan), the +new one generated; assertions mirror the missing-record arm. + +Verify: `npx vitest run … test/suite/section-13.4.test.ts`. + +### Task 46 — T14-7: `refused-id-collision` locates every colliding bearer + +Cites: TEST-SPEC T14-7 (§14: a jointly-violated condition locates every +participant — `refused-id-collision` locates every colliding bearer, two in +T6.4-3's prefix-replacement arm, `b` and `b.c`); SPEC 14, 6.4. + +Now: `section-14.ts` T14-7 (≈ lines 118–132, 2260–2262) asserts +SOME-quantified over "the fixture's one assertable participant". + +Do: stage Task 20's exported two-bearer fixture and assert the single finding's +location set equals exactly the two bearers. Prerequisite: Task 20. + +Verify: `npx vitest run … test/suite/section-14.test.ts`. + +### Task 47 — P-1: stage `.`-containing draws as nested segments; quote discipline for `'`/`"` draws + +Cites: TEST-SPEC P-1 (§16 revised: "a draw containing `.` can be spelled as no +single segment (1.4: `.` is the ID separator) and stages as that many +segments, its bearer nested beneath the ancestor chain the split's prefixes +spell … asserting acceptance by `build` iff every resulting segment satisfies +1.4"; "each draw is spelled in the quote kind its content admits — double +quotes for a draw containing `'`, single quotes for one containing `"`, either +otherwise — and a draw containing both quote characters … is never staged"); +SPEC 1.4, 2.7. + +Now: `test/suite/registry/section-16-p1.ts` (≈ line 140) predicts rejection +for any `.`-containing draw and stages it as one top-level segment; the +alphabet (module header ≈ 52–57) omits `"` and `'` and every spelling is +double-quoted. + +Do: (a) split a `.`-containing draw on `.`, stage the bearer nested under the +ancestor chain the prefixes spell (each prefix segment a valid staged +ancestor), and predict acceptance iff every resulting segment satisfies 1.4 +(empty segments from leading/trailing/double dots are invalid); (b) add `"` +and `'` to the alphabet, choose the quote kind per the discipline, and filter +out draws containing both (never staged; keep the property's draw count by +redrawing); apply the same to the tag property. Keep the fixed seed set and +the seed report. + +Verify: `npx vitest run … test/suite/section-16-p1.test.ts` (default seeds, +then `XSPEC_PROPERTY_SEED=random` a few times — no harness error); `npm run +test:self` green (CONF-VALID passes P-1; VALID-CTRL and VALID-WIDE still fail +exactly their sets). From f2184889056be8eb94d69188385cce9090a67b97 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 04:18:10 +0000 Subject: [PATCH 184/226] =?UTF-8?q?sdg(phase-9):=20CONF-CORE=20conformer?= =?UTF-8?q?=20=E2=80=94=20refresh=20stale=20graph=20data=20inside=20mutati?= =?UTF-8?q?ng=20review=20subcommands,=20after=20the=20hold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX_PLAN Task 1 (CERTIFICATIONS §CONF-CORE Scope; SPEC 13.3, 13.5; T13.5-1's stale-workspace arm, T10.1-1). In test/fixtures/conf-core/product.mjs: - runMutating takes a `refreshesGraphData` option; `review create`, `resolve`, and `split` opt in. The conformer runs the refresh after exclusivity is acquired and the hold has been released, before the operation's own writes; under VIOL-CORE-EARLYWRITE it joins the pre-hold writes (as that violator's expected-failures text states). `rename`, file-form `move`, and every read command are unchanged. The refresh is a position-independent closure defined before exclusivity is acquired, so Task 3's single deviation switch can move it ahead of acquireExclusivity. - refreshStaleGraphData: graph data is stale when missing or when it differs from what the current sources generate, the recorded derived-file paths excluded from the comparison and left unchanged; stale graph data is rewritten exactly as `build` writes it (graphDataContent gains an optional recorded `derived`), no module generated or removed, nothing else written; current graph data is not rewritten at all; a record present but unreadable (SPEC 14.23) is left untouched. An invalid workspace reports findings from the same loadGraph the subcommand uses. - derivedWriter() now carries the VIOL-CORE-PARTIALWRITE switch for both derived-file writers (regenerate and the refresh). Verified by hand on a staged CONF-CORE workspace: while `review create --strategy audit --test-hold` is held after a section edit, .xspec/graph.json and every other workspace file are byte-identical to their pre-invocation state; after release exit 0, the session exists, and graph data equals the bytes a fresh `build` writes on a twin (`check` then flags only the module as stale); on a freshly built workspace graph data is not rewritten (same inode and mtime). `npm run test:self`: 328 passed, 2 failed (330) — the two pre-existing certification-document gate failures that Task 4 removes; the certification runner passes (CONF-CORE conformer and violators unchanged in verdict). typecheck and format:check clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 39 ------- test/fixtures/conf-core/product.mjs | 167 +++++++++++++++++++++++----- 2 files changed, 142 insertions(+), 64 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index ee09a5b..7b2f943 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,45 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage A — certification gate: CONF-CORE refresh, T13.5-1 stale arm, VIOL-CORE-EARLYREFRESH, manifest -### Task 1 — CONF-CORE conformer: 13.3 refresh inside mutating `review` subcommands, after the hold - -Cites: CERTIFICATIONS.md §CONF-CORE Scope ("on a workspace whose graph data is -stale but whose sources are valid … the 13.3 refresh a mutating `review` -subcommand performs after the hold and before its own writes, writing graph -data byte-identical to what `build` writes (13.3, T10.1-1), as T13.5-1's -stale-workspace arm observes it"); §VIOL-CORE-EARLYREFRESH (whose passing side -needs the conformer's refresh to be distinguishable); TEST-SPEC T13.5-1 -stale-workspace arm; SPEC 13.3, 13.5. - -Now: in `test/fixtures/conf-core/product.mjs` graph data (`.xspec/graph.json`) -is written only by `regenerate()` (≈ line 1112), whose call sites are `build` -(≈ 2021), `rename` (≈ 2390) and file-form `move` (≈ 2452). `review create`, -`resolve`, `split` call `loadGraph` only (≈ 2545, 2626, 2724), so a stale -workspace stays stale across a mutating `review` subcommand and the conformer -would fail T13.5-1's stale arm (Task 2). - -Do: in `runMutating` (≈ 1971) — or in each mutating `review` subcommand's -`operate` body — after exclusivity is acquired and the hold has been created and -released (`holdIfRequested`, the `writesBeforeHold` deviation's ordering -untouched), and before the subcommand's own writes (session file, journal), -refresh stale graph data: compare the stored graph against the current sources -exactly as the read path decides staleness, and when stale write graph data -byte-identical to what `build` writes (reuse `regenerate` or the same -serialization; nothing else written). A workspace whose graph data is current -must be refreshed by nothing (byte-identical graph data). Keep `rename`/`move` -and every read command as they are. Add a short comment naming the CERTIFICATIONS -§CONF-CORE scope sentence. Design the insertion so Task 3's single deviation -switch can move the refresh to before exclusivity is acquired. - -Verify: stage by hand a workspace (one spec group, two `.mdx` sections), `node -test/fixtures/conf-core/bin.mjs build`, edit a section's text, run `… review -create --name n --strategy audit --test-hold <path outside the workspace>` in -the background: while the hold file exists, `.xspec/graph.json` is byte-identical -to its pre-invocation bytes; delete the hold file; after exit 0 the graph data -equals the bytes a fresh `build` on a twin workspace writes and the session -exists. `npm run test:self` shows no new failures (the two -`certification-document` failures remain until Task 4). - ### Task 2 — T13.5-1: stale-workspace arm (`review create --strategy audit --test-hold` on stale graph data) Cites: TEST-SPEC T13.5-1 ("Stale-workspace arm (13.5: the hold precedes every diff --git a/test/fixtures/conf-core/product.mjs b/test/fixtures/conf-core/product.mjs index ec930c1..090a7fe 100644 --- a/test/fixtures/conf-core/product.mjs +++ b/test/fixtures/conf-core/product.mjs @@ -15,7 +15,11 @@ // - `rename` and file-form `move` with journal append (SPEC 6.1, 6.2). // - `review` with the `audit` strategy (SPEC 10.6) through `create`, // `resolve`, `split`, and the read subcommands, including read-time -// invalidation over the recorded state of SPEC 10.4. +// invalidation over the recorded state of SPEC 10.4, and — on a workspace +// whose graph data is stale but whose sources are valid — the SPEC 13.3 +// refresh a mutating `review` subcommand performs after the hold and +// before its own writes, writing graph data byte-identical to what +// `build` writes (T13.5-1's stale-workspace arm, T10.1-1). // - SPEC 13.4 durable protection and SPEC 13.5 in full, `--test-hold` // included. // @@ -31,6 +35,12 @@ // with O_EXCL (anything already there — a symbolic link included — fails // the command exit 2 without modifying anything); the command proceeds // only once that file has been deleted. +// - 13.3 refresh (SPEC 13.3): a mutating `review` subcommand, after the hold +// and before its own writes, compares the stored graph data against the +// current sources (the recorded derived-file paths excluded) and rewrites +// stale graph data alone, exactly as `build` writes it — see +// refreshStaleGraphData; `rename` and `move` regenerate every derived file +// at their end instead (SPEC 6.4, 6.5). // - Atomic visibility (SPEC 13.5): every derived and durable write goes // through a temp file in the target's directory renamed over the target, // so a concurrent reader observes prior content, complete new content, or @@ -152,10 +162,11 @@ const PARTIAL_WRITE_INTERVAL_MS = 50; * and only then receives the remainder, completing the content. The path is * never unlinked, so a concurrent reader observes the partial file — never * absence — and after the call resolves the path holds the complete bytes - * (each completed command still leaves the conformer's final state). Used by - * regenerate() alone, under the `partialDerivedWrites` deviation: derived - * files only — durable files (journal, sessions, sources) keep the - * conformer's atomic writes everywhere. + * (each completed command still leaves the conformer's final state). Used + * through derivedWriter() alone — regenerate() and refreshStaleGraphData() — + * under the `partialDerivedWrites` deviation: derived files only — durable + * files (journal, sessions, sources) keep the conformer's atomic writes + * everywhere. */ async function writeFilePartialThenComplete(absPath, data) { const bytes = Buffer.from(data); @@ -1061,7 +1072,18 @@ function moduleContent(model) { ); } -function graphDataContent(graph) { +/** Derived-file paths as `build` records them: the modules generated now. */ +function currentDerivedPaths(graph) { + return graph.files.map((model) => modulePathFor(model.rel)).sort(); +} + +/** + * Graph data exactly as `build` writes it (SPEC 13.3, 13.4): per-file + * section ids, node hashes, and source ranges, plus the recorded derived-file + * paths — the paths generated now or, for the 13.3 refresh, the stored + * record left unchanged (`derived`). + */ +function graphDataContent(graph, derived = currentDerivedPaths(graph)) { const filesData = {}; for (const model of graph.files) { const nodes = {}; @@ -1081,7 +1103,6 @@ function graphDataContent(graph) { nodes, }; } - const derived = graph.files.map((model) => modulePathFor(model.rel)).sort(); return canonicalJson({ derived, files: filesData }) + "\n"; } @@ -1103,6 +1124,27 @@ async function readRecordedDerived(root) { return []; } +/** + * The writer every derived-file write goes through — regenerate() and the + * 13.3 refresh (refreshStaleGraphData) are this fixture's only derived-file + * writers; durable files (journal, sessions, sources) are written atomically + * everywhere. + * + * VIOL-CORE-PARTIALWRITE (CERTIFICATIONS.md): derived-file writes are not + * atomic in their observable effect — while a derived file is being written, + * its path holds a strict prefix of the new content for a sustained interval, + * long relative to a concurrent reader's polling cadence, before the + * complete content appears (see writeFilePartialThenComplete). Switching the + * writer here deviates every derived write — generated modules and graph + * data — and nothing else: durable files are unaffected, orphan removal and + * each command's completed final bytes are unchanged. + */ +function derivedWriter() { + return deviations.partialDerivedWrites + ? writeFilePartialThenComplete + : writeFileAtomic; +} + /** * Regenerate every derived file exactly as `build` writes it: modules per * source, orphan removal via the recorded derived paths, graph data @@ -1110,19 +1152,7 @@ async function readRecordedDerived(root) { * regeneration (SPEC 6.4, 6.5). */ async function regenerate(graph) { - // VIOL-CORE-PARTIALWRITE (CERTIFICATIONS.md): derived-file writes are not - // atomic in their observable effect — while a derived file is being - // written, its path holds a strict prefix of the new content for a - // sustained interval, long relative to a concurrent reader's polling - // cadence, before the complete content appears (see - // writeFilePartialThenComplete). regenerate() is this fixture's only - // derived-file writer, so switching the writer here deviates every derived - // write — generated modules and graph data — and nothing else: durable - // files (journal, sessions, sources) are unaffected, orphan removal and - // each command's completed final bytes are unchanged. - const write = deviations.partialDerivedWrites - ? writeFilePartialThenComplete - : writeFileAtomic; + const write = derivedWriter(); const recorded = await readRecordedDerived(graph.root); const current = new Set(graph.files.map((model) => modulePathFor(model.rel))); for (const orphan of recorded) { @@ -1139,6 +1169,64 @@ async function regenerate(graph) { await write(path.join(graph.root, GRAPH_DATA_REL), graphDataContent(graph)); } +/** + * The stored graph data as the 13.3 refresh consults it: `missing` (no + * file), `unreadable` (present but not readable as a record, SPEC 14.23 — a + * state refresh neither repairs nor replaces), or `readable` with its exact + * bytes and its recorded derived-file paths. + */ +async function readGraphDataRecord(root) { + let text; + try { + text = await fsp.readFile(path.join(root, GRAPH_DATA_REL), "utf8"); + } catch (error) { + return { state: error.code === "ENOENT" ? "missing" : "unreadable" }; + } + try { + const parsed = JSON.parse(text); + if ( + Array.isArray(parsed?.derived) && + parsed.derived.every((entry) => typeof entry === "string") && + typeof parsed.files === "object" && + parsed.files !== null + ) { + return { state: "readable", text, derived: parsed.derived }; + } + } catch { + // Not JSON: not readable as a record. + } + return { state: "unreadable" }; +} + +/** + * The 13.3 refresh of stale graph data, as a mutating `review` subcommand + * performs it (CERTIFICATIONS.md §CONF-CORE Scope: "on a workspace whose + * graph data is stale but whose sources are valid … the 13.3 refresh a + * mutating `review` subcommand performs after the hold and before its own + * writes, writing graph data byte-identical to what `build` writes (13.3, + * T10.1-1), as T13.5-1's stale-workspace arm observes it"). Graph data is + * stale when it is missing or does not match the current sources — a + * comparison from which the recorded derived-file paths are excluded — and + * is then rewritten exactly as `build` writes it, except that no module is + * generated or removed and the recorded derived-file paths are left + * unchanged (SPEC 13.3); nothing else is written. Current graph data is + * refreshed by nothing (its bytes untouched), and a record that exists but + * cannot be read (SPEC 14.23) is neither repaired nor replaced. The graph is + * loaded exactly as the subcommand loads it, so an invalid workspace reports + * its findings here, before anything is written (SPEC 13.3, 12.0). + */ +async function refreshStaleGraphData(config) { + const graph = await loadGraph(config.root, config.groups); + const record = await readGraphDataRecord(config.root); + if (record.state === "unreadable") return; + const content = graphDataContent( + graph, + record.state === "readable" ? record.derived : undefined, + ); + if (record.state === "readable" && record.text === content) return; + await derivedWriter()(path.join(config.root, GRAPH_DATA_REL), content); +} + // --------------------------------------------------------------------------- // Journal (SPEC 6.1, 6.3): append-only JSON lines; forward identity mapping // --------------------------------------------------------------------------- @@ -1968,8 +2056,31 @@ const MUTATING_FLAGS = { ...READ_FLAGS, "--test-hold": "value" }; * release. The lock is released on every path; a killed process releases by * dying (the next command detects the dead holder). */ -async function runMutating(cwd, configFlag, holdFlag, operate) { +/** + * Run a mutating command (SPEC 13.5): configuration, then workspace + * exclusivity, then the `--test-hold` seam, then — for the mutating `review` + * subcommands (`refreshesGraphData`) — the 13.3 refresh of stale graph data, + * then the operation's own writes (`operate`); exclusivity ends with the + * command. + */ +async function runMutating( + cwd, + configFlag, + holdFlag, + operate, + { refreshesGraphData = false } = {}, +) { const config = await loadConfig(cwd, configFlag); + // The 13.3 refresh of stale graph data (CERTIFICATIONS.md §CONF-CORE Scope: + // "the 13.3 refresh a mutating `review` subcommand performs after the hold + // and before its own writes, writing graph data byte-identical to what + // `build` writes"): only the mutating `review` subcommands refresh — + // `rename` and file-form `move` finish with a full regeneration instead + // (SPEC 6.4, 6.5) — and the conformer runs it below, after exclusivity is + // acquired and the hold has been released, before the operation's writes. + const refreshIfStale = async () => { + if (refreshesGraphData) await refreshStaleGraphData(config); + }; // VIOL-CORE-NOLOCK (CERTIFICATIONS.md): mutating commands do not exclude // one another — exclusivity is neither acquired nor checked, so a second // mutating command started while another runs or is held proceeds normally @@ -1995,15 +2106,18 @@ async function runMutating(cwd, configFlag, holdFlag, operate) { // VIOL-CORE-EARLYWRITE (CERTIFICATIONS.md): the mutating command // performs its workspace modifications before creating the hold file — // it acquires exclusivity (above, unchanged), completes the operation's - // writes (journal append included), then creates the hold file, waits - // for its deletion, and exits normally with the operation's outcome. - // The hold seam's own semantics (empty file, occupied path fails - // exit 2) and everything else are exactly the conformer's behavior. + // writes (the 13.3 refresh of stale graph data and the journal append + // included), then creates the hold file, waits for its deletion, and + // exits normally with the operation's outcome. The hold seam's own + // semantics (empty file, occupied path fails exit 2) and everything + // else are exactly the conformer's behavior. + await refreshIfStale(); const code = await operate(config); await holdIfRequested(); return code; } await holdIfRequested(); + await refreshIfStale(); return await operate(config); } finally { await lock.release(); @@ -2602,6 +2716,7 @@ async function reviewCreate(io, cwd, argv) { ]); return 0; }, + { refreshesGraphData: true }, ); } @@ -2645,6 +2760,7 @@ async function reviewResolve(io, cwd, argv) { ]); return 0; }, + { refreshesGraphData: true }, ); } @@ -2839,6 +2955,7 @@ async function reviewSplit(io, cwd, argv) { ]); return 0; }, + { refreshesGraphData: true }, ); } From f7e60545184dc5d5c0e63cebd443b2ef9237a395 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 04:31:00 +0000 Subject: [PATCH 185/226] =?UTF-8?q?sdg(phase-9):=20T13.5-1=20stale-workspa?= =?UTF-8?q?ce=20arm=20=E2=80=94=20the=20hold=20precedes=20the=2013.3=20ref?= =?UTF-8?q?resh,=20graph=20data=20byte-identical=20while=20held?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX_PLAN Task 2 (TEST-SPEC T13.5-1 stale-workspace arm; CERTIFICATIONS §CONF-CORE staging constraints and freshness constraint, §VIOL-CORE-EARLYREFRESH; SPEC 13.3, 13.5). In test/suite/registry/section-13.5.ts: - staleWorkspaceArm, run at the end of T13.5-1's registered body on its own workspaces: three identically staged CONF-CORE-shaped workspaces (`build`, then one section's own text edited as T10.1-1 stages it — graph data stale, workspace valid). The build twin's `build` on the edited sources first establishes the staging premise (it differs from the held workspace's stale graph data). The held workspace runs `review create --strategy audit --name n --test-hold <path outside the workspace>` through the existing hold choreography: while held, the whole workspace — `.xspec/graph.json` included, the one file a pre-hold refresh would change — is byte-identical to its pre-invocation snapshot; after release exit 0 and the session file exists; the final tree equals the no-seam twin's (seam neutrality, both refreshing to the same state), and the graph data alone (everything under `.xspec/` but the journal and `.xspec/reviews/`) equals what `build` wrote on the build twin — product-to-itself compares under H-4/H-6. - Every other arm still stages itself freshly built (the freshness constraint); the file header, the staging-constraints notes, and the test title record the arm. Verification: `section-13.5.test.ts` against the built product — 7/7 pass (T13.5-1 with the new arm included). `npm run test:self` — 328 passed, 2 failed: exactly the two known certification-document gate failures (manifest and violator count, Tasks 3–4); the CONF-CORE conformer passes T13.5-1, VIOL-CORE-EARLYWRITE still fails it on its first arm, no other certification change. A throwaway copy of the conformer with the refresh moved before exclusivity (not committed; Task 3's shape) fails T13.5-1 on the stale arm's while-held compare alone, naming `.xspec/graph.json`. Typecheck and Prettier clean. AGENTS.md gains a note on reading `npm run test:self` output; Task 2 removed from the plan (Task 3's cross-reference updated). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 2 +- specs/tmp/FIX_PLAN.md | 41 +----- test/suite/registry/section-13.5.ts | 221 +++++++++++++++++++++++++++- 3 files changed, 220 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5dfaaf2..a99568b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Full test suite (TEST-SPEC sections 1–17, certification included; the Linux CI leg): `npm test`. Build the product first — tests invoke the built executable. - Run a subset of a test project by appending file paths, e.g. `npx vitest run --config test/vitest.config.ts --project suite test/suite/section-1.1-1.2.test.ts` — the way to observe new product-facing tests failing-as-diagnosed against the stub product (Phase 9 red-green). - Running tests also requires the system `git` executable on PATH: harness fixtures script local git repositories (`test/helpers/workspace.ts`). No git configuration is needed — the builder isolates all ambient git config and identity. -- Harness self-tests and certification only (TEST-SPEC 17): `npm run test:self`. +- Harness self-tests and certification only (TEST-SPEC 17): `npm run test:self`. Its certification runner prints one `PASS`/`FAIL` line per (test, fixture) pair as it drives each fixture executable; a violator's `FAIL` lines are its expected outcomes, not failures — Vitest's own failures are the `×` lines and the final `Tests` summary, so when piping the output through `grep`, filter on those rather than on `FAIL`. - Certification fixture products (CERTIFICATIONS.md, e.g. `test/fixtures/conf-core/`) are plain Node ESM programs with no build step and no dependencies: the certification runner (and manual debugging) invokes `node test/fixtures/<fixture>/bin.mjs <command> …` with a staged workspace as the working directory. Violator executables sit beside the conformer's entry as `bin-<deviation>.mjs` (e.g. `bin-nolock.mjs`) and run the same way. `test/fixtures/` is excluded from the harness typecheck; Prettier still formats it. - Windows-leg subset (TEST-SPEC E-6; run by the Windows CI job): `npm run test:windows`. Build the product first. Its byte-identity test compares this leg's representative-fixture outputs against the Linux leg's, read from the directory named by `XSPEC_E6_EXCHANGE_DIR`; the Linux outputs are written by the suite project's E-6 writer test (`test/suite/e6-exchange-writer.test.ts`) whenever that variable is set during `npm test`, and CI exchanges them as the `e6-linux-outputs` artifact (`.github/workflows/ci.yml`). Against the stub product the whole subset is red (diagnosed failures) before any exchange is consulted; once the product conforms, run the Linux suite with the variable set, then the Windows subset with the same variable — with the variable unset or the directory absent, the byte-identity test fails loudly by design (never skips). The T11.6-1 drive-mismatch test (`test/windows/e6-drive-mismatch.test.ts`) additionally stages a substituted drive mapping (`subst`), which exists only on Windows: on any other platform it fails loudly after its same-drive premise arm (never skips), so a fully green `npm run test:windows` needs an actual Windows machine. - Local-only suite (TEST-SPEC E-2; separately invocable, never run in CI, currently empty): `npm run test:local`. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 7b2f943..5827176 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,45 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage A — certification gate: CONF-CORE refresh, T13.5-1 stale arm, VIOL-CORE-EARLYREFRESH, manifest -### Task 2 — T13.5-1: stale-workspace arm (`review create --strategy audit --test-hold` on stale graph data) - -Cites: TEST-SPEC T13.5-1 ("Stale-workspace arm (13.5: the hold precedes every -modification, the 13.3 refresh included): on a workspace whose graph data is -stale — a section's text edited after `build`, the workspace still valid — -`review create --strategy audit --test-hold`: while held, graph data, and every -other workspace file, is byte-identical to its pre-invocation state; after -release the session is created and the graph data refreshed (T10.1-1) — -discriminating a product that refreshes before acquiring exclusivity"); -CERTIFICATIONS.md §CONF-CORE staging constraints (the stale arm is the only -in-scope mutating command started on stale graph data; the basic arm and every -other held command start on a freshly built workspace with no refresh pending); -§VIOL-CORE-EARLYREFRESH expected failures (fails on the while-held compare of -this arm alone). SPEC 13.3, 13.5. - -Now: `test/suite/registry/section-13.5.ts` T13.5-1 has the basic, seam-neutrality, -occupied-hold-path and unknown-flag arms; no stale/refresh staging anywhere in -the file. - -Do: add the arm to T13.5-1 (same ID, same registered body): build a workspace -drawn from CONF-CORE's surface (one spec group, plain `.mdx` sections, no -imports/`d`/embeddings/tags, no git, no `code`/`markdown`/`coverage`/`policy`), -`build` it, edit one section's own text so the workspace stays valid, snapshot -every workspace file (`.xspec/` included), then run `review create --name <n> ---strategy audit --test-hold <path outside the workspace>` through the -existing hold choreography; while held assert the whole workspace byte-identical -to the snapshot (graph data included — the compare must be scoped to include -`.xspec/graph.json`, the one file a pre-hold refresh would change); release; -assert exit 0, the session file exists, and graph data now equals what `build` -writes on an identical twin (product-to-itself compare under H-4/H-6, as the -seam-neutrality arm already does). Keep every other arm's fixture freshly built -(the CERTIFICATIONS freshness constraint). - -Verify: `npx vitest run --config test/vitest.config.ts --project suite -test/suite/section-13.5.test.ts` — T13.5-1 passes against the built product or -fails as a diagnosed product failure (report which); `npm run test:self`: the -CONF-CORE conformer (Task 1) passes T13.5-1, VIOL-CORE-EARLYWRITE still fails it, -no other certification change. - ### Task 3 — VIOL-CORE-EARLYREFRESH executable and deviation switch Cites: CERTIFICATIONS.md §VIOL-CORE-EARLYREFRESH (Scope CONF-CORE; Deviation: @@ -130,7 +91,7 @@ Verify: by hand, on a stale workspace as in Task 1, `node test/fixtures/conf-core/bin-earlyrefresh.mjs review create --name n --strategy audit --test-hold <path>`: while held, `.xspec/graph.json` already holds the refreshed bytes; on a current workspace nothing differs from `bin.mjs`. Then run -T13.5-1 (Task 2) against this binding through the certification runner or a +T13.5-1 (its stale-workspace arm landed with the Task 2 commit) against this binding through the certification runner or a one-off binding: it fails on the stale arm's while-held compare; T6.1-2, T10.4-5, T13.4-5, T13.5-2..5 pass. (The manifest entry is Task 4.) diff --git a/test/suite/registry/section-13.5.ts b/test/suite/registry/section-13.5.ts index dc3b3ae..ad86cea 100644 --- a/test/suite/registry/section-13.5.ts +++ b/test/suite/registry/section-13.5.ts @@ -1,7 +1,9 @@ // TEST-SPEC §13.5 (concurrency and isolation) — SUITE-48: T13.5-1 (hold-seam // basics: five held mutating-command arms each compared byte-identically -// against its no-seam twin (seam neutrality), the occupied-hold-path exit-2 -// arms, and the non-mutating unknown-flag arm), T13.5-2 (mutual exclusion), +// against its no-seam twin (seam neutrality), the stale-workspace arm — the +// hold precedes the 13.3 refresh, graph data byte-identical while held — the +// occupied-hold-path exit-2 arms, and the non-mutating unknown-flag arm), +// T13.5-2 (mutual exclusion), // T13.5-3 (exclusivity ends with the process), T13.5-4 (readers during // mutation + build/query storm), T13.5-5 (atomic visibility via a polling // reader), T13.5-6 (workspace isolation), T13.5-7 (interrupted mutation: @@ -28,6 +30,13 @@ // lookup included — with the seam flag alone removed, and its whole-tree // compare includes the journal (§VIOL-CORE-CHATTYREADS's passing analysis // leans on exactly that sequence equality). +// - T13.5-1's stale-workspace arm is the only in-scope mutating command +// started on stale graph data (§CONF-CORE's freshness constraint, which +// §VIOL-CORE-EARLYREFRESH's passing side leans on): it stages its own +// workspaces, and every other mutating command these tests start — +// T13.5-1's basic arms, T13.5-2's held and excluded commands, T13.5-3's +// killed and subsequent commands, T13.5-4's held mutator — starts on a +// freshly built workspace with no refresh pending (T10.1-1). // - T13.5-2's excluded commands carry no `--test-hold` (§VIOL-CORE-NOLOCK), // and its modifies-nothing compare brackets each excluded command alone, // with the baseline snapshot taken while command 1 is already held @@ -113,6 +122,7 @@ import { assertDirectoriesEqual, assertLeavesUnchanged, assertSnapshotsEqual, + diffSnapshots, snapshotDirectory, } from "../../helpers/snapshot.js"; import type { @@ -328,11 +338,211 @@ function requireRowByScope( // T13.5-1 — hold seam basics // --------------------------------------------------------------------------- +// The staleness edit for T13.5-1's stale-workspace arm, as T10.1-1 stages +// it: same structure and identities, different leaf text — the graph data the +// earlier `build` wrote no longer matches the sources (SPEC 13.3), while the +// workspace stays valid. +const A_MDX_EDITED = A_MDX.replace("Kid text.", "Kid text, edited."); + +/** + * Snapshot scope "graph data alone": everything under `.xspec/` except the + * durable files there — the journal (`.xspec/journal`, SPEC 6.1) and the + * review sessions (`.xspec/reviews/`, SPEC 10.1) — with everything outside + * `.xspec/` pruned. SPEC 13.3 leaves graph data's layout unenumerated, so + * the scope is the graph-data area (11.6) minus its durable occupants; in a + * harness-staged workspace nothing foreign lives there. + */ +function excludeAllButGraphData(relPathBytes: Uint8Array): boolean { + const rel = Buffer.from(relPathBytes).toString("latin1"); + if (rel === ".xspec") return false; + if (!rel.startsWith(".xspec/")) return true; + return ( + rel === ".xspec/journal" || + rel === ".xspec/reviews" || + rel.startsWith(".xspec/reviews/") + ); +} + +/** + * T13.5-1's stale-workspace arm (SPEC 13.5: the hold precedes every + * modification, the 13.3 refresh included). Three identically staged + * CONF-CORE-shaped workspaces: `build`, then one section's own text edited so + * the graph data is stale while the workspace stays valid (as T10.1-1 stages + * it). The held workspace runs `review create --strategy audit --test-hold`: + * while held, every workspace file — `.xspec/graph.json` included, the one + * file a pre-hold refresh would change — is byte-identical to its + * pre-invocation state; after release the command exits 0 and the session + * file exists; the final tree equals the no-seam twin's (seam neutrality on + * this arm too — both refresh, to the same final state), and the graph data + * equals what `build` writes on the build twin (13.3: the refresh writes + * exactly what `build` would write, the recorded derived-file paths — which + * this edit leaves unchanged — excepted): product-to-itself compares under + * H-4, well-defined across directories per H-6. This is the only in-scope + * mutating command the 13.5 tests start on stale graph data + * (CERTIFICATIONS.md §CONF-CORE's freshness constraint, which + * §VIOL-CORE-EARLYREFRESH's passing side leans on); every other arm stages + * itself freshly built. + */ +async function staleWorkspaceArm(product: ProductBinding): Promise<void> { + const create = ["review", "create", "--strategy", "audit", "--name", "n"]; + const context = + "T13.5-1 (stale workspace, held `review create --strategy audit --name n`)"; + await withWorkspace(CORE_DECL, async (workspace) => { + await withWorkspace(CORE_DECL, async (twinNoSeam) => { + await withWorkspace(CORE_DECL, async (twinBuild) => { + // Identical staging on all three: `build`, then the staleness edit. + await buildOk(product, workspace, "T13.5-1 stale arm staging `build`"); + await buildOk( + product, + twinNoSeam, + "T13.5-1 stale arm no-seam twin staging `build`", + ); + await buildOk( + product, + twinBuild, + "T13.5-1 stale arm build twin staging `build`", + ); + for (const staged of [workspace, twinNoSeam, twinBuild]) { + await staged.file("specs/A.mdx", A_MDX_EDITED); + } + + // What `build` writes on the edited sources (the build twin), and the + // staging premise: it differs from the held workspace's current graph + // data, so a refresh is pending there (SPEC 13.3: graph data carries + // the sources' hashes and source ranges, so an edited text cannot + // leave it current) — otherwise the arm could discriminate nothing. + await buildOk( + product, + twinBuild, + "T13.5-1 stale arm build twin `build` on the edited sources", + ); + const staleGraphData = await snapshotDirectory(workspace.root, { + exclude: excludeAllButGraphData, + }); + const builtGraphData = await snapshotDirectory(twinBuild.root, { + exclude: excludeAllButGraphData, + }); + if (diffSnapshots(staleGraphData, builtGraphData).length === 0) { + fail( + `${context}: staging premise — after the edit the workspace's ` + + `graph data must be stale, i.e. differ from what \`build\` ` + + `writes on an identically edited twin (SPEC 13.3: graph data ` + + `carries the sources' hashes and source ranges), but the two ` + + `are byte-identical, so no refresh is pending and the arm ` + + `cannot discriminate (H-8)`, + ); + } + + // Pre-invocation state: every workspace file, `.xspec/` included. + const before = await snapshotDirectory(workspace.root); + const hold = holdPathFor(workspace, "hold-stale.tmp"); + const running = await startProduct(product, { + cwd: workspace.root, + argv: [...create, "--test-hold", hold], + }); + try { + await awaitHoldFile(running, hold, context); + await assertEmptyHoldFile(hold, context); + const whileHeld = await snapshotDirectory(workspace.root); + assertSnapshotsEqual( + before, + whileHeld, + `${context}: the workspace while held vs before the command ` + + `started — graph data (.xspec/graph.json, the one file the ` + + `pending 13.3 refresh changes) and every other workspace ` + + `file: the hold is created after acquiring exclusivity and ` + + `before every modification, the refresh included, so a ` + + `product that refreshes before acquiring exclusivity fails ` + + `here (SPEC 13.5, 13.3)`, + ); + if (running.hasExited()) { + fail( + `${context}: the command must proceed only once the hold ` + + `file is deleted, but it exited while the hold file still ` + + `existed (SPEC 13.5) — ${await describeExit(running)}`, + ); + } + await releaseHoldFile(hold); + let result: RunResult; + try { + result = await running.waitForExit(); + } catch (error) { + return fail( + `${context}: once the hold file is deleted the command must ` + + `proceed and complete normally (SPEC 13.5) — ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + assertExitCode( + result, + 0, + `${context}: completes normally once the hold file is deleted ` + + `(SPEC 13.5)`, + ); + } finally { + running.kill(); + await releaseHoldFile(hold); + } + const kind = await workspace.kind(sessionRel("n")); + if (kind !== "file") { + fail( + `${context}: after completing normally, the session file exists ` + + `as a plain file at ${sessionRel("n")} (SPEC 10.1); found ${kind}`, + ); + } + + // Seam neutrality on this arm: the no-seam twin runs the same + // operation on the same stale state without `--test-hold`, and the + // final trees are compared whole — sources, journal, sessions, + // derived files, and graph data (both refresh, to the same state). + await expectExit( + product, + twinNoSeam, + create, + 0, + "T13.5-1 (stale workspace, twin) `review create --strategy audit " + + "--name n` run without --test-hold on the identical stale twin " + + "workspace (SPEC 13.5)", + ); + await assertDirectoriesEqual( + workspace.root, + twinNoSeam.root, + `${context} vs its no-seam twin: the final workspace state of the ` + + `held-then-released run — sources, journal, sessions, derived ` + + `files, and graph data — is byte-identical to the same operation ` + + `run without --test-hold on an identical twin workspace (SPEC ` + + `13.5 seam neutrality; a product-to-itself comparison under H-4, ` + + `well-defined across directories per H-6)`, + ); + + // The refresh (SPEC 13.3, T10.1-1): after release the graph data is + // refreshed — byte-identical to what `build` writes on the build + // twin, the edit leaving the recorded derived-file paths unchanged. + await assertDirectoriesEqual( + workspace.root, + twinBuild.root, + `${context} vs its build twin, graph data alone (everything ` + + `under .xspec/ but the journal and .xspec/reviews/): after ` + + `release the 13.3 refresh has run, writing exactly what ` + + `\`build\` writes on an identically edited twin — the recorded ` + + `derived-file paths, which the edit leaves unchanged, excepted ` + + `(SPEC 13.3, 13.5; T10.1-1; a product-to-itself comparison ` + + `under H-4/H-6)`, + { exclude: excludeAllButGraphData }, + ); + }); + }); + }); +} + const T13_5_1 = defineProductTest({ id: "T13.5-1", title: - "each mutating command (`rename`, file-form `move`, `review create/resolve/split`) with `--test-hold` creates an empty file at the path after acquiring exclusivity and before modifying anything (workspace byte-identical while held), proceeds only once the file is deleted, and completes normally, the held-then-released run's final workspace state — sources, journal, sessions, derived files, and graph data — byte-identical to the same operation run without `--test-hold` on an identical twin workspace (seam neutrality: the seam changes no other behavior; H-4/H-6); anything at the hold path — file, directory, or symlink — fails the command exit 2 without modifying anything; `build` and `query` given `--test-hold` fail exit 2 as an unknown flag (SPEC 13.5, 12.0)", + "each mutating command (`rename`, file-form `move`, `review create/resolve/split`) with `--test-hold` creates an empty file at the path after acquiring exclusivity and before modifying anything (workspace byte-identical while held), proceeds only once the file is deleted, and completes normally, the held-then-released run's final workspace state — sources, journal, sessions, derived files, and graph data — byte-identical to the same operation run without `--test-hold` on an identical twin workspace (seam neutrality: the seam changes no other behavior; H-4/H-6); on a workspace whose graph data is stale (a section's text edited after `build`, the workspace still valid) `review create --strategy audit --test-hold` leaves graph data and every other workspace file byte-identical while held — the hold precedes the 13.3 refresh too — and after release creates the session and refreshes the graph data to what `build` writes on an identical twin; anything at the hold path — file, directory, or symlink — fails the command exit 2 without modifying anything; `build` and `query` given `--test-hold` fail exit 2 as an unknown flag (SPEC 13.5, 13.3, 12.0)", run: async (product) => { + // Every arm below stages itself on a freshly built workspace with no + // refresh pending (CERTIFICATIONS.md §CONF-CORE's freshness constraint); + // the stale-workspace arm at the end stages its own. await withWorkspace(CORE_DECL, async (workspace) => { // Seam neutrality (SPEC 13.5: the seam changes no other behavior): an // identical twin workspace is driven through the exact same command @@ -686,6 +896,11 @@ const T13_5_1 = defineProductTest({ ); } }); + + // Stale-workspace arm (SPEC 13.5: the hold precedes every modification, + // the 13.3 refresh included) — on its own workspaces, the only in-scope + // mutating command started on stale graph data. + await staleWorkspaceArm(product); }, }); From 75b6b8f534386340bf5e88352f79d039c2f369d2 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 04:39:48 +0000 Subject: [PATCH 186/226] =?UTF-8?q?sdg(phase-9):=20VIOL-CORE-EARLYREFRESH?= =?UTF-8?q?=20violator=20=E2=80=94=20the=2013.3=20refresh=20runs=20before?= =?UTF-8?q?=20exclusivity,=20one=20deviation=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX_PLAN Task 3 (CERTIFICATIONS.md §VIOL-CORE-EARLYREFRESH; SPEC 13.3, 13.5; T13.5-1's stale-workspace arm). In test/fixtures/conf-core/: - product.mjs: a `refreshBeforeExclusivity` deviation switch, consumed at one point in runMutating — when set, Task 1's position-independent refresh closure runs before acquireExclusivity (before the lock and the hold) and the post-hold call becomes a no-op, so the refresh runs exactly once, at the moved position; the lock, the hold file created after exclusivity and before every other write, the operation's writes, and the refresh's bytes are the conformer's, and current graph data is refreshed by nothing. The `deviations` doc comment gains the switch in document order (after EARLYWRITE, before STALELOCK). - bin-earlyrefresh.mjs: the violator executable, mirroring bin-earlywrite.mjs (header quoting the deviation and the certified set; `runXspec(argv, cwd, { refreshBeforeExclusivity: true })`). Verification: by hand on a stale CONF-CORE-shaped workspace (one section's text edited after `build`), `bin-earlyrefresh.mjs review create --name n --strategy audit --test-hold <path>` — while held, .xspec/graph.json already holds the bytes `build` writes on a twin, where bin.mjs leaves it byte-identical to its stale snapshot; both exit 0 after release, create the session, and end with graph data equal to the twin's. On a freshly built workspace nothing differs from bin.mjs (graph data keeps its inode and mtime while held; exit 0). Through the certification runner against a one-off binding (a temporary self-project file, not committed): of the eight §CONF-CORE in-scope tests, exactly T13.5-1 fails — on the stale arm's while-held compare alone, naming .xspec/graph.json — and T6.1-2, T10.4-5, T13.4-5, T13.5-2..5 pass, no errors or hangs. `npm run test:self`: 328 passed, 2 failed (330) — exactly the two known certification-document gate pins Task 4 removes; every per-fixture verdict unchanged. Typecheck and Prettier clean. AGENTS.md records the one-off-binding technique; Task 3 removed from the plan and Task 4's prerequisites marked landed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 1 + specs/tmp/FIX_PLAN.md | 34 ++------------------ test/fixtures/conf-core/bin-earlyrefresh.mjs | 18 +++++++++++ test/fixtures/conf-core/product.mjs | 26 +++++++++++++-- 4 files changed, 46 insertions(+), 33 deletions(-) create mode 100644 test/fixtures/conf-core/bin-earlyrefresh.mjs diff --git a/AGENTS.md b/AGENTS.md index a99568b..400ed41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Running tests also requires the system `git` executable on PATH: harness fixtures script local git repositories (`test/helpers/workspace.ts`). No git configuration is needed — the builder isolates all ambient git config and identity. - Harness self-tests and certification only (TEST-SPEC 17): `npm run test:self`. Its certification runner prints one `PASS`/`FAIL` line per (test, fixture) pair as it drives each fixture executable; a violator's `FAIL` lines are its expected outcomes, not failures — Vitest's own failures are the `×` lines and the final `Tests` summary, so when piping the output through `grep`, filter on those rather than on `FAIL`. - Certification fixture products (CERTIFICATIONS.md, e.g. `test/fixtures/conf-core/`) are plain Node ESM programs with no build step and no dependencies: the certification runner (and manual debugging) invokes `node test/fixtures/<fixture>/bin.mjs <command> …` with a staged workspace as the working directory. Violator executables sit beside the conformer's entry as `bin-<deviation>.mjs` (e.g. `bin-nolock.mjs`) and run the same way. `test/fixtures/` is excluded from the harness typecheck; Prettier still formats it. +- To run a selection of product tests against one fixture executable that the certification manifest does not (yet) list — e.g. a violator before its manifest entry lands — put a temporary `*.test.ts` under `test/self/` (the self project's include pattern is `test/self/**/*.test.ts`; files elsewhere never run) that calls `runProductTests` from `test/self/certification-runner.ts` with a `ProductBinding` of the fixture (`command: process.execPath`, `prefixArgs: [<abs bin path>]`) and `productTestSuite.select([...ids])` from `test/suite/registry/index.ts`, run it as `npx vitest run --config test/vitest.config.ts --project self test/self/<file>.test.ts`, and delete the file before committing. - Windows-leg subset (TEST-SPEC E-6; run by the Windows CI job): `npm run test:windows`. Build the product first. Its byte-identity test compares this leg's representative-fixture outputs against the Linux leg's, read from the directory named by `XSPEC_E6_EXCHANGE_DIR`; the Linux outputs are written by the suite project's E-6 writer test (`test/suite/e6-exchange-writer.test.ts`) whenever that variable is set during `npm test`, and CI exchanges them as the `e6-linux-outputs` artifact (`.github/workflows/ci.yml`). Against the stub product the whole subset is red (diagnosed failures) before any exchange is consulted; once the product conforms, run the Linux suite with the variable set, then the Windows subset with the same variable — with the variable unset or the directory absent, the byte-identity test fails loudly by design (never skips). The T11.6-1 drive-mismatch test (`test/windows/e6-drive-mismatch.test.ts`) additionally stages a substituted drive mapping (`subst`), which exists only on Windows: on any other platform it fails loudly after its same-drive premise arm (never skips), so a fully green `npm run test:windows` needs an actual Windows machine. - Local-only suite (TEST-SPEC E-2; separately invocable, never run in CI, currently empty): `npm run test:local`. - Property tests (TEST-SPEC 16; machinery in `test/helpers/property.ts`) run a fixed seed set by default — the CI mode, fully deterministic. To rerun with a specific seed: `XSPEC_PROPERTY_SEED=<uint32 from the failure message>`. Optional randomized local mode: `XSPEC_PROPERTY_SEED=random` (each property reports its seed for replay). Never set the variable in CI. Vitest intercepts the seed reports (`console.info`); add `--disable-console-intercept` to the vitest invocation to see them — but place any test-file filter argument *before* that flag (a file path following `--disable-console-intercept` is not treated as a filter and the whole project runs). diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 5827176..e32e819 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,36 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage A — certification gate: CONF-CORE refresh, T13.5-1 stale arm, VIOL-CORE-EARLYREFRESH, manifest -### Task 3 — VIOL-CORE-EARLYREFRESH executable and deviation switch - -Cites: CERTIFICATIONS.md §VIOL-CORE-EARLYREFRESH (Scope CONF-CORE; Deviation: -"The 13.3 refresh a mutating `review` subcommand performs on a stale workspace -(T10.1-1) runs before workspace exclusivity is acquired, so stale graph data is -rewritten before the hold file is created. A single deviation … the hold file is -still created after exclusivity and before every other write … and a workspace -whose graph data is current is refreshed by nothing"; Certifies: T13.5-1; -Expected failures: exactly T13.5-1, on the stale arm's while-held compare). - -Now: no `test/fixtures/conf-core/bin-earlyrefresh.mjs`; `product.mjs` has no -corresponding switch (existing switches: `noMutualExclusion`, `writesBeforeHold`, -`staleLockBlocks`, `partialDerivedWrites`, `chattyReads`, `persistReadInvalidation`). - -Do: add a `deviations.refreshBeforeExclusivity` (name free) switch in -`product.mjs` consumed at exactly one point: in the mutating `review` -subcommands, run Task 1's refresh before exclusivity is acquired (before the -lock and the hold) instead of after the hold; everything else identical to the -conformer. Add `bin-earlyrefresh.mjs` mirroring `bin-earlywrite.mjs` (header -comment quoting the deviation and the certified set; `runXspec(argv, cwd, -{ <switch>: true })`). - -Verify: by hand, on a stale workspace as in Task 1, `node -test/fixtures/conf-core/bin-earlyrefresh.mjs review create --name n --strategy -audit --test-hold <path>`: while held, `.xspec/graph.json` already holds the -refreshed bytes; on a current workspace nothing differs from `bin.mjs`. Then run -T13.5-1 (its stale-workspace arm landed with the Task 2 commit) against this binding through the certification runner or a -one-off binding: it fails on the stale arm's while-held compare; T6.1-2, -T10.4-5, T13.4-5, T13.5-2..5 pass. (The manifest entry is Task 4.) - ### Task 4 — Certification manifest and whole-document pins for the revised CERTIFICATIONS.md Cites: TEST-SPEC C-1 (whole-document gate); CERTIFICATIONS.md §CONF-CORE @@ -113,7 +83,9 @@ Do: edit the manifest verbatim to the document (drop T6.1-1 from both sets; add `violator("VIOL-CORE-EARLYREFRESH", "conf-core/bin-earlyrefresh.mjs", ["T13.5-1"])` between EARLYWRITE and STALELOCK); set the pin to 17 and the title to match; re-read the manifest header comment and the per-fixture generation in -`certification.test.ts` for any other count or ID pin. Prerequisites: Tasks 1–3. +`certification.test.ts` for any other count or ID pin. Prerequisites: Tasks 1–3, all landed (f218488, f7e6054, and the commit +that added `test/fixtures/conf-core/bin-earlyrefresh.mjs` with the conformer's +`refreshBeforeExclusivity` switch). Verify: `npm run test:self` fully green — the document gate, the manifest equality, and every per-fixture certification (EARLYREFRESH fails exactly diff --git a/test/fixtures/conf-core/bin-earlyrefresh.mjs b/test/fixtures/conf-core/bin-earlyrefresh.mjs new file mode 100644 index 0000000..96efbbb --- /dev/null +++ b/test/fixtures/conf-core/bin-earlyrefresh.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +// VIOL-CORE-EARLYREFRESH violator executable (CERTIFICATIONS.md +// §VIOL-CORE-EARLYREFRESH). The CONF-CORE conformer with exactly one +// behavioral deviation: the 13.3 refresh a mutating `review` subcommand +// performs on a stale workspace (T10.1-1) runs before workspace exclusivity +// is acquired, so stale graph data is rewritten before the hold file is +// created — one ordering rule of 13.5 (the hold precedes every modification, +// the refresh included) broken for the refresh alone; the hold file is still +// created after exclusivity and before every other write, and a workspace +// whose graph data is current is refreshed by nothing. Certifies T13.5-1 +// (C-1): exactly that test fails against this fixture, on its stale-workspace +// arm's while-held compare; every other §CONF-CORE in-scope test passes. +import { runXspec } from "./product.mjs"; + +const code = await runXspec(process.argv.slice(2), process.cwd(), { + refreshBeforeExclusivity: true, +}); +process.exit(code); diff --git a/test/fixtures/conf-core/product.mjs b/test/fixtures/conf-core/product.mjs index 090a7fe..71f2cd0 100644 --- a/test/fixtures/conf-core/product.mjs +++ b/test/fixtures/conf-core/product.mjs @@ -2078,9 +2078,26 @@ async function runMutating( // `rename` and file-form `move` finish with a full regeneration instead // (SPEC 6.4, 6.5) — and the conformer runs it below, after exclusivity is // acquired and the hold has been released, before the operation's writes. + // The closure is position-independent so that one deviation can move it. const refreshIfStale = async () => { if (refreshesGraphData) await refreshStaleGraphData(config); }; + let refreshAfterHold = refreshIfStale; + if (deviations.refreshBeforeExclusivity) { + // VIOL-CORE-EARLYREFRESH (CERTIFICATIONS.md): the 13.3 refresh a + // mutating `review` subcommand performs on a stale workspace runs here, + // before workspace exclusivity is acquired — before the lock and the + // hold — so stale graph data is rewritten before the hold file is + // created; the refresh after the hold (below) becomes a no-op, so the + // refresh runs exactly once, at this position. Everything else — the + // lock; the hold file created after exclusivity and before every other + // write (the session write, `rename`/`move`'s edits, journal appends, + // the finishing regeneration of 6.4/6.5); the refresh's own bytes — is + // exactly the conformer's behavior, and a workspace whose graph data is + // current is refreshed by nothing, where the deviation is unobservable. + await refreshIfStale(); + refreshAfterHold = async () => {}; + } // VIOL-CORE-NOLOCK (CERTIFICATIONS.md): mutating commands do not exclude // one another — exclusivity is neither acquired nor checked, so a second // mutating command started while another runs or is held proceeds normally @@ -2111,13 +2128,13 @@ async function runMutating( // exits normally with the operation's outcome. The hold seam's own // semantics (empty file, occupied path fails exit 2) and everything // else are exactly the conformer's behavior. - await refreshIfStale(); + await refreshAfterHold(); const code = await operate(config); await holdIfRequested(); return code; } await holdIfRequested(); - await refreshIfStale(); + await refreshAfterHold(); return await operate(config); } finally { await lock.release(); @@ -3154,6 +3171,11 @@ async function commandReview(io, cwd, argv) { * - `writesBeforeHold` (VIOL-CORE-EARLYWRITE): a mutating command performs * its workspace modifications before creating the hold file; see * runMutating. + * - `refreshBeforeExclusivity` (VIOL-CORE-EARLYREFRESH): the 13.3 refresh a + * mutating `review` subcommand performs on a stale workspace runs before + * workspace exclusivity is acquired, so stale graph data is rewritten + * before the hold file is created; the hold file is still created after + * exclusivity and before every other write; see runMutating. * - `staleLockBlocks` (VIOL-CORE-STALELOCK): workspace exclusivity is not * released by abnormal termination — a lock file left by a killed holder * refuses every later mutating command; see acquireExclusivity. From 300f23a32408e2276595864c5c34d82ec39754f2 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 04:46:27 +0000 Subject: [PATCH 187/226] =?UTF-8?q?sdg(phase-9):=20certification=20manifes?= =?UTF-8?q?t=20and=20gate=20pins=20for=20the=20revised=20CERTIFICATIONS.md?= =?UTF-8?q?=20=E2=80=94=20VIOL-CORE-EARLYREFRESH=20wired,=20T6.1-1=20out?= =?UTF-8?q?=20of=20CONF-CORE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX_PLAN Task 4 (TEST-SPEC C-1 whole-document gate; CERTIFICATIONS.md §CONF-CORE, §VIOL-CORE-EARLYREFRESH, §VIOL-CORE-CHATTYREADS, §Exclusions). - test/self/certification-fixtures.ts: CONF-CORE's in-scope set is the document's eight (T6.1-1 dropped — see its Exclusions entry); the VIOL-CORE-EARLYREFRESH entry (conf-core/bin-earlyrefresh.mjs, certifies T13.5-1) sits in document order between EARLYWRITE and STALELOCK, its deviation summarized inline like its siblings; CHATTYREADS certifies T13.4-5 alone. - test/self/certification-document.test.ts: EXPECTED_VIOLATORS 16 -> 17 and the gate test's title to match ("5 conformers and 17 violators"). - test/self/certification.test.ts: the header comment's count ("all seventeen violators"); the per-fixture generation carries no other pin. Verification: `npm run test:self` 331 passed / 331 (16 files, no harness errors or hangs), previously 328/330 — the two certification-document gate tests (count pin, manifest equality) now pass, and the per-fixture report reads: CONF-CORE conformer 8 pass / 0 fail; VIOL-CORE-EARLYREFRESH 7 pass / 1 fail (exactly T13.5-1); VIOL-CORE-CHATTYREADS 7 pass / 1 fail (exactly T13.4-5); every other fixture's verdict unchanged. Typecheck and Prettier clean. Task 4 removed from the plan — Stage A (the certification gate) is complete, so its heading is gone and the rules bullet trimmed accordingly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 37 +++--------------------- test/self/certification-document.test.ts | 4 +-- test/self/certification-fixtures.ts | 13 +++++++-- test/self/certification.test.ts | 2 +- 4 files changed, 18 insertions(+), 38 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index e32e819..43b4400 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -46,10 +46,10 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. failure; a harness error (crash, stack overflow, exhausted limit, decode exception outside the assertion protocol) is never acceptable. For a test in a certification scope, additionally run `npm run test:self` and confirm C-1. -- Work top to bottom: Stage A turns the self project green (its four tasks form - one dependency chain); Stage B removes the H-11 harness error; Stage C adds the - CONF-DISC code-group surface; Stage D is the remaining suite gaps in section - order (independent of each other unless a task names a prerequisite). +- Work top to bottom (Stage A, the certification gate, is complete): Stage B + removes the H-11 harness error; Stage C adds the CONF-DISC code-group + surface; Stage D is the remaining suite gaps in section order (independent + of each other unless a task names a prerequisite). - Commit `sdg(phase-9): <imperative summary>`, ending every commit message with the two trailer lines `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>` and @@ -63,35 +63,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. --- -## Stage A — certification gate: CONF-CORE refresh, T13.5-1 stale arm, VIOL-CORE-EARLYREFRESH, manifest - -### Task 4 — Certification manifest and whole-document pins for the revised CERTIFICATIONS.md - -Cites: TEST-SPEC C-1 (whole-document gate); CERTIFICATIONS.md §CONF-CORE -"In-scope tests: T6.1-2, T10.4-5, T13.4-5, T13.5-1, T13.5-2, T13.5-3, T13.5-4, -T13.5-5" (T6.1-1 excluded — see its Exclusions entry); §VIOL-CORE-CHATTYREADS -"Certifies: T13.4-5"; §VIOL-CORE-EARLYREFRESH "Certifies: T13.5-1", placed in -document order after VIOL-CORE-EARLYWRITE and before VIOL-CORE-STALELOCK; the -document now defines 5 conformers and 17 violators. - -Now: `test/self/certification-fixtures.ts` lists T6.1-1 in CONF-CORE's in-scope -set and in CHATTYREADS's certified set and has no EARLYREFRESH entry; -`test/self/certification-document.test.ts` pins `EXPECTED_VIOLATORS = 16` (line -35) and its test title says "16 violators" (line 268). Both gate tests fail. - -Do: edit the manifest verbatim to the document (drop T6.1-1 from both sets; add -`violator("VIOL-CORE-EARLYREFRESH", "conf-core/bin-earlyrefresh.mjs", -["T13.5-1"])` between EARLYWRITE and STALELOCK); set the pin to 17 and the title -to match; re-read the manifest header comment and the per-fixture generation in -`certification.test.ts` for any other count or ID pin. Prerequisites: Tasks 1–3, all landed (f218488, f7e6054, and the commit -that added `test/fixtures/conf-core/bin-earlyrefresh.mjs` with the conformer's -`refreshBeforeExclusivity` switch). - -Verify: `npm run test:self` fully green — the document gate, the manifest -equality, and every per-fixture certification (EARLYREFRESH fails exactly -T13.5-1; CHATTYREADS fails exactly T13.4-5; CONF-CORE passes its eight in-scope -tests). - ## Stage B — H-11 answer-scale capacity (P-11 harness error), S-8, S-2, T1.3-7 ### Task 5 — Make `decodeViewNodeForm` non-recursive (H-11) diff --git a/test/self/certification-document.test.ts b/test/self/certification-document.test.ts index 8a6b888..9491b9d 100644 --- a/test/self/certification-document.test.ts +++ b/test/self/certification-document.test.ts @@ -32,7 +32,7 @@ const CERTIFICATIONS_PATH = fileURLToPath( // this gate when the document's fixture set changes, and guard against a // parser regression losing entries wholesale. const EXPECTED_CONFORMERS = 5; -const EXPECTED_VIOLATORS = 16; +const EXPECTED_VIOLATORS = 17; /** A violator entry as parsed from CERTIFICATIONS.md. */ interface DocumentViolator { @@ -265,7 +265,7 @@ function parseDocument(): readonly DocumentConformer[] { ); } -test("CERTIFICATIONS.md defines exactly 5 conformers and 16 violators (C-1 whole-document gate)", () => { +test("CERTIFICATIONS.md defines exactly 5 conformers and 17 violators (C-1 whole-document gate)", () => { const document = parseDocument(); expect( { diff --git a/test/self/certification-fixtures.ts b/test/self/certification-fixtures.ts index 9e2317c..a4fe840 100644 --- a/test/self/certification-fixtures.ts +++ b/test/self/certification-fixtures.ts @@ -96,7 +96,6 @@ export const CERTIFICATION_FIXTURES: readonly CertificationConformer[] = [ "CONF-CORE", "conf-core/bin.mjs", [ - "T6.1-1", "T6.1-2", "T10.4-5", "T13.4-5", @@ -122,6 +121,17 @@ export const CERTIFICATION_FIXTURES: readonly CertificationConformer[] = [ "T13.5-1", "T13.5-4", ]), + // VIOL-CORE-EARLYREFRESH: the 13.3 refresh a mutating `review` + // subcommand performs on a stale workspace runs before workspace + // exclusivity is acquired, so stale graph data is rewritten before the + // hold file is created — one ordering rule of 13.5 (the hold precedes + // every modification, the refresh included) broken for the refresh + // alone; the hold file is still created after exclusivity and before + // every other write, and a workspace whose graph data is current is + // refreshed by nothing. + violator("VIOL-CORE-EARLYREFRESH", "conf-core/bin-earlyrefresh.mjs", [ + "T13.5-1", + ]), // VIOL-CORE-STALELOCK: workspace exclusivity is not released by // abnormal termination — after a mutating command's process is killed, // every later mutating command in that workspace is refused with the @@ -143,7 +153,6 @@ export const CERTIFICATION_FIXTURES: readonly CertificationConformer[] = [ // `.xspec/journal`, creating the file when absent. Mutating commands, // and the entries `rename`/`move` append, are unchanged. violator("VIOL-CORE-CHATTYREADS", "conf-core/bin-chattyreads.mjs", [ - "T6.1-1", "T13.4-5", ]), // VIOL-CORE-PERSISTREADS: review reads persist read-time invalidation — diff --git a/test/self/certification.test.ts b/test/self/certification.test.ts index 7802913..7d027ef 100644 --- a/test/self/certification.test.ts +++ b/test/self/certification.test.ts @@ -2,7 +2,7 @@ // // One per-fixture verification is generated below for every entry of the // CERTIFICATION_FIXTURES manifest (certification-fixtures.ts) — all five -// conformers and all sixteen violators — and the whole-document gate +// conformers and all seventeen violators — and the whole-document gate // (certification-document.test.ts) proves that manifest equal to // specs/CERTIFICATIONS.md, so certification demonstrably runs against each // fixture in the document (C-1). From 5407ec31d7e163ebc00303820c96a606ce23a7e0 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 04:59:09 +0000 Subject: [PATCH 188/226] =?UTF-8?q?sdg(phase-9):=20de-recurse=20the=20view?= =?UTF-8?q?=20decode=20path=20=E2=80=94=20decodeViewNodeForm,=20the=20mark?= =?UTF-8?q?er=20walk,=20P-11's=20unavailability=20walk,=20and=20the=20diag?= =?UTF-8?q?nosis=20renderer's=20fallback=20(H-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5 of FIX_PLAN.md. `decodeViewNodeForm` (test/helpers/adapters/forms.ts) now walks the section tree through an explicit frame stack — enterViewNode (the node's own members, in the original check order) / leaveViewNode (children order, node, text members) — so the depth-2048 and depth-4096 towers P-8/P-11 stage decode with no depth cap; identical checks, sites and messages (S-5 green). Confirming the rest of decodeViewReport at depth 4096 exposed two more per-level recursions on the same path, converted the same way: assertUnavailabilityMarkerForms (forms.ts) and P-11's documentCarriesUnavailability (section-16-p11.ts) — the latter was the next frame the cited reproduction overflowed in. describeJsonValue (decode.ts) renders a diagnosis excerpt without recursion when V8's JSON.stringify throws RangeError at that nesting (structuredClone does too; JSON.parse is iterative; the plain frame budget here is ~9.9k frames). Verified: XSPEC_PROPERTY_SEED=271828183 section-16-p11 passes (no harness error); section-16-p8 passes; a temporary self-test (not committed) decoded towers 4096 and 16384 deep in both arms and diagnosed deep-path failures exactly; npm run test:self 331/331; typecheck and format clean. Plan: Task 5 removed, Task 6 trimmed to the remaining audit. AGENTS.md: the V8 limits note. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 1 + specs/tmp/FIX_PLAN.md | 65 +++-------- test/helpers/adapters/decode.ts | 53 ++++++++- test/helpers/adapters/forms.ts | 162 ++++++++++++++++++++------ test/suite/registry/section-16-p11.ts | 28 +++-- 5 files changed, 220 insertions(+), 89 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 400ed41..b78e0e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,3 +19,4 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Property tests (TEST-SPEC 16; machinery in `test/helpers/property.ts`) run a fixed seed set by default — the CI mode, fully deterministic. To rerun with a specific seed: `XSPEC_PROPERTY_SEED=<uint32 from the failure message>`. Optional randomized local mode: `XSPEC_PROPERTY_SEED=random` (each property reports its seed for replay). Never set the variable in CI. Vitest intercepts the seed reports (`console.info`); add `--disable-console-intercept` to the vitest invocation to see them — but place any test-file filter argument *before* that flag (a file path following `--disable-console-intercept` is not treated as a filter and the whole project runs). - Format code (Prettier, default config, `src/` and `test/` only): `npm run format`; verify: `npm run format:check`. - CI: `.github/workflows/ci.yml` — harness-self (Linux), full suite (Linux, network disabled after setup via `.github/scripts/run-without-network.sh`), and the Windows E-6 leg on every pull request. The `e6-linux-outputs` upload step of the full-suite job has no `if: always()`, so it is skipped whenever any Linux test fails (the run ends with no artifact): the Windows byte-identity test then fails loudly on the missing manifest until suite-linux is green — that arm's verdict is meaningful only on a green Linux run. +- Answer-document scale (TEST-SPEC H-11): the suite stages section towers 4096 deep (`NESTING_DEPTHS` in `test/suite/registry/section-16-p8.ts`), past V8's limits in this Node — a plain recursive function gets ~9.9k frames, and `JSON.stringify`/`structuredClone` throw `RangeError` at that nesting while `JSON.parse` is iterative. Harness walks over answer documents therefore use explicit stacks (`decodeViewNodeForm`, `assertUnavailabilityMarkerForms`, `describeJsonValue`'s fallback in `test/helpers/adapters/`); a property reporting `RangeError: Maximum call stack size exceeded` as a harness error names a walk still recursing per level — its `Caused by` frames locate it. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 43b4400..1233b9a 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,61 +65,34 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage B — H-11 answer-scale capacity (P-11 harness error), S-8, S-2, T1.3-7 -### Task 5 — Make `decodeViewNodeForm` non-recursive (H-11) - -Cites: TEST-SPEC §0 H-11 ("the H-3/12.7 decoding, and every subsequent -per-datum traversal of an answer document … succeed, with harness-internal -capacity limits … dimensioned to the scales the suite itself stages … A -harness-side failure while capturing or evaluating an answer — a crash, hang, or -exhausted internal limit — is reported as a defect in the harness"); §16 P-8 -(staged nesting floor ≥ 2048; the suite stages `NESTING_DEPTHS = [512, 2048, -4096]` in `test/suite/registry/section-16-p8.ts` ≈ line 286) and P-11; §1.3 -T1.3-7; §17 S-8. - -Now: `test/helpers/adapters/forms.ts` `decodeViewNodeForm` (≈ lines 1623–1695) -calls itself once per `children` level, building nested `DecodeSite`s through -`at(site, …)`; at the depth-2048 tower it throws `RangeError: Maximum call -stack size exceeded` (frames `forms.ts:1694 → :1650 → decodeRangeForm :161`). -Reproduce: `npm run build && XSPEC_PROPERTY_SEED=271828183 npx vitest run ---config test/vitest.config.ts --project suite test/suite/section-16-p11.test.ts` -(trial 1, "specs/B.mdx: append a depth-2048 balanced section tower", arm `view`). - -Do: rewrite the `view` node decoding as an explicit-stack (iterative) traversal -that performs exactly the same checks in the same order per node — member -allow-list with/without `--text`, identity datum never `null`, range, opening/ -closing, attributes in tag order strictly ascending, tags/coverage three-state -datums, children in document order strictly ascending — and produces the same -`ViewNode` tree and the same `formFail` sites/messages (S-5's output-adapter -self-tests must stay green). Node's V8 `JSON.parse` is iterative, but confirm -with the depth-4096 vector that no other step in `decodeViewReport` (path -building, `at`, error rendering, `structuredClone`/`JSON.stringify` on failure) -recurses per level. No depth cap of any kind. - -Verify: the reproduction above no longer reports a harness error (P-11 passes -or fails as a diagnosed product failure); `npm run test:self` green -(`s5-output-adapters.test.ts` in particular); `npm run typecheck`. - ### Task 6 — De-recurse every other per-datum walk over answer documents (H-11 audit) Cites: TEST-SPEC §0 H-11 (every subsequent per-datum traversal, "16's property walks included"); §16 P-8, P-11; §17 S-8. -Now: at least two more walks recurse natively over `view` trees: -`assertUnavailabilityMarkerForms` in `test/helpers/adapters/forms.ts` (the -`unavailable`-exclusivity walk) and `documentCarriesUnavailability` in -`test/suite/registry/section-16-p11.ts`. Others may exist (Markdown/oracle -walks over nested sections, `query subtree` row handling, the E-6 comparators, -fixture generators that emit nested towers recursively, `test/helpers/ -property.ts` shrinkers). +Now: Task 5 (done; `git log` — "de-recurse the view decode path") converted the +walks on the `view` decode path — `decodeViewNodeForm` and +`assertUnavailabilityMarkerForms` in `test/helpers/adapters/forms.ts`, +`documentCarriesUnavailability` in `test/suite/registry/section-16-p11.ts` — +to explicit stacks, and gave `describeJsonValue` (`test/helpers/adapters/ +decode.ts`) a non-recursive fallback for the `RangeError` V8's `JSON.stringify` +throws at tower depth 4096 (`structuredClone` throws the same; `JSON.parse` is +iterative; this Node's plain frame budget is ~9.9k frames). The P-11 +reproduction (`npm run build && XSPEC_PROPERTY_SEED=271828183 npx vitest run +--config test/vitest.config.ts --project suite test/suite/section-16-p11.test.ts`) +and `test/suite/section-16-p8.test.ts` run without harness errors. Others may +still recurse per level: Markdown/oracle walks over nested sections, `query +subtree` row handling, the E-6 comparators, fixture generators that emit nested +towers recursively, `test/helpers/property.ts` shrinkers. Do: grep `test/helpers/` and `test/suite/registry/` for functions that call themselves (or mutually) per nesting level of an answer document or of a staged tree, and convert each to an explicit stack/queue with identical semantics and -ordering. Cover the two named walks first. Dimension: depth 4096 (the largest -staged nesting) must pass with margin; do not introduce depth caps. Leave a -one-line comment at each converted site citing H-11. +ordering. Dimension: depth 4096 (the largest staged nesting) must pass with +margin; do not introduce depth caps. Leave a one-line comment at each converted +site citing H-11. -Verify: `npm run test:self` green; the P-11 reproduction from Task 5 and +Verify: `npm run test:self` green; the P-11 reproduction above and `test/suite/section-16-p8.test.ts` run without harness errors; `npm run typecheck`. @@ -222,7 +195,7 @@ the file → the full positional tree decoded through `decodeViewReport`, depth asserted by iterative walk. Register the ID in the module's exported array, add `"T1.3-7": ["1.3"]` (plus 11.1/11.4 if the H-7 map convention lists every cited section — follow the neighbouring entries) to `traceability.ts`. -Prerequisite: Task 5 (the `view` decode must not overflow). +Prerequisite: Task 5 — done (the `view` decode no longer overflows). Verify: `npx vitest run --config test/vitest.config.ts --project suite test/suite/section-1.3.test.ts` — passes against the built product or fails as diff --git a/test/helpers/adapters/decode.ts b/test/helpers/adapters/decode.ts index c2af8a8..eff1d8e 100644 --- a/test/helpers/adapters/decode.ts +++ b/test/helpers/adapters/decode.ts @@ -63,8 +63,14 @@ export function describeJsonValue(value: unknown): string { let rendered: string; try { rendered = JSON.stringify(value) ?? String(value); - } catch { - rendered = String(value); + } catch (error) { + // H-11: V8's serializer recurses per nesting level and exhausts its frame + // budget (a RangeError) on the towers the suite stages, 4096 sections deep + // (P-8, P-11); the diagnosis then renders the same text without recursion. + rendered = + error instanceof RangeError + ? renderJsonWithoutRecursion(value) + : String(value); } const LIMIT = 256; if (rendered.length > LIMIT) { @@ -73,6 +79,49 @@ export function describeJsonValue(value: unknown): string { return `${kind} ${rendered}`; } +/** + * `JSON.stringify` for decoded-JSON data (objects, arrays, strings, numbers, + * booleans, `null`) through an explicit stack — the text `JSON.stringify` + * renders, produced without native recursion per nesting level (H-11). + */ +function renderJsonWithoutRecursion(value: unknown): string { + const out: string[] = []; + const work: ({ readonly text: string } | { readonly value: unknown })[] = [ + { value }, + ]; + while (work.length > 0) { + const item = work.pop()!; + if ("text" in item) { + out.push(item.text); + continue; + } + const current = item.value; + if (Array.isArray(current)) { + work.push({ text: "]" }); + for (let index = current.length - 1; index >= 0; index -= 1) { + work.push({ value: current[index] }); + if (index > 0) work.push({ text: "," }); + } + work.push({ text: "[" }); + } else if (typeof current === "object" && current !== null) { + const entries = Object.entries(current).filter( + ([, member]) => member !== undefined, + ); + work.push({ text: "}" }); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const [key, member] = entries[index]!; + work.push({ value: member }); + work.push({ text: `${JSON.stringify(key)}:` }); + if (index > 0) work.push({ text: "," }); + } + work.push({ text: "{" }); + } else { + out.push(JSON.stringify(current) ?? "null"); + } + } + return out.join(""); +} + /** The decoded value must be a JSON object (not null, not an array). */ export function expectObject( value: unknown, diff --git a/test/helpers/adapters/forms.ts b/test/helpers/adapters/forms.ts index d0baa6c..167c7a7 100644 --- a/test/helpers/adapters/forms.ts +++ b/test/helpers/adapters/forms.ts @@ -1619,12 +1619,74 @@ function decodeViewTextMember( * 11.4); `attributes` entries are in tag order and `children` in document * order — both strictly ascending by range start (distinct constructs occupy * distinct spans). + * + * H-11: the tree is walked through an explicit stack, never by native + * recursion per nesting level — the suite stages section towers 2048 and + * 4096 deep (P-8, P-11), past V8's frame budget — and no depth cap of any + * kind. The checks run per node in exactly the order a recursive descent + * runs them: the node's own members first, then each child completely + * (subtree included) in document order, then the children's order and the + * text members. */ function decodeViewNodeForm( value: unknown, site: DecodeSite, text: boolean, ): ViewNode { + const stack: ViewNodeFrame[] = [enterViewNode(value, site, text)]; + for (;;) { + const top = stack[stack.length - 1]!; + if (top.nextChild < top.rawChildren.length) { + const index = top.nextChild; + top.nextChild += 1; + stack.push( + enterViewNode( + top.rawChildren[index], + at(top.childrenSite, index), + text, + ), + ); + continue; + } + const node = leaveViewNode(top, text); + stack.pop(); + const parent = stack[stack.length - 1]; + if (parent === undefined) return node; + parent.children.push(node); + } +} + +/** One node's decode in flight: its own members decoded, children pending. */ +interface ViewNodeFrame { + readonly site: DecodeSite; + readonly obj: Record<string, unknown>; + readonly identity: ViewNode["identity"]; + readonly range: SourceRange; + readonly opening: SourceRange | null; + readonly closing: SourceRange | null; + readonly attributes: ViewAttributeEntry[]; + readonly tags: ViewNode["tags"]; + readonly coverage: ViewNode["coverage"]; + readonly childrenSite: DecodeSite; + readonly rawChildren: readonly unknown[]; + /** The children decoded so far, in document order. */ + readonly children: ViewNode[]; + /** The index of the next raw child to decode. */ + nextChild: number; +} + +/** + * A node's own members, in form order — everything that precedes its + * children's decode: the member allow-list (with or without `--text`), the + * identity datum (never `null`), the range, the opening and closing tag + * ranges, the attribute entries in tag order, the tags and coverage datums, + * and the array form of the children member. + */ +function enterViewNode( + value: unknown, + site: DecodeSite, + text: boolean, +): ViewNodeFrame { const obj = expectObject(value, site); const allowed = text ? [...VIEW_NODE_MEMBERS, ...VIEW_NODE_TEXT_MEMBERS] @@ -1688,12 +1750,48 @@ function decodeViewNodeForm( ); const childrenSite = at(site, "children"); - const children = expectArray( + const rawChildren = expectArray( requiredKey(obj, "children", site), childrenSite, - ).map((element, index) => - decodeViewNodeForm(element, at(childrenSite, index), text), ); + + return { + site, + obj, + identity: + identityDatum.state === "value" + ? identityDatum.value + : { unavailable: true as const }, + range, + opening, + closing, + attributes, + tags: + tagsDatum.state === "value" + ? tagsDatum.value + : tagsDatum.state === "null" + ? null + : { unavailable: true as const }, + coverage: + coverageDatum.state === "value" + ? coverageDatum.value + : coverageDatum.state === "null" + ? null + : { unavailable: true as const }, + childrenSite, + rawChildren, + children: [], + nextChild: 0, + }; +} + +/** + * A node's completion once every child is decoded: the children's document + * order (strictly ascending by start), the node itself, and the text + * members exactly when `--text` is given. + */ +function leaveViewNode(frame: ViewNodeFrame, text: boolean): ViewNode { + const { site, obj, childrenSite, children } = frame; for (let i = 1; i < children.length; i += 1) { if (children[i - 1]!.range.start >= children[i]!.range.start) { formFail( @@ -1717,26 +1815,13 @@ function decodeViewNodeForm( ownText?: ViewNode["ownText"]; subtreeText?: ViewNode["subtreeText"]; } = { - identity: - identityDatum.state === "value" - ? identityDatum.value - : { unavailable: true as const }, - range, - opening, - closing, - attributes, - tags: - tagsDatum.state === "value" - ? tagsDatum.value - : tagsDatum.state === "null" - ? null - : { unavailable: true as const }, - coverage: - coverageDatum.state === "value" - ? coverageDatum.value - : coverageDatum.state === "null" - ? null - : { unavailable: true as const }, + identity: frame.identity, + range: frame.range, + opening: frame.opening, + closing: frame.closing, + attributes: frame.attributes, + tags: frame.tags, + coverage: frame.coverage, children, }; if (text) { @@ -2158,14 +2243,24 @@ export function assertUnavailabilityMarkerForms( doc: unknown, context?: string, ): void { - const walk = (value: unknown, site: DecodeSite): void => { + // H-11: an explicit stack, never native recursion per nesting level — the + // documents this walk covers include `view` towers 4096 sections deep + // (P-8, P-11), past V8's frame budget; no depth cap of any kind. The visit + // order is a recursive descent's: each value before its members, array + // elements by index and object members in property order, each subtree + // completely before the next sibling. + const pending: { readonly value: unknown; readonly site: DecodeSite }[] = [ + { value: doc, site: rootSite("12.7 unavailability-marker walk", context) }, + ]; + while (pending.length > 0) { + const { value, site } = pending.pop()!; if (Array.isArray(value)) { - value.forEach((element, index) => { - walk(element, at(site, index)); - }); - return; + for (let index = value.length - 1; index >= 0; index -= 1) { + pending.push({ value: value[index], site: at(site, index) }); + } + continue; } - if (typeof value !== "object" || value === null) return; + if (typeof value !== "object" || value === null) continue; const obj = value as Record<string, unknown>; if ( Object.hasOwn(obj, "unavailable") && @@ -2179,9 +2274,10 @@ export function assertUnavailabilityMarkerForms( value, ); } - for (const [key, member] of Object.entries(obj)) { - walk(member, at(site, key)); + const entries = Object.entries(obj); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const [key, member] = entries[index]!; + pending.push({ value: member, site: at(site, key) }); } - }; - walk(doc, rootSite("12.7 unavailability-marker walk", context)); + } } diff --git a/test/suite/registry/section-16-p11.ts b/test/suite/registry/section-16-p11.ts index fb8c571..ac51774 100644 --- a/test/suite/registry/section-16-p11.ts +++ b/test/suite/registry/section-16-p11.ts @@ -452,15 +452,27 @@ async function runAvailabilityCommand( * (SPEC 12.7) — presence of the member is presence of the marker. */ function documentCarriesUnavailability(value: unknown): boolean { - if (Array.isArray(value)) { - return value.some((element) => documentCarriesUnavailability(element)); + // H-11: an explicit stack, never native recursion per nesting level — the + // fuzzed `view` answers carry the depth-2048 and depth-4096 section towers + // the suite stages (P-8, P-11), past V8's frame budget; no depth cap. + const pending: unknown[] = [value]; + while (pending.length > 0) { + const current = pending.pop(); + if (Array.isArray(current)) { + for (let index = current.length - 1; index >= 0; index -= 1) { + pending.push(current[index]); + } + continue; + } + if (typeof current !== "object" || current === null) continue; + const obj = current as Record<string, unknown>; + if (Object.hasOwn(obj, "unavailable")) return true; + const members = Object.values(obj); + for (let index = members.length - 1; index >= 0; index -= 1) { + pending.push(members[index]); + } } - if (typeof value !== "object" || value === null) return false; - const obj = value as Record<string, unknown>; - if (Object.hasOwn(obj, "unavailable")) return true; - return Object.values(obj).some((member) => - documentCarriesUnavailability(member), - ); + return false; } /** Decode an answer through its surface's form-exact 12.7 decoder (H-3). */ From f2cb541b4f8bac2a33a66b59cedca4b1f447e31f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 05:12:57 +0000 Subject: [PATCH 189/226] =?UTF-8?q?sdg(phase-9):=20finish=20de-recursing?= =?UTF-8?q?=20the=20H-3=20decoders=20=E2=80=94=20walkForRangeData=20and=20?= =?UTF-8?q?decodeIdsTreeNode=20on=20explicit=20stacks;=20H-11=20audit=20re?= =?UTF-8?q?corded,=20Task=206=20trimmed=20to=20the=20registry=20JSON=20wal?= =?UTF-8?q?kers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 6 (H-11 audit): a call-graph survey over test/helpers/ and test/suite/registry/ found 61 recursive sites. The two remaining recursive H-3 decoders in test/helpers/adapters/query.ts are converted: walkForRangeData (1.7 bare edge-endpoint walk) to a pre-order explicit stack with children pushed last-first, so every datum is checked in the order the recursive descent checked it and the first failure is the same; decodeIdsTreeNode (ids --tree, one node per section level) to the frame stack shape of decodeViewNodeForm. Verified: a temporary self-test drove both on 200k-deep documents (removed before commit); S-5 green; section-12.3-12.5 and section-1.6-1.7 pass against the built product; the P-11 reproduction seed and P-8 run without harness errors; npm run test:self 331/331; typecheck clean. Every walk a deep staged input reaches is now explicit-stack. Task 6 is replaced by its remainder — the generic JSON walkers over review documents in the section-10.x/12.0-i registry modules (canonicalJson, collectStringLeaves, canonicalizeJson) — with the audit's other classes recorded in the task: generator- and fixture-bounded model/oracle walks (left as they are; draw order is tape-significant, H-10) and directory/path/glob/process-tree recursion. AGENTS.md notes the query.ts walks and the by-hand depth drive. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 2 +- specs/tmp/FIX_PLAN.md | 98 +++++++++++++++++-------- test/helpers/adapters/query.ts | 130 ++++++++++++++++++++++++--------- 3 files changed, 163 insertions(+), 67 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b78e0e7..9a39fe5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,4 +19,4 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Property tests (TEST-SPEC 16; machinery in `test/helpers/property.ts`) run a fixed seed set by default — the CI mode, fully deterministic. To rerun with a specific seed: `XSPEC_PROPERTY_SEED=<uint32 from the failure message>`. Optional randomized local mode: `XSPEC_PROPERTY_SEED=random` (each property reports its seed for replay). Never set the variable in CI. Vitest intercepts the seed reports (`console.info`); add `--disable-console-intercept` to the vitest invocation to see them — but place any test-file filter argument *before* that flag (a file path following `--disable-console-intercept` is not treated as a filter and the whole project runs). - Format code (Prettier, default config, `src/` and `test/` only): `npm run format`; verify: `npm run format:check`. - CI: `.github/workflows/ci.yml` — harness-self (Linux), full suite (Linux, network disabled after setup via `.github/scripts/run-without-network.sh`), and the Windows E-6 leg on every pull request. The `e6-linux-outputs` upload step of the full-suite job has no `if: always()`, so it is skipped whenever any Linux test fails (the run ends with no artifact): the Windows byte-identity test then fails loudly on the missing manifest until suite-linux is green — that arm's verdict is meaningful only on a green Linux run. -- Answer-document scale (TEST-SPEC H-11): the suite stages section towers 4096 deep (`NESTING_DEPTHS` in `test/suite/registry/section-16-p8.ts`), past V8's limits in this Node — a plain recursive function gets ~9.9k frames, and `JSON.stringify`/`structuredClone` throw `RangeError` at that nesting while `JSON.parse` is iterative. Harness walks over answer documents therefore use explicit stacks (`decodeViewNodeForm`, `assertUnavailabilityMarkerForms`, `describeJsonValue`'s fallback in `test/helpers/adapters/`); a property reporting `RangeError: Maximum call stack size exceeded` as a harness error names a walk still recursing per level — its `Caused by` frames locate it. +- Answer-document scale (TEST-SPEC H-11): the suite stages section towers 4096 deep (`NESTING_DEPTHS` in `test/suite/registry/section-16-p8.ts`), past V8's limits in this Node — a plain recursive function gets ~9.9k frames, and `JSON.stringify`/`structuredClone` throw `RangeError` at that nesting while `JSON.parse` is iterative. Harness walks over answer documents therefore use explicit stacks (`decodeViewNodeForm`, `assertUnavailabilityMarkerForms`, `describeJsonValue`'s fallback, and `query.ts`'s `walkForRangeData` and `decodeIdsTreeNode` — `ids --tree` nests one node per section level — in `test/helpers/adapters/`); a property reporting `RangeError: Maximum call stack size exceeded` as a harness error names a walk still recursing per level — its `Caused by` frames locate it. To drive a decoder at depth by hand, a temporary self-test (see above) building the document in a loop is enough: 200k-deep `ids --tree` and edge documents decode in well under a second. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 1233b9a..4c32bb6 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,36 +65,74 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage B — H-11 answer-scale capacity (P-11 harness error), S-8, S-2, T1.3-7 -### Task 6 — De-recurse every other per-datum walk over answer documents (H-11 audit) - -Cites: TEST-SPEC §0 H-11 (every subsequent per-datum traversal, "16's property -walks included"); §16 P-8, P-11; §17 S-8. - -Now: Task 5 (done; `git log` — "de-recurse the view decode path") converted the -walks on the `view` decode path — `decodeViewNodeForm` and -`assertUnavailabilityMarkerForms` in `test/helpers/adapters/forms.ts`, -`documentCarriesUnavailability` in `test/suite/registry/section-16-p11.ts` — -to explicit stacks, and gave `describeJsonValue` (`test/helpers/adapters/ -decode.ts`) a non-recursive fallback for the `RangeError` V8's `JSON.stringify` -throws at tower depth 4096 (`structuredClone` throws the same; `JSON.parse` is -iterative; this Node's plain frame budget is ~9.9k frames). The P-11 -reproduction (`npm run build && XSPEC_PROPERTY_SEED=271828183 npx vitest run ---config test/vitest.config.ts --project suite test/suite/section-16-p11.test.ts`) -and `test/suite/section-16-p8.test.ts` run without harness errors. Others may -still recurse per level: Markdown/oracle walks over nested sections, `query -subtree` row handling, the E-6 comparators, fixture generators that emit nested -towers recursively, `test/helpers/property.ts` shrinkers. - -Do: grep `test/helpers/` and `test/suite/registry/` for functions that call -themselves (or mutually) per nesting level of an answer document or of a staged -tree, and convert each to an explicit stack/queue with identical semantics and -ordering. Dimension: depth 4096 (the largest staged nesting) must pass with -margin; do not introduce depth caps. Leave a one-line comment at each converted -site citing H-11. - -Verify: `npm run test:self` green; the P-11 reproduction above and -`test/suite/section-16-p8.test.ts` run without harness errors; `npm run -typecheck`. +### Task 6 — Convert the remaining answer-document JSON walkers in the registry modules (H-11 audit remainder) + +Cites: TEST-SPEC §0 H-11 (every subsequent per-datum traversal of an answer +document, "16's property walks included"); §16 P-8, P-11; §17 S-8. + +Now: the H-11 audit is complete (`git log` — "finish de-recursing the H-3 +decoders"): a per-file call-graph survey over `test/helpers/` and +`test/suite/registry/` (TypeScript compiler API; self- and mutual recursion +among named functions, arrows, and methods; no walker calls itself through an +object-literal method) found 61 recursive sites. Every walk a deep staged input +reaches is now explicit-stack: P-8's 4096-deep towers go through +`assertJsonOutputConvention`/`parseJsonStdout` only (plain iterative +`JSON.parse`), P-11's `view` documents through `decodeViewNodeForm`, +`assertUnavailabilityMarkerForms`, `documentCarriesUnavailability` and +`describeJsonValue`'s fallback (Task 5), and `walkForRangeData` and +`decodeIdsTreeNode` in `test/helpers/adapters/query.ts` — the last two +recursive H-3 decoders (`ids --tree` nests one node per section level) — are +converted, so T1.3-7's `query subtree`/`view` (Task 9) and S-8's synthetic +documents (Task 7) meet no recursive decoder. `sorted-keys.ts` is a +hand-written iterative scanner; `property.ts` (shrinkers), the Markdown +oracle, `e6.ts`, and the `query subtree` row decoder hold no per-level +recursion. + +What remains recursive, by class: +(a) Generic JSON walkers over answer documents in registry modules — the sole +remaining conversion work: `canonicalJson` (section-10.2-10.3.ts ≈252, +section-10.6.ts ≈299, section-10.7-i.ts ≈398, section-10.7-ii.ts ≈462), +`collectStringLeaves` (section-10.2-10.3.ts ≈285, section-10.4.ts ≈290, +section-10.6.ts ≈332, section-10.7-i.ts ≈431, section-10.7-ii.ts ≈495), and +`canonicalizeJson` (section-12.0-i.ts ≈407). They recurse per JSON nesting +level of the review-record/session documents those tests decode — nested by +member structure, from the tests' own small fixtures — so no staged input +reaches V8's budget through them today; convert them anyway, so that no +answer-document walk in the suite recurses per level. +(b) Staged-model and oracle walks whose depth is set by their inputs' +construction, never by an answer: the section-16 generators bound section +nesting at `depth < 2` (p2-p3 `genBlocks`/`genBlock`/`genBlockSection`, p4 +`genSection`/`renderItems`/`walkItems`/`collectDotted`/`walkSection`/ +`walkBody`, p9 `genSection`/`renderSectionLines`/`walk`/`visit`, p12 +`emitSection`), at three fixed levels (p13 `visit`/`renderSectionLines`), or +by a dotted id's segment count (p5-p6 `subtreeDotteds`/`walkDotteds`/ +`collectInside`/`nonempty`/`walk`); the coverage oracle's `visit` +(coverage.ts ≈275), graph-diff's `resolve` (graph-diff.ts ≈137) and the +section-move oracle's ten walkers (section-move.ts ≈253–1183) are reached only +from P-5/P-6, P-13 and shallow deterministic fixtures. Leave these as they are: +a generator's draw order is tape-significant (H-10 replay and shrinking), and +their bound is the generator's own, not a depth cap on any answer. Revisit only +if a test raises a staged model's depth toward the H-11 scale through them. +(c) Not document walks: directory/path/glob/process-tree recursion +(`snapshot.ts walk`, `workspace.ts path`/`symlink`/`makeTreeWritable`, +`record-staging.ts collectGraphDataFiles`, `glob.ts matchAt`/`matchFrom`, +`subprocess.ts kill`, `section-7-basics.ts listFiles`) — bounded by fixture +layout. + +Do: convert each site in (a) to an explicit stack with identical output — +`canonicalJson` renders the same string (keys sorted the same way, `undefined` +members dropped where the copy drops them, `JSON.stringify` for keys and +leaves); `collectStringLeaves` yields the same leaves in the same order +(depth-first: array elements in index order, members in enumeration order); +`canonicalizeJson` builds the same key-sorted structure. Keep each module's +copy in place (the modules are independent by design). Leave a one-line +comment at each converted site citing H-11. Do not introduce depth caps. + +Verify: `npm run typecheck`; `npm run test:self` green; each changed module's +wrapper against the built product — `npx vitest run --config +test/vitest.config.ts --project suite test/suite/section-10.2-10.3.test.ts` +(likewise 10.4, 10.6, 10.7-i, 10.7-ii, 12.0-i) — passes or fails only as +diagnosed product failures, never a harness error. ### Task 7 — S-8 answer-scale capacity self-test (decoders, walks, capture gate) diff --git a/test/helpers/adapters/query.ts b/test/helpers/adapters/query.ts index 58bd5d7..03aa501 100644 --- a/test/helpers/adapters/query.ts +++ b/test/helpers/adapters/query.ts @@ -423,39 +423,54 @@ export function assertNodeEdgeListsBare(doc: unknown, context?: string): void { walkForRangeData(requiredKey(obj, "edges", site), at(site, "edges")); } -function walkForRangeData(value: unknown, site: DecodeSite): void { - if (Array.isArray(value)) { - value.forEach((element, index) => { - walkForRangeData(element, at(site, index)); - }); - return; - } - if (typeof value !== "object" || value === null) return; - const obj = value as Record<string, unknown>; - for (const name of ["range", "sourceRange"]) { - if (Object.hasOwn(obj, name)) { +function walkForRangeData(root: unknown, rootSite: DecodeSite): void { + // H-11: an explicit stack, never native recursion per nesting level — the + // suite stages documents past V8's frame budget (P-8, P-11), and no depth + // cap of any kind. Children are pushed last-first so each datum is checked + // in exactly the order a recursive descent checks it: a value's own members + // first, then each element or member completely (subtree included), in + // index and then property-enumeration order — the first failure reported + // is the same one. + const stack: { readonly value: unknown; readonly site: DecodeSite }[] = [ + { value: root, site: rootSite }, + ]; + while (stack.length > 0) { + const { value, site } = stack.pop()!; + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index -= 1) { + stack.push({ value: value[index], site: at(site, index) }); + } + continue; + } + if (typeof value !== "object" || value === null) continue; + const obj = value as Record<string, unknown>; + for (const name of ["range", "sourceRange"]) { + if (Object.hasOwn(obj, name)) { + decodeFail( + at(site, name), + "no range datum on an edge surface — everywhere a graph node " + + "appears as an edge endpoint it is a bare identity, requirement " + + "node and code location alike; a code location's source range is " + + "presented in exactly two outputs, occurrence records and review " + + "payloads (SPEC 1.7)", + obj[name], + ); + } + } + if (Object.hasOwn(obj, "start") && Object.hasOwn(obj, "end")) { decodeFail( - at(site, name), - "no range datum on an edge surface — everywhere a graph node " + - "appears as an edge endpoint it is a bare identity, requirement " + - "node and code location alike; a code location's source range is " + - "presented in exactly two outputs, occurrence records and review " + - "payloads (SPEC 1.7)", - obj[name], + site, + 'no range-shaped {"start", "end"} datum on an edge surface — edge ' + + "endpoints are bare identities with no range datum accompanying " + + "them (SPEC 1.7)", + value, ); } - } - if (Object.hasOwn(obj, "start") && Object.hasOwn(obj, "end")) { - decodeFail( - site, - 'no range-shaped {"start", "end"} datum on an edge surface — edge ' + - "endpoints are bare identities with no range datum accompanying " + - "them (SPEC 1.7)", - value, - ); - } - for (const [key, member] of Object.entries(obj)) { - walkForRangeData(member, at(site, key)); + const entries = Object.entries(obj); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const [key, member] = entries[index]!; + stack.push({ value: member, site: at(site, key) }); + } } } @@ -484,15 +499,58 @@ export function decodeIdsReport(doc: unknown, context?: string): IdsReport { return { files }; } +/** + * One `ids --tree` node per section nesting level, decoded through an + * explicit stack. + * + * H-11: never native recursion per nesting level — the suite stages section + * towers 2048 and 4096 deep (P-8, P-11, T1.3-7), past V8's frame budget — + * and no depth cap of any kind. The checks run per node in exactly the order + * a recursive descent runs them: the node's own members first (`id`, then + * the array form of `children`), then each child completely (subtree + * included) in document order. + */ function decodeIdsTreeNode(value: unknown, site: DecodeSite): IdsTreeNode { + const stack: IdsTreeFrame[] = [enterIdsTreeNode(value, site)]; + for (;;) { + const top = stack[stack.length - 1]!; + if (top.nextChild < top.rawChildren.length) { + const index = top.nextChild; + top.nextChild += 1; + stack.push( + enterIdsTreeNode(top.rawChildren[index], at(top.childrenSite, index)), + ); + continue; + } + const node: IdsTreeNode = { id: top.id, children: top.children }; + stack.pop(); + const parent = stack[stack.length - 1]; + if (parent === undefined) return node; + parent.children.push(node); + } +} + +/** One node's decode in flight: its own members decoded, children pending. */ +interface IdsTreeFrame { + readonly id: string; + readonly childrenSite: DecodeSite; + readonly rawChildren: readonly unknown[]; + /** The children decoded so far, in document order. */ + readonly children: IdsTreeNode[]; + /** The index of the next raw child to decode. */ + nextChild: number; +} + +/** A node's own members, in form order — everything before its children. */ +function enterIdsTreeNode(value: unknown, site: DecodeSite): IdsTreeFrame { const obj = expectObject(value, site); const childrenSite = at(site, "children"); - return { - id: expectNonEmptyString(requiredKey(obj, "id", site), at(site, "id")), - children: expectArray(requiredKey(obj, "children", site), childrenSite).map( - (element, index) => decodeIdsTreeNode(element, at(childrenSite, index)), - ), - }; + const id = expectNonEmptyString(requiredKey(obj, "id", site), at(site, "id")); + const rawChildren = expectArray( + requiredKey(obj, "children", site), + childrenSite, + ); + return { id, childrenSite, rawChildren, children: [], nextChild: 0 }; } /** `ids --tree` (T12.3-1): per-file nesting in file and document order. */ From 7199b503e0a795d9d5171b5ce77d75fb006866c1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 05:23:56 +0000 Subject: [PATCH 190/226] =?UTF-8?q?sdg(phase-9):=20de-recurse=20the=20regi?= =?UTF-8?q?stry=20modules'=20JSON=20walkers=20=E2=80=94=20canonicalJson,?= =?UTF-8?q?=20collectStringLeaves=20and=20canonicalizeJson=20on=20explicit?= =?UTF-8?q?=20stacks=20(H-11=20audit=20remainder)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - section-10.2-10.3.ts, section-10.6.ts, section-10.7-i.ts, section-10.7-ii.ts: canonicalJson renders through an explicit stack of value/text items — the same key sort, undefined members dropped, JSON.stringify for keys and leaves; identical string. - section-10.2-10.3.ts, section-10.4.ts, section-10.6.ts, section-10.7-i.ts, section-10.7-ii.ts: collectStringLeaves walks an explicit stack with children pushed last-first — the same depth-first leaf order. - section-12.0-i.ts: canonicalizeJson attaches empty container copies in sorted key order and fills them from a pending stack — the same key-sorted structure. - Each module keeps its own copy; one H-11 comment per site; no depth caps. - Verified: old-vs-new twins identical on 20,000 random documents, and the new walks handle a 200k-level document where the recursive form throws RangeError. npm run typecheck clean; the six changed modules' wrappers 32/32 against the built product; npm run test:self 331/331. - FIX_PLAN.md: Task 6 removed (41 tasks remain); Task 41's pointer to Task 6 reworded. AGENTS.md: the H-11 note lists the registry walkers and the scratch-twin technique for an unexported walker. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 2 +- specs/tmp/FIX_PLAN.md | 71 +----------------------- test/suite/registry/section-10.2-10.3.ts | 70 ++++++++++++++++------- test/suite/registry/section-10.4.ts | 22 +++++--- test/suite/registry/section-10.6.ts | 70 ++++++++++++++++------- test/suite/registry/section-10.7-i.ts | 70 ++++++++++++++++------- test/suite/registry/section-10.7-ii.ts | 70 ++++++++++++++++------- test/suite/registry/section-12.0-i.ts | 43 ++++++++++---- 8 files changed, 254 insertions(+), 164 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a39fe5..68802e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,4 +19,4 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Property tests (TEST-SPEC 16; machinery in `test/helpers/property.ts`) run a fixed seed set by default — the CI mode, fully deterministic. To rerun with a specific seed: `XSPEC_PROPERTY_SEED=<uint32 from the failure message>`. Optional randomized local mode: `XSPEC_PROPERTY_SEED=random` (each property reports its seed for replay). Never set the variable in CI. Vitest intercepts the seed reports (`console.info`); add `--disable-console-intercept` to the vitest invocation to see them — but place any test-file filter argument *before* that flag (a file path following `--disable-console-intercept` is not treated as a filter and the whole project runs). - Format code (Prettier, default config, `src/` and `test/` only): `npm run format`; verify: `npm run format:check`. - CI: `.github/workflows/ci.yml` — harness-self (Linux), full suite (Linux, network disabled after setup via `.github/scripts/run-without-network.sh`), and the Windows E-6 leg on every pull request. The `e6-linux-outputs` upload step of the full-suite job has no `if: always()`, so it is skipped whenever any Linux test fails (the run ends with no artifact): the Windows byte-identity test then fails loudly on the missing manifest until suite-linux is green — that arm's verdict is meaningful only on a green Linux run. -- Answer-document scale (TEST-SPEC H-11): the suite stages section towers 4096 deep (`NESTING_DEPTHS` in `test/suite/registry/section-16-p8.ts`), past V8's limits in this Node — a plain recursive function gets ~9.9k frames, and `JSON.stringify`/`structuredClone` throw `RangeError` at that nesting while `JSON.parse` is iterative. Harness walks over answer documents therefore use explicit stacks (`decodeViewNodeForm`, `assertUnavailabilityMarkerForms`, `describeJsonValue`'s fallback, and `query.ts`'s `walkForRangeData` and `decodeIdsTreeNode` — `ids --tree` nests one node per section level — in `test/helpers/adapters/`); a property reporting `RangeError: Maximum call stack size exceeded` as a harness error names a walk still recursing per level — its `Caused by` frames locate it. To drive a decoder at depth by hand, a temporary self-test (see above) building the document in a loop is enough: 200k-deep `ids --tree` and edge documents decode in well under a second. +- Answer-document scale (TEST-SPEC H-11): the suite stages section towers 4096 deep (`NESTING_DEPTHS` in `test/suite/registry/section-16-p8.ts`), past V8's limits in this Node — a plain recursive function gets ~9.9k frames, and `JSON.stringify`/`structuredClone` throw `RangeError` at that nesting while `JSON.parse` is iterative. Harness walks over answer documents therefore use explicit stacks (`decodeViewNodeForm`, `assertUnavailabilityMarkerForms`, `describeJsonValue`'s fallback, and `query.ts`'s `walkForRangeData` and `decodeIdsTreeNode` — `ids --tree` nests one node per section level — in `test/helpers/adapters/`, and the registry modules' own generic JSON walkers, `canonicalJson`, `collectStringLeaves` and `canonicalizeJson` in `test/suite/registry/section-10.*.ts` and `section-12.0-i.ts`); a property reporting `RangeError: Maximum call stack size exceeded` as a harness error names a walk still recursing per level — its `Caused by` frames locate it. To drive a decoder at depth by hand, a temporary self-test (see above) building the document in a loop is enough: 200k-deep `ids --tree` and edge documents decode in well under a second. A walker its module does not export can be exercised the same way as a scratch `.ts` twin of it run under `node --experimental-strip-types` (Node 22). diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 4c32bb6..fee6e0f 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,75 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage B — H-11 answer-scale capacity (P-11 harness error), S-8, S-2, T1.3-7 -### Task 6 — Convert the remaining answer-document JSON walkers in the registry modules (H-11 audit remainder) - -Cites: TEST-SPEC §0 H-11 (every subsequent per-datum traversal of an answer -document, "16's property walks included"); §16 P-8, P-11; §17 S-8. - -Now: the H-11 audit is complete (`git log` — "finish de-recursing the H-3 -decoders"): a per-file call-graph survey over `test/helpers/` and -`test/suite/registry/` (TypeScript compiler API; self- and mutual recursion -among named functions, arrows, and methods; no walker calls itself through an -object-literal method) found 61 recursive sites. Every walk a deep staged input -reaches is now explicit-stack: P-8's 4096-deep towers go through -`assertJsonOutputConvention`/`parseJsonStdout` only (plain iterative -`JSON.parse`), P-11's `view` documents through `decodeViewNodeForm`, -`assertUnavailabilityMarkerForms`, `documentCarriesUnavailability` and -`describeJsonValue`'s fallback (Task 5), and `walkForRangeData` and -`decodeIdsTreeNode` in `test/helpers/adapters/query.ts` — the last two -recursive H-3 decoders (`ids --tree` nests one node per section level) — are -converted, so T1.3-7's `query subtree`/`view` (Task 9) and S-8's synthetic -documents (Task 7) meet no recursive decoder. `sorted-keys.ts` is a -hand-written iterative scanner; `property.ts` (shrinkers), the Markdown -oracle, `e6.ts`, and the `query subtree` row decoder hold no per-level -recursion. - -What remains recursive, by class: -(a) Generic JSON walkers over answer documents in registry modules — the sole -remaining conversion work: `canonicalJson` (section-10.2-10.3.ts ≈252, -section-10.6.ts ≈299, section-10.7-i.ts ≈398, section-10.7-ii.ts ≈462), -`collectStringLeaves` (section-10.2-10.3.ts ≈285, section-10.4.ts ≈290, -section-10.6.ts ≈332, section-10.7-i.ts ≈431, section-10.7-ii.ts ≈495), and -`canonicalizeJson` (section-12.0-i.ts ≈407). They recurse per JSON nesting -level of the review-record/session documents those tests decode — nested by -member structure, from the tests' own small fixtures — so no staged input -reaches V8's budget through them today; convert them anyway, so that no -answer-document walk in the suite recurses per level. -(b) Staged-model and oracle walks whose depth is set by their inputs' -construction, never by an answer: the section-16 generators bound section -nesting at `depth < 2` (p2-p3 `genBlocks`/`genBlock`/`genBlockSection`, p4 -`genSection`/`renderItems`/`walkItems`/`collectDotted`/`walkSection`/ -`walkBody`, p9 `genSection`/`renderSectionLines`/`walk`/`visit`, p12 -`emitSection`), at three fixed levels (p13 `visit`/`renderSectionLines`), or -by a dotted id's segment count (p5-p6 `subtreeDotteds`/`walkDotteds`/ -`collectInside`/`nonempty`/`walk`); the coverage oracle's `visit` -(coverage.ts ≈275), graph-diff's `resolve` (graph-diff.ts ≈137) and the -section-move oracle's ten walkers (section-move.ts ≈253–1183) are reached only -from P-5/P-6, P-13 and shallow deterministic fixtures. Leave these as they are: -a generator's draw order is tape-significant (H-10 replay and shrinking), and -their bound is the generator's own, not a depth cap on any answer. Revisit only -if a test raises a staged model's depth toward the H-11 scale through them. -(c) Not document walks: directory/path/glob/process-tree recursion -(`snapshot.ts walk`, `workspace.ts path`/`symlink`/`makeTreeWritable`, -`record-staging.ts collectGraphDataFiles`, `glob.ts matchAt`/`matchFrom`, -`subprocess.ts kill`, `section-7-basics.ts listFiles`) — bounded by fixture -layout. - -Do: convert each site in (a) to an explicit stack with identical output — -`canonicalJson` renders the same string (keys sorted the same way, `undefined` -members dropped where the copy drops them, `JSON.stringify` for keys and -leaves); `collectStringLeaves` yields the same leaves in the same order -(depth-first: array elements in index order, members in enumeration order); -`canonicalizeJson` builds the same key-sorted structure. Keep each module's -copy in place (the modules are independent by design). Leave a one-line -comment at each converted site citing H-11. Do not introduce depth caps. - -Verify: `npm run typecheck`; `npm run test:self` green; each changed module's -wrapper against the built product — `npx vitest run --config -test/vitest.config.ts --project suite test/suite/section-10.2-10.3.test.ts` -(likewise 10.4, 10.6, 10.7-i, 10.7-ii, 12.0-i) — passes or fails only as -diagnosed product failures, never a harness error. - ### Task 7 — S-8 answer-scale capacity self-test (decoders, walks, capture gate) Cites: TEST-SPEC §17 S-8 ("The H-3/12.7 decoders and every answer-document @@ -954,7 +885,7 @@ never run it. T12.7-1's run body (`section-12.7.ts` ≈ 2236–2254) calls only located-findings, policy, cross-module, review-refusal and byte-paths arms; `decodeSourceRange` (query.ts ≈ 64) is already form-exact. -Do: (a) invoke the walk (its iterative form, Task 6) on every captured JSON +Do: (a) invoke the walk (its iterative form — every answer-document walk is explicit-stack now; see `git log`) on every captured JSON document at the single decode entry of each adapter (query, review, model, and any other adapter that parses product JSON), so an `unavailable` marker in a non-exclusive position fails loudly everywhere; (b) add the listed diff --git a/test/suite/registry/section-10.2-10.3.ts b/test/suite/registry/section-10.2-10.3.ts index 27abcfa..23a68c9 100644 --- a/test/suite/registry/section-10.2-10.3.ts +++ b/test/suite/registry/section-10.2-10.3.ts @@ -250,19 +250,43 @@ function requireItem( * is shape territory (H-3/H-4). */ function canonicalJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map((element) => canonicalJson(element)).join(",")}]`; - } - if (value !== null && typeof value === "object") { - const entries = Object.entries(value as Record<string, unknown>) - .filter(([, member]) => member !== undefined) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map( - ([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`, - ); - return `{${entries.join(",")}}`; + // H-11: an explicit stack, never native recursion per nesting level. + type Item = { readonly render: unknown } | { readonly text: string }; + const pieces: string[] = []; + const stack: Item[] = [{ render: value }]; + while (stack.length > 0) { + const item = stack.pop(); + if (item === undefined) break; + if ("text" in item) { + pieces.push(item.text); + continue; + } + const current = item.render; + if (Array.isArray(current)) { + pieces.push("["); + stack.push({ text: "]" }); + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push({ render: current[index] }); + if (index > 0) stack.push({ text: "," }); + } + } else if (current !== null && typeof current === "object") { + const entries = Object.entries(current as Record<string, unknown>) + .filter(([, member]) => member !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + pieces.push("{"); + stack.push({ text: "}" }); + let remaining = entries.length; + for (const [key, member] of entries.reverse()) { + stack.push({ render: member }); + stack.push({ text: `${JSON.stringify(key)}:` }); + remaining -= 1; + if (remaining > 0) stack.push({ text: "," }); + } + } else { + pieces.push(JSON.stringify(current) ?? "null"); + } } - return JSON.stringify(value) ?? "null"; + return pieces.join(""); } /** Diagnosed canonical-JSON (key-order-insensitive) deep equality. */ @@ -283,13 +307,21 @@ function assertSameInformation( /** Every string leaf of a decoded JSON value (array elements and members). */ function collectStringLeaves(value: unknown, into: string[] = []): string[] { - if (typeof value === "string") { - into.push(value); - } else if (Array.isArray(value)) { - for (const element of value) collectStringLeaves(element, into); - } else if (value !== null && typeof value === "object") { - for (const member of Object.values(value)) { - collectStringLeaves(member, into); + // H-11: an explicit stack, never native recursion per nesting level. + const stack: unknown[] = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current === "string") { + into.push(current); + } else if (Array.isArray(current)) { + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push(current[index]); + } + } else if (current !== null && typeof current === "object") { + const members = Object.values(current); + for (let index = members.length - 1; index >= 0; index -= 1) { + stack.push(members[index]); + } } } return into; diff --git a/test/suite/registry/section-10.4.ts b/test/suite/registry/section-10.4.ts index f4e684d..fbf24b9 100644 --- a/test/suite/registry/section-10.4.ts +++ b/test/suite/registry/section-10.4.ts @@ -288,13 +288,21 @@ async function expectItemStatus( /** Every string leaf of a decoded JSON value (array elements and members). */ function collectStringLeaves(value: unknown, into: string[] = []): string[] { - if (typeof value === "string") { - into.push(value); - } else if (Array.isArray(value)) { - for (const element of value) collectStringLeaves(element, into); - } else if (value !== null && typeof value === "object") { - for (const member of Object.values(value)) { - collectStringLeaves(member, into); + // H-11: an explicit stack, never native recursion per nesting level. + const stack: unknown[] = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current === "string") { + into.push(current); + } else if (Array.isArray(current)) { + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push(current[index]); + } + } else if (current !== null && typeof current === "object") { + const members = Object.values(current); + for (let index = members.length - 1; index >= 0; index -= 1) { + stack.push(members[index]); + } } } return into; diff --git a/test/suite/registry/section-10.6.ts b/test/suite/registry/section-10.6.ts index 102cd44..8686de4 100644 --- a/test/suite/registry/section-10.6.ts +++ b/test/suite/registry/section-10.6.ts @@ -297,19 +297,43 @@ function assertBlockedBy( * is shape territory (H-3/H-4). */ function canonicalJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map((element) => canonicalJson(element)).join(",")}]`; - } - if (value !== null && typeof value === "object") { - const entries = Object.entries(value as Record<string, unknown>) - .filter(([, member]) => member !== undefined) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map( - ([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`, - ); - return `{${entries.join(",")}}`; + // H-11: an explicit stack, never native recursion per nesting level. + type Item = { readonly render: unknown } | { readonly text: string }; + const pieces: string[] = []; + const stack: Item[] = [{ render: value }]; + while (stack.length > 0) { + const item = stack.pop(); + if (item === undefined) break; + if ("text" in item) { + pieces.push(item.text); + continue; + } + const current = item.render; + if (Array.isArray(current)) { + pieces.push("["); + stack.push({ text: "]" }); + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push({ render: current[index] }); + if (index > 0) stack.push({ text: "," }); + } + } else if (current !== null && typeof current === "object") { + const entries = Object.entries(current as Record<string, unknown>) + .filter(([, member]) => member !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + pieces.push("{"); + stack.push({ text: "}" }); + let remaining = entries.length; + for (const [key, member] of entries.reverse()) { + stack.push({ render: member }); + stack.push({ text: `${JSON.stringify(key)}:` }); + remaining -= 1; + if (remaining > 0) stack.push({ text: "," }); + } + } else { + pieces.push(JSON.stringify(current) ?? "null"); + } } - return JSON.stringify(value) ?? "null"; + return pieces.join(""); } /** Diagnosed canonical-JSON (key-order-insensitive) deep equality. */ @@ -330,13 +354,21 @@ function assertSameInformation( /** Every string leaf of a decoded JSON value (array elements and members). */ function collectStringLeaves(value: unknown, into: string[] = []): string[] { - if (typeof value === "string") { - into.push(value); - } else if (Array.isArray(value)) { - for (const element of value) collectStringLeaves(element, into); - } else if (value !== null && typeof value === "object") { - for (const member of Object.values(value)) { - collectStringLeaves(member, into); + // H-11: an explicit stack, never native recursion per nesting level. + const stack: unknown[] = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current === "string") { + into.push(current); + } else if (Array.isArray(current)) { + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push(current[index]); + } + } else if (current !== null && typeof current === "object") { + const members = Object.values(current); + for (let index = members.length - 1; index >= 0; index -= 1) { + stack.push(members[index]); + } } } return into; diff --git a/test/suite/registry/section-10.7-i.ts b/test/suite/registry/section-10.7-i.ts index 699d4cb..6c043c9 100644 --- a/test/suite/registry/section-10.7-i.ts +++ b/test/suite/registry/section-10.7-i.ts @@ -396,19 +396,43 @@ function assertStoredCounts( * is shape territory (H-3/H-4). */ function canonicalJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map((element) => canonicalJson(element)).join(",")}]`; - } - if (value !== null && typeof value === "object") { - const entries = Object.entries(value as Record<string, unknown>) - .filter(([, member]) => member !== undefined) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map( - ([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`, - ); - return `{${entries.join(",")}}`; + // H-11: an explicit stack, never native recursion per nesting level. + type Item = { readonly render: unknown } | { readonly text: string }; + const pieces: string[] = []; + const stack: Item[] = [{ render: value }]; + while (stack.length > 0) { + const item = stack.pop(); + if (item === undefined) break; + if ("text" in item) { + pieces.push(item.text); + continue; + } + const current = item.render; + if (Array.isArray(current)) { + pieces.push("["); + stack.push({ text: "]" }); + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push({ render: current[index] }); + if (index > 0) stack.push({ text: "," }); + } + } else if (current !== null && typeof current === "object") { + const entries = Object.entries(current as Record<string, unknown>) + .filter(([, member]) => member !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + pieces.push("{"); + stack.push({ text: "}" }); + let remaining = entries.length; + for (const [key, member] of entries.reverse()) { + stack.push({ render: member }); + stack.push({ text: `${JSON.stringify(key)}:` }); + remaining -= 1; + if (remaining > 0) stack.push({ text: "," }); + } + } else { + pieces.push(JSON.stringify(current) ?? "null"); + } } - return JSON.stringify(value) ?? "null"; + return pieces.join(""); } /** Diagnosed canonical-JSON (key-order-insensitive) deep equality. */ @@ -429,13 +453,21 @@ function assertSameInformation( /** Every string leaf of a decoded JSON value (array elements and members). */ function collectStringLeaves(value: unknown, into: string[] = []): string[] { - if (typeof value === "string") { - into.push(value); - } else if (Array.isArray(value)) { - for (const element of value) collectStringLeaves(element, into); - } else if (value !== null && typeof value === "object") { - for (const member of Object.values(value)) { - collectStringLeaves(member, into); + // H-11: an explicit stack, never native recursion per nesting level. + const stack: unknown[] = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current === "string") { + into.push(current); + } else if (Array.isArray(current)) { + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push(current[index]); + } + } else if (current !== null && typeof current === "object") { + const members = Object.values(current); + for (let index = members.length - 1; index >= 0; index -= 1) { + stack.push(members[index]); + } } } return into; diff --git a/test/suite/registry/section-10.7-ii.ts b/test/suite/registry/section-10.7-ii.ts index 27926e3..7fba0ef 100644 --- a/test/suite/registry/section-10.7-ii.ts +++ b/test/suite/registry/section-10.7-ii.ts @@ -460,19 +460,43 @@ function assertBlockedBy( * is shape territory (H-3/H-4). */ function canonicalJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map((element) => canonicalJson(element)).join(",")}]`; - } - if (value !== null && typeof value === "object") { - const entries = Object.entries(value as Record<string, unknown>) - .filter(([, member]) => member !== undefined) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map( - ([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`, - ); - return `{${entries.join(",")}}`; + // H-11: an explicit stack, never native recursion per nesting level. + type Item = { readonly render: unknown } | { readonly text: string }; + const pieces: string[] = []; + const stack: Item[] = [{ render: value }]; + while (stack.length > 0) { + const item = stack.pop(); + if (item === undefined) break; + if ("text" in item) { + pieces.push(item.text); + continue; + } + const current = item.render; + if (Array.isArray(current)) { + pieces.push("["); + stack.push({ text: "]" }); + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push({ render: current[index] }); + if (index > 0) stack.push({ text: "," }); + } + } else if (current !== null && typeof current === "object") { + const entries = Object.entries(current as Record<string, unknown>) + .filter(([, member]) => member !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + pieces.push("{"); + stack.push({ text: "}" }); + let remaining = entries.length; + for (const [key, member] of entries.reverse()) { + stack.push({ render: member }); + stack.push({ text: `${JSON.stringify(key)}:` }); + remaining -= 1; + if (remaining > 0) stack.push({ text: "," }); + } + } else { + pieces.push(JSON.stringify(current) ?? "null"); + } } - return JSON.stringify(value) ?? "null"; + return pieces.join(""); } /** Diagnosed canonical-JSON (key-order-insensitive) deep equality. */ @@ -493,13 +517,21 @@ function assertSameInformation( /** Every string leaf of a decoded JSON value (array elements and members). */ function collectStringLeaves(value: unknown, into: string[] = []): string[] { - if (typeof value === "string") { - into.push(value); - } else if (Array.isArray(value)) { - for (const element of value) collectStringLeaves(element, into); - } else if (value !== null && typeof value === "object") { - for (const member of Object.values(value)) { - collectStringLeaves(member, into); + // H-11: an explicit stack, never native recursion per nesting level. + const stack: unknown[] = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current === "string") { + into.push(current); + } else if (Array.isArray(current)) { + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push(current[index]); + } + } else if (current !== null && typeof current === "object") { + const members = Object.values(current); + for (let index = members.length - 1; index >= 0; index -= 1) { + stack.push(members[index]); + } } } return into; diff --git a/test/suite/registry/section-12.0-i.ts b/test/suite/registry/section-12.0-i.ts index c144393..aadb793 100644 --- a/test/suite/registry/section-12.0-i.ts +++ b/test/suite/registry/section-12.0-i.ts @@ -405,18 +405,41 @@ interface SweepStoryOptions { * object key order is formatting, not information (TEST-SPEC §11). */ function canonicalizeJson(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(canonicalizeJson); - } - if (value !== null && typeof value === "object") { - const record = value as Record<string, unknown>; - const sorted: Record<string, unknown> = {}; - for (const key of Object.keys(record).sort()) { - sorted[key] = canonicalizeJson(record[key]); + // H-11: an explicit stack, never native recursion per nesting level. + type Container = unknown[] | Record<string, unknown>; + const isContainer = (candidate: unknown): candidate is Container => + candidate !== null && typeof candidate === "object"; + if (!isContainer(value)) return value; + const root: Container = Array.isArray(value) ? [] : {}; + const pending: { readonly source: Container; readonly copy: Container }[] = [ + { source: value, copy: root }, + ]; + // A leaf is placed as is; a container is placed as a fresh empty copy — + // attached in its parent's sorted member order, filled when its own frame + // is popped. + const placed = (member: unknown): unknown => { + if (!isContainer(member)) return member; + const copy: Container = Array.isArray(member) ? [] : {}; + pending.push({ source: member, copy }); + return copy; + }; + while (pending.length > 0) { + const frame = pending.pop(); + if (frame === undefined) break; + const { source, copy } = frame; + if (Array.isArray(source)) { + const target = copy as unknown[]; + for (let index = 0; index < source.length; index += 1) { + target[index] = placed(source[index]); + } + } else { + const target = copy as Record<string, unknown>; + for (const key of Object.keys(source).sort()) { + target[key] = placed(source[key]); + } } - return sorted; } - return value; + return root; } /** From a6adf4050d61c66a57ad8e3dcf900cd2a13e6b88 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 05:54:58 +0000 Subject: [PATCH 191/226] =?UTF-8?q?sdg(phase-9):=20add=20the=20S-8=20answe?= =?UTF-8?q?r-scale=20capacity=20gate=20=E2=80=94=20decoders=20and=20walks?= =?UTF-8?q?=208192=20deep,=20capture=20gated=20on=20the=20204=20MB=20`view?= =?UTF-8?q?=20--text`=20blowup,=20capture=20cap=20raised=20to=20512=20MiB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 7 of the Phase 9 plan (TEST-SPEC §17 S-8, §0 H-11). - test/self/s8-answer-scale-capacity.test.ts derives the staged maxima from the suite's own generators: the deepest stageable chain (two depth-4096 towers nested by a shuffle within the three-mutation budget: 8192), the largest staged file (base + three towers, ~197 KB, every tower/rewrite mix computed), and the `view --text` blowup (SPEC 3's line terminators are LF/CR/CRLF only, so a P-8 LF -> U+2028 rewrite leaves every tower level's separators as content and each level's subtree text re-emits the levels below: ~204 MB over two towers in the `
` JSON spelling). The fixed CI seed set is replayed through the new `drawFixedSeedTrials` to confirm the staged draws stay inside the derivation (and that P-8's 2048 floor is staged). Synthetic conforming-form documents at that scale are built iteratively and driven through every H-3/12.7 decoder, the marker walk, P-11's unavailability walk, the diagnosis renderer's fallback, and the registry modules' JSON walkers; the blowup document is streamed by an S-3 style stand-in through `runProduct`, captured complete and byte-identical, decoded from the capture, and a cap one byte short surfaces as ProductRunOutputOverflowError. - DEFAULT_MAX_OUTPUT_BYTES: 64 MiB -> 512 MiB, gated at >= 2x the blowup. - section-16-p8.ts exports NESTING_DEPTHS, TERMINATOR_SEQUENCES, MAX_MUTATIONS_PER_TRIAL and the byte-exact sectionTowerSource (used by mutateNesting); section-16-p11.ts exports documentCarriesUnavailability; the section-10.x/12.0-i JSON walkers are exported. No draw changes. - AGENTS.md records the gate's cost and the cap's derivation. Verification: npm run typecheck green; npm run test:self 335/335 (17 files); the new file alone runs in ~10 s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 1 + specs/tmp/FIX_PLAN.md | 51 -- test/helpers/property.ts | 28 + test/helpers/subprocess.ts | 15 +- test/self/s8-answer-scale-capacity.test.ts | 840 +++++++++++++++++++++ test/suite/registry/section-10.2-10.3.ts | 7 +- test/suite/registry/section-10.4.ts | 5 +- test/suite/registry/section-10.6.ts | 7 +- test/suite/registry/section-10.7-i.ts | 7 +- test/suite/registry/section-10.7-ii.ts | 7 +- test/suite/registry/section-12.0-i.ts | 2 +- test/suite/registry/section-16-p11.ts | 14 +- test/suite/registry/section-16-p8.ts | 30 +- 13 files changed, 940 insertions(+), 74 deletions(-) create mode 100644 test/self/s8-answer-scale-capacity.test.ts diff --git a/AGENTS.md b/AGENTS.md index 68802e5..2d35620 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,3 +20,4 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Format code (Prettier, default config, `src/` and `test/` only): `npm run format`; verify: `npm run format:check`. - CI: `.github/workflows/ci.yml` — harness-self (Linux), full suite (Linux, network disabled after setup via `.github/scripts/run-without-network.sh`), and the Windows E-6 leg on every pull request. The `e6-linux-outputs` upload step of the full-suite job has no `if: always()`, so it is skipped whenever any Linux test fails (the run ends with no artifact): the Windows byte-identity test then fails loudly on the missing manifest until suite-linux is green — that arm's verdict is meaningful only on a green Linux run. - Answer-document scale (TEST-SPEC H-11): the suite stages section towers 4096 deep (`NESTING_DEPTHS` in `test/suite/registry/section-16-p8.ts`), past V8's limits in this Node — a plain recursive function gets ~9.9k frames, and `JSON.stringify`/`structuredClone` throw `RangeError` at that nesting while `JSON.parse` is iterative. Harness walks over answer documents therefore use explicit stacks (`decodeViewNodeForm`, `assertUnavailabilityMarkerForms`, `describeJsonValue`'s fallback, and `query.ts`'s `walkForRangeData` and `decodeIdsTreeNode` — `ids --tree` nests one node per section level — in `test/helpers/adapters/`, and the registry modules' own generic JSON walkers, `canonicalJson`, `collectStringLeaves` and `canonicalizeJson` in `test/suite/registry/section-10.*.ts` and `section-12.0-i.ts`); a property reporting `RangeError: Maximum call stack size exceeded` as a harness error names a walk still recursing per level — its `Caused by` frames locate it. To drive a decoder at depth by hand, a temporary self-test (see above) building the document in a loop is enough: 200k-deep `ids --tree` and edge documents decode in well under a second. A walker its module does not export can be exercised the same way as a scratch `.ts` twin of it run under `node --experimental-strip-types` (Node 22). +- Answer-scale capacity gate (TEST-SPEC S-8): `test/self/s8-answer-scale-capacity.test.ts` derives the suite's staged maxima from the generators themselves — `NESTING_DEPTHS`, `MAX_MUTATIONS_PER_TRIAL`, `TERMINATOR_SEQUENCES`, and the byte-exact tower builder `sectionTowerSource(depth, balanced)` exported by `test/suite/registry/section-16-p8.ts`, P-2/P-3's `specSubtreeTexts` oracle — replays the fixed CI seed set through `drawFixedSeedTrials(generator, runs)` (`test/helpers/property.ts`; the same per-seed PRNG stream `checkProperty` uses, no property body) to confirm the staged draws stay inside them, and drives every H-3/12.7 decoder and answer-document walk on synthetic documents at that scale: a `view` nested 8192 deep (two depth-4096 towers nested by a shuffle mutation) and the `view --text` blowup — two depth-4096 towers whose LF → U+2028 rewrite leaves every level's separators as content, ~204 MB in the `\u2028` spelling. The blowup document is written to a workspace file and streamed to stdout by a stand-in script through `runProduct`, so the test costs ~10 s and holds roughly 0.6–0.8 GB in the Vitest worker (the captured bytes, their UTF-8 string, and the parsed document coexist); run it alone with `npx vitest run --config test/vitest.config.ts --project self test/self/s8-answer-scale-capacity.test.ts`. `DEFAULT_MAX_OUTPUT_BYTES` in `test/helpers/subprocess.ts` (512 MiB) is gated by that test at no less than twice the blowup document — lower it or grow a generator's scale and S-8 fails, by design; memory for the cap is committed only as output arrives. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index fee6e0f..95cc44c 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,57 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage B — H-11 answer-scale capacity (P-11 harness error), S-8, S-2, T1.3-7 -### Task 7 — S-8 answer-scale capacity self-test (decoders, walks, capture gate) - -Cites: TEST-SPEC §17 S-8 ("The H-3/12.7 decoders and every answer-document -walk the suite performs succeed, every datum evaluated without harness error, -on synthetic conforming-form documents at the maximum answer scale H-11 -obliges … expansion blowup included, a `view --text` answer multiplying -embedded subtree text through each expansion level past its staged input's own -size … among them a `view` document nested at least as deep as P-8's -giant-nesting floor. Capture is gated at the same scale through S-3's stand-in -mechanism: a stand-in command emitting the largest of these synthetic documents -on standard output is driven through the H-2 capture path product invocations -use, and the captured bytes MUST be complete and identical to what the stand-in -emitted"); §0 H-11 (an exhausted capture limit must surface as a loud harness -error, never silent truncation). - -Now: no `test/self/s8-*` file; nothing under `test/` references S-8 or H-11. -The capture cap is `DEFAULT_MAX_OUTPUT_BYTES` (64 MiB) in -`test/helpers/subprocess.ts` ≈ line 52; overflow raises -`ProductRunOutputOverflowError` (plain `Error`, runner outcome "error" — the -H-11 classification, already correct). S-3's stand-in pattern is in -`test/self/s3-subprocess-driver.test.ts` (`STANDIN_SOURCE`, `standin()`). - -Do: add `test/self/s8-answer-scale-capacity.test.ts` (S-8 in its header -comment). (a) Derive and document, in the test, the maximum answer scale over -the suite's staged inputs: the deepest staged nesting (4096 from P-8's -`NESTING_DEPTHS`; floor 2048 from T1.3-7), the largest staged document size -(sweep deterministic fixtures and every generator's size bounds in -`test/suite/registry/section-16-*.ts` and `test/helpers/`), and the `view ---text` expansion blowup (embedded subtree text re-emitted at each expansion -level: compute the bound from the deepest/largest embedding chain any staged -input can hold, per SPEC 11.4). (b) Synthesize conforming-form documents at -those scales in memory (a `view` document nested ≥ 4096 levels; a `view --text` -document at the blowup size; the largest `query`/`occurrences`/`build --json` -shapes if any walk consumes them at scale) and drive each through every H-3/12.7 -decoder in `test/helpers/adapters/` and every answer-document walk the suite -performs (the P-11 walk, `assertUnavailabilityMarkerForms`, any decoder-level -ordering checks), asserting no exception and the expected datum count. -(c) Capture gate: a stand-in Node script that writes the largest synthetic -document to stdout (streamed in chunks, then exit 0), driven through the same -`ProductBinding`/run path product invocations use; assert the captured stdout -bytes are complete and byte-identical to the emitted document and that the -run's outcome is a normal completion. If the document exceeds -`DEFAULT_MAX_OUTPUT_BYTES`, raise the default (H-11: dimensioned to staged -scales) and keep a separate assertion that an over-cap stand-in surfaces as -`ProductRunOutputOverflowError` (loud, never truncation). Prerequisites: Tasks -5–6 (otherwise the gate is red). - -Verify: `npm run test:self` green including the new file; the test runs in the -`self` project under the CI `harness-self` job (check `test/vitest.config.ts` -project globs include it); `npm run typecheck`. - ### Task 8 — S-2 scale vectors: ≥ 2048-deep document and the largest staged document, read back byte-complete Cites: TEST-SPEC §17 S-2 ("with scale vectors at the suite's staged maxima — a diff --git a/test/helpers/property.ts b/test/helpers/property.ts index 216f69b..a87d31d 100644 --- a/test/helpers/property.ts +++ b/test/helpers/property.ts @@ -430,6 +430,34 @@ export async function checkProperty<T>( } } +/** + * Draw a generator's trials exactly as {@link checkProperty} draws them under + * the fixed seed plan (E-5: the CI seed set) — per seed a fresh PRNG and + * `runs` sequential trials — without running any property body. S-8 replays + * the suite's generators through this to measure the scales the fixed seed + * set actually stages against the harness's derived capacity bounds (H-11). + */ +export function drawFixedSeedTrials<T>( + generator: Gen<T>, + runs: number, + seeds: readonly number[] = DEFAULT_PROPERTY_SEEDS, +): T[] { + if (!Number.isInteger(runs) || runs <= 0) { + throw new Error( + `drawFixedSeedTrials: runs must be a positive integer, got ${String(runs)}`, + ); + } + validateSeeds("drawFixedSeedTrials", seeds); + const values: T[] = []; + for (const seed of seeds) { + const rng = new Mulberry32(seed); + for (let trial = 1; trial <= runs; trial += 1) { + values.push(generateTrial(generator, rng).value); + } + } + return values; +} + // --------------------------------------------------------------------------- // Seeded generation: PRNG, choice source, trials. diff --git a/test/helpers/subprocess.ts b/test/helpers/subprocess.ts index 26eed87..01e95d1 100644 --- a/test/helpers/subprocess.ts +++ b/test/helpers/subprocess.ts @@ -48,8 +48,19 @@ import { fileURLToPath } from "node:url"; /** Hang guard applied to every invocation unless overridden (H-8). */ export const DEFAULT_TIMEOUT_MS = 30_000; -/** Runaway-output guard: combined stdout+stderr cap per invocation. */ -export const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 * 1024; +/** + * Runaway-output guard: combined stdout+stderr cap per invocation. H-11 + * dimensions it to the largest answer SPEC.md permits a conforming product + * over the inputs the suite stages — S-8 (test/self/s8-answer-scale- + * capacity.test.ts) derives that scale from the suite's own generators + * (about 204 MB: `view --text` over two depth-4096 section towers whose + * line feeds a P-8 rewrite turned into U+2028 separators, every level's + * subtree text re-emitting the levels below, spelled JSON-escaped) and gates + * this constant at no less than twice it. Memory is committed only as output + * arrives, so the cap costs ordinary runs nothing; exceeding it is a loud + * `ProductRunOutputOverflowError`, never a silent truncation. + */ +export const DEFAULT_MAX_OUTPUT_BYTES = 512 * 1024 * 1024; /** Default bound on hold-file waits (H-8: waits always terminate). */ export const DEFAULT_WAIT_FOR_FILE_TIMEOUT_MS = 10_000; diff --git a/test/self/s8-answer-scale-capacity.test.ts b/test/self/s8-answer-scale-capacity.test.ts new file mode 100644 index 0000000..3ca40bd --- /dev/null +++ b/test/self/s8-answer-scale-capacity.test.ts @@ -0,0 +1,840 @@ +// S-8 Answer-scale capacity self-test (TEST-SPEC 17 S-8; §0 H-11). The +// harness must capture, decode, and evaluate every answer SPEC.md permits a +// conforming product over the inputs the suite stages — nesting depth and +// document size included, expansion blowup included — with every internal +// capacity limit dimensioned to that scale, and an exhausted capture limit a +// loud harness error, never a silent truncation. No CERTIFICATIONS.md fixture +// reaches this class (a harness-side failure against a conforming answer is +// a spurious fail, not a missed deviation), so it is gated here, before any +// product exists (H-8's ordering): +// +// 1. the scale is DERIVED from the suite's own generators — P-8/P-11's +// towers and mutation budget, P-2/P-3's expansion oracle — never +// assumed; the constants below state the derivation, and the fixed CI +// seed set (E-5) is replayed to confirm the staged draws stay inside it; +// 2. synthetic conforming-form documents at that scale are built +// iteratively (never by recursion — `JSON.stringify` itself overflows +// at these depths) and driven through every H-3/12.7 decoder and every +// answer-document walk the suite performs, asserting no exception and +// the expected datum counts; +// 3. capture is gated at the same scale through S-3's stand-in mechanism: +// a stand-in command streams the largest synthetic document to stdout +// through the one ProductBinding/run path product invocations use +// (H-2, C-2); the captured bytes must be complete and identical to what +// it emitted, the capture feeds the decoders unchanged, the default +// capture cap must hold at least twice the document, and a cap set just +// below the document must surface as ProductRunOutputOverflowError. + +import { Buffer } from "node:buffer"; +import { once } from "node:events"; +import { createReadStream, createWriteStream } from "node:fs"; +import { expect, onTestFinished, test } from "vitest"; +import { + assertBareEdgeEndpoints, + assertNodeEdgeListsBare, + assertUnavailabilityMarkerForms, + decodeAtReport, + decodeEdgesReport, + decodeErrorDocument, + decodeFindingsReport, + decodeIdsReport, + decodeIdsTreeReport, + decodeNodeIdentityRowsReport, + decodeNodeReport, + decodeNodeRowsReport, + decodeNodeSummaryRowsReport, + decodeNodeTextSummary, + decodeOccurrencesReport, + decodeReachableReport, + decodeViewReport, + describeJsonValue, +} from "../helpers/adapters/index.js"; +import type { IdsTreeNode, ViewNode } from "../helpers/adapters/index.js"; +import { parseJsonStdout } from "../helpers/assertions.js"; +import { drawFixedSeedTrials } from "../helpers/property.js"; +import { + DEFAULT_MAX_OUTPUT_BYTES, + ProductRunOutputOverflowError, + runProduct, +} from "../helpers/subprocess.js"; +import type { ProductBinding } from "../helpers/subprocess.js"; +import { TestWorkspace } from "../helpers/workspace.js"; +import { + canonicalJson as canonicalJson1023, + collectStringLeaves as collectStringLeaves1023, +} from "../suite/registry/section-10.2-10.3.js"; +import { collectStringLeaves as collectStringLeaves104 } from "../suite/registry/section-10.4.js"; +import { + canonicalJson as canonicalJson106, + collectStringLeaves as collectStringLeaves106, +} from "../suite/registry/section-10.6.js"; +import { + canonicalJson as canonicalJson107i, + collectStringLeaves as collectStringLeaves107i, +} from "../suite/registry/section-10.7-i.js"; +import { + canonicalJson as canonicalJson107ii, + collectStringLeaves as collectStringLeaves107ii, +} from "../suite/registry/section-10.7-ii.js"; +import { canonicalizeJson } from "../suite/registry/section-12.0-i.js"; +import { + documentCarriesUnavailability, + genAvailabilityTrial, +} from "../suite/registry/section-16-p11.js"; +import { + generatedDoc, + specSubtreeTexts, +} from "../suite/registry/section-16-p2-p3.js"; +import { + FUZZ_BASE_FILES, + genFuzzTrial, + MAX_MUTATIONS_PER_TRIAL, + NESTING_DEPTHS, + sectionTowerSource, + TERMINATOR_SEQUENCES, +} from "../suite/registry/section-16-p8.js"; + +// --------------------------------------------------------------------------- +// 1. The scale the suite stages — derived from the generators, not assumed +// +// Nesting. P-8's giant-nesting draws stage balanced towers `<S id="g">` × +// depth (NESTING_DEPTHS; the deepest 4096, the floor 2048 that T1.3-7 +// anchors). A trial applies up to MAX_MUTATIONS_PER_TRIAL mutations to the +// same file, and a shuffle mutation relocates one contiguous byte range, so +// tower + tower + shuffle can drop the second tower into the first's +// innermost level: the deepest section chain any P-8/P-11 draw can stage is +// 2 × 4096 = 8192 (a third tower would need a fourth mutation). Every `view` +// and `ids --tree` answer over such an input nests one node per level. +const GIANT_NESTING_FLOOR = 2048; +const DEEPEST_STAGED_TOWER = Math.max(...NESTING_DEPTHS); +const SYNTHETIC_DEPTH = 2 * DEEPEST_STAGED_TOWER; + +// Document size. The largest staged file is the largest fuzz base file with +// every mutation of the budget appending the deepest balanced tower. The +// competing growth is a terminator rewrite (every LF → the fattest sequence +// of TERMINATOR_SEQUENCES, U+2028 at three bytes): `towers` towers plus +// `rewrites` rewrites grow each line feed to at most 2^(rewrites − 1) × 3 +// bytes (LFLF doublings, then the fattest sequence), and every such mix is +// computed below — the all-towers mix wins. Splices (≤ 8 bytes), garbage +// (≤ 64), BOMs (≤ 3), and terminator runs (≤ 64 × 3) are smaller than any +// tower; truncate and shuffle never grow a file. P-2/P-3 documents (≤ 3 +// files × ≤ 6 sections of single-line constructs), P-4/P-9's (≤ 3 sections +// per file, prose runs ≤ 8 characters), and every deterministic fixture +// (the largest: T4.1's ~33 KiB own-text probe) are far smaller. +const TOWER_SOURCE = sectionTowerSource(DEEPEST_STAGED_TOWER, true); +const TOWER_BYTES = Buffer.byteLength(TOWER_SOURCE, "utf8"); +const FATTEST_TERMINATOR = Math.max( + ...TERMINATOR_SEQUENCES.map(([, sequence]) => sequence.length), +); + +function countLineFeeds(text: string): number { + let count = 0; + for ( + let index = text.indexOf("\n"); + index >= 0; + index = text.indexOf("\n", index + 1) + ) { + count += 1; + } + return count; +} + +/** Every tower/rewrite mix of the mutation budget over one base file. */ +function stagedSizeCandidates(base: string): number[] { + const bytes = Buffer.byteLength(base, "utf8"); + const feeds = countLineFeeds(base); + const towerFeeds = countLineFeeds(TOWER_SOURCE); + const candidates: number[] = []; + for (let towers = 0; towers <= MAX_MUTATIONS_PER_TRIAL; towers += 1) { + const rewrites = MAX_MUTATIONS_PER_TRIAL - towers; + const bytesPerFeed = + rewrites === 0 ? 1 : 2 ** (rewrites - 1) * FATTEST_TERMINATOR; + candidates.push( + bytes + + towers * TOWER_BYTES + + (feeds + towers * towerFeeds) * (bytesPerFeed - 1), + ); + } + return candidates; +} + +const LARGEST_BASE_BYTES = Math.max( + ...FUZZ_BASE_FILES.map(([, text]) => Buffer.byteLength(text, "utf8")), +); +const LARGEST_STAGED_INPUT_BYTES = Math.max( + ...FUZZ_BASE_FILES.flatMap(([, text]) => stagedSizeCandidates(text)), +); + +// Expansion blowup. SPEC 3 defines a line terminator as CRLF, a lone LF, or +// a lone CR — nothing else — so after P-8's LF → U+2028 rewrite a tower's +// tags no longer stand on lines of their own: no line is dropped, and every +// level's own text is its two separators. A section's subtree text (1.6) +// re-emits every level beneath it, so a `view --text` answer carries, per +// tower, Σ_k (2·(D − k) separators + the content line) — quadratic in the +// depth: ~100 MB at D = 4096 in the six-character `\u2028` JSON spelling a +// conforming product may choose (12.7 pins no escaping), ~50 MB raw. Two +// towers under one rewrite (tower + tower + rewrite: the whole budget) double +// it, and P-11's answer arms request exactly this (`view --text` over the +// mutated base). That is the largest answer SPEC.md permits over a staged +// input — three orders of magnitude past the input's own size. Embedding +// chains multiply less here: the fuzz base's chain (B.b → A.c → A.a.b) has +// fan-out one, a shuffle can carry a tower into an embedded target once, and +// an embedded subtree is re-emitted once per embedding level, not once per +// nesting level; P-2/P-3's generator has no structural expansion bound (each +// target may embed every earlier target), so its fixed-seed maximum is +// measured below and its randomized mode fails loudly in the oracle +// (`specSubtreeTexts` materializes every expansion before any product runs) +// rather than silently under-capturing. +const BLOWUP_TOWERS = 2; +const BLOWUP_DEPTH = DEEPEST_STAGED_TOWER; +/** U+2028 as a conforming product may spell it inside a JSON string. */ +const SEPARATOR_ESCAPED = "\\u2028"; +const SEPARATOR = "\u2028"; + +test("S-8: the derived scale — deepest chain, largest staged input, blowup input", () => { + expect(DEEPEST_STAGED_TOWER).toBeGreaterThanOrEqual(GIANT_NESTING_FLOOR); + expect(SYNTHETIC_DEPTH).toBe(8192); + // The tower the suite stages, byte for byte (sectionTowerSource is what + // mutateNesting appends): 11 bytes per opener line, the content line, 5 + // bytes per closer line. + expect(TOWER_BYTES).toBe( + DEEPEST_STAGED_TOWER * 11 + 6 + DEEPEST_STAGED_TOWER * 5, + ); + expect(FATTEST_TERMINATOR).toBe(3); + // The derivation's claim: the all-towers mix is the largest staged file. + expect(LARGEST_STAGED_INPUT_BYTES).toBe( + LARGEST_BASE_BYTES + MAX_MUTATIONS_PER_TRIAL * TOWER_BYTES, + ); + expect(LARGEST_STAGED_INPUT_BYTES).toBeGreaterThan(190_000); + expect(LARGEST_STAGED_INPUT_BYTES).toBeLessThan(200_000); +}); + +test("S-8: the fixed CI seed set stages within the derived scale (E-5 replay)", () => { + // DEFAULT_RUNS_PER_SEED (25) bounds every property's registered run count, + // and each seed's trials are one sequential PRNG stream, so the draws the + // suite stages under the fixed plan are a prefix of these. + const RUNS = 25; + let largestFile = 0; + let deepestTower = 0; + const trials = [ + ...drawFixedSeedTrials(genFuzzTrial, RUNS), + ...drawFixedSeedTrials(genAvailabilityTrial, RUNS), + ]; + for (const trial of trials) { + for (const [, bytes] of trial.files) { + largestFile = Math.max(largestFile, bytes.length); + } + for (const mutation of trial.mutations) { + const depth = /depth-(\d+) /.exec(mutation); + if (depth !== null) + deepestTower = Math.max(deepestTower, Number(depth[1])); + } + } + expect(largestFile).toBeLessThanOrEqual(LARGEST_STAGED_INPUT_BYTES); + expect(deepestTower).toBeLessThanOrEqual(DEEPEST_STAGED_TOWER); + // P-8's test-strength floor on staged draws (TEST-SPEC §16 P-8): the fixed + // seed set must itself stage nesting at least 2048 deep. + expect(deepestTower).toBeGreaterThanOrEqual(GIANT_NESTING_FLOOR); + + // P-2/P-3: the largest text datum a `query node` answer carries over the + // fixed-seed documents — the expansion oracle's own materialization. + let largestText = 0; + for (const doc of drawFixedSeedTrials(generatedDoc, RUNS)) { + for (const text of specSubtreeTexts(doc).values()) { + largestText = Math.max(largestText, Buffer.byteLength(text, "utf8")); + } + } + expect(largestText).toBeGreaterThan(0); + expect(largestText).toBeLessThan(LARGEST_STAGED_INPUT_BYTES); +}); + +// --------------------------------------------------------------------------- +// 2. Synthetic conforming-form documents, built iteratively + +const VIEWED_FILE = "specs/A.mdx"; + +interface TowerText { + /** JSON-escaped own text of level k (1 = the outermost). */ + readonly own: (level: number) => string; + /** JSON-escaped subtree text of level k. */ + readonly subtree: (level: number) => string; +} + +interface ViewDocumentSpec { + readonly towers: number; + readonly depth: number; + /** Node text members (the `--text` form), or null for the bare form. */ + readonly text: TowerText | null; + /** JSON-escaped root own/subtree texts (text form only). */ + readonly rootText: { readonly own: string; readonly subtree: string }; + /** Tower node identities: the marker (duplicate `g`) or a plain string. */ + readonly identity: "marker" | "string"; + /** Element count of each flat per-file member and of the findings. */ + readonly flat: number; +} + +/** + * Append one balanced tower's node chain as JSON text: `depth` nested view + * nodes in the literal 12.7 form, ranges laid out exactly as + * sectionTowerSource's bytes lie from `start` — opener lines of 11 bytes + * (`<S id="g">`, the attribute at +3..+9), the 6-byte content line, closer + * lines of 5 bytes. Returns the byte offset after the tower. + */ +function appendTowerNodes( + out: string[], + spec: ViewDocumentSpec, + start: number, + tower: number, +): number { + const closersStart = start + spec.depth * 11 + 6; + for (let level = 1; level <= spec.depth; level += 1) { + const open = start + (level - 1) * 11; + const close = closersStart + (spec.depth - level) * 5; + const identity = + spec.identity === "marker" + ? '{"unavailable":true}' + : JSON.stringify(`${VIEWED_FILE}#t${String(tower)}.g${String(level)}`); + out.push( + `{"identity":${identity},"range":{"start":${String(open)},"end":${String(close + 4)}},` + + `"opening":{"start":${String(open)},"end":${String(open + 10)}},` + + `"closing":{"start":${String(close)},"end":${String(close + 4)}},` + + `"attributes":[{"name":"id","range":{"start":${String(open + 3)},"end":${String(open + 9)}},"text":"id=\\"g\\""}],` + + `"tags":[],"coverage":null,`, + ); + if (spec.text !== null) { + out.push( + `"ownText":"${spec.text.own(level)}","subtreeText":"${spec.text.subtree(level)}",`, + ); + } + out.push('"children":['); + } + for (let level = 1; level <= spec.depth; level += 1) out.push("]}"); + return closersStart + spec.depth * 5; +} + +/** `count` findings in the pinned order: one 14.1 finding locating every + * bearer, then one 14.3 finding per bearer, locations ascending. */ +function appendFindings(out: string[], count: number): void { + if (count === 0) return; + out.push( + '{"code":"missing-id","message":"a section spells no id","locations":[', + ); + for (let index = 0; index < count; index += 1) { + if (index > 0) out.push(","); + out.push( + `{"file":${JSON.stringify(VIEWED_FILE)},"range":{"start":${String(index * 11)},"end":${String(index * 11 + 10)}}}`, + ); + } + out.push('],"path":null,"identities":[]}'); + for (let index = 0; index < count; index += 1) { + out.push( + `,{"code":"duplicate-id","message":"duplicate id g","locations":[{"file":${JSON.stringify(VIEWED_FILE)},"range":{"start":${String(index * 11)},"end":${String(index * 11 + 10)}}}],"path":null,"identities":[]}`, + ); + } +} + +/** The whole `view` document as JSON text pieces (join to get the text). */ +function buildViewDocument(spec: ViewDocumentSpec): string[] { + const out: string[] = []; + const fileLength = spec.towers * TOWER_BYTES; + out.push('{"findings":['); + appendFindings(out, spec.flat); + out.push(`],"views":[{"file":${JSON.stringify(VIEWED_FILE)},"root":`); + out.push( + `{"identity":${JSON.stringify(VIEWED_FILE)},"range":{"start":0,"end":${String(fileLength)}},` + + `"opening":null,"closing":null,"attributes":[],"tags":null,"coverage":null,`, + ); + if (spec.text !== null) { + out.push( + `"ownText":"${spec.rootText.own}","subtreeText":"${spec.rootText.subtree}",`, + ); + } + out.push('"children":['); + let offset = 0; + for (let tower = 1; tower <= spec.towers; tower += 1) { + if (tower > 1) out.push(","); + offset = appendTowerNodes(out, spec, offset, tower); + } + out.push("]}"); + out.push(',"imports":['); + for (let index = 0; index < spec.flat; index += 1) { + if (index > 0) out.push(","); + out.push( + `{"range":{"start":${String(index * 8)},"end":${String(index * 8 + 7)}},"name":null,"target":{"unavailable":true}}`, + ); + } + out.push('],"occurrences":['); + for (let index = 0; index < spec.flat; index += 1) { + if (index > 0) out.push(","); + out.push( + `{"file":${JSON.stringify(VIEWED_FILE)},"range":{"start":${String(index * 6)},"end":${String(index * 6 + 5)}},` + + `"kind":"embeds","source":{"unavailable":true},"target":"specs/A.mdx#a.b"}`, + ); + } + out.push('],"comments":['); + for (let index = 0; index < spec.flat; index += 1) { + if (index > 0) out.push(","); + out.push(`{"start":${String(index * 4)},"end":${String(index * 4 + 3)}}`); + } + out.push("]}]}"); + return out; +} + +/** An `ids --tree` document nesting one node per level, `depth` deep. */ +function buildIdsTreeDocument(depth: number): string { + const out: string[] = [ + `{"files":[{"file":${JSON.stringify(VIEWED_FILE)},"nodes":[`, + ]; + for (let level = 0; level < depth; level += 1) + out.push('{"id":"g","children":['); + for (let level = 0; level < depth; level += 1) out.push("]}"); + out.push("]}]}"); + return out.join(""); +} + +function countViewNodes(root: ViewNode): { nodes: number; depth: number } { + let nodes = 0; + let depth = 0; + const stack: { readonly node: ViewNode; readonly level: number }[] = [ + { node: root, level: 0 }, + ]; + while (stack.length > 0) { + const { node, level } = stack.pop()!; + nodes += 1; + depth = Math.max(depth, level); + for (const child of node.children) + stack.push({ node: child, level: level + 1 }); + } + return { nodes, depth }; +} + +function countIdsNodes(roots: readonly IdsTreeNode[]): { + nodes: number; + depth: number; +} { + let nodes = 0; + let depth = 0; + const stack: { readonly node: IdsTreeNode; readonly level: number }[] = + roots.map((node) => ({ node, level: 1 })); + while (stack.length > 0) { + const { node, level } = stack.pop()!; + nodes += 1; + depth = Math.max(depth, level); + for (const child of node.children) + stack.push({ node: child, level: level + 1 }); + } + return { nodes, depth }; +} + +function innermost(root: ViewNode): ViewNode { + let node = root; + while (node.children.length > 0) node = node.children[0]!; + return node; +} + +/** An independent count of string leaves (the registry walkers' oracle). */ +function countStringLeaves(value: unknown): number { + let count = 0; + const stack: unknown[] = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (typeof current === "string") count += 1; + else if (Array.isArray(current)) stack.push(...current); + else if (typeof current === "object" && current !== null) { + stack.push(...Object.values(current)); + } + } + return count; +} + +const identities = (count: number, offset = 0): string[] => + Array.from( + { length: count }, + (_, index) => `${VIEWED_FILE}#g${String(index + offset)}`, + ); + +test("S-8: every H-3/12.7 decoder and answer-document walk succeeds at the synthetic depth", () => { + const depth = SYNTHETIC_DEPTH; + const flat = SYNTHETIC_DEPTH; + const bareText = buildViewDocument({ + towers: 1, + depth, + text: null, + rootText: { own: "", subtree: "" }, + identity: "marker", + flat, + }).join(""); + const bare = JSON.parse(bareText) as unknown; + + // The `view` document: the positional tree one node per level, and the + // flat per-file members and findings at the same count. + const view = decodeViewReport(bare, { text: false }, "S-8 deep view"); + expect(view.findings).toHaveLength(flat + 1); + expect(view.views).toHaveLength(1); + const fileView = view.views[0]!; + expect(countViewNodes(fileView.root)).toEqual({ nodes: depth + 1, depth }); + expect(fileView.imports).toHaveLength(flat); + expect(fileView.occurrences).toHaveLength(flat); + expect(fileView.comments).toHaveLength(flat); + expect(innermost(fileView.root).identity).toEqual({ unavailable: true }); + + // The `--text` twin at depth (P-11's arm): both text members per level. + const textDoc = JSON.parse( + buildViewDocument({ + towers: 1, + depth, + text: { own: () => "", subtree: () => "deep.\\n" }, + rootText: { own: "", subtree: "deep.\\n" }, + identity: "marker", + flat: 0, + }).join(""), + ) as unknown; + const textView = decodeViewReport( + textDoc, + { text: true }, + "S-8 deep view --text", + ); + expect(countViewNodes(textView.views[0]!.root)).toEqual({ + nodes: depth + 1, + depth, + }); + expect(innermost(textView.views[0]!.root).subtreeText).toBe("deep.\n"); + + // The whole-document marker walk and P-11's unavailability walk. + assertUnavailabilityMarkerForms(bare, "S-8 deep view"); + expect(documentCarriesUnavailability(bare)).toBe(true); + expect(documentCarriesUnavailability(textDoc)).toBe(true); + + // The diagnosis renderer: bounded text at any depth (its fallback runs + // where V8's serializer overflows). + const description = describeJsonValue(bare); + expect(description.startsWith("object ")).toBe(true); + expect(description.length).toBeLessThan(400); + + // The registry modules' generic JSON walkers: string leaves counted + // against an independent walk, canonical renderings that decode back to + // the same tree, key-order canonicalization that decodes likewise. + const leaves = countStringLeaves(bare); + for (const collect of [ + collectStringLeaves1023, + collectStringLeaves104, + collectStringLeaves106, + collectStringLeaves107i, + collectStringLeaves107ii, + ]) { + expect(collect(bare)).toHaveLength(leaves); + } + for (const render of [ + canonicalJson1023, + canonicalJson106, + canonicalJson107i, + canonicalJson107ii, + ]) { + const rendered = render(bare); + expect(rendered.length).toBeGreaterThan(depth * 100); + const decoded = decodeViewReport(JSON.parse(rendered), { text: false }); + expect(countViewNodes(decoded.views[0]!.root)).toEqual({ + nodes: depth + 1, + depth, + }); + } + const canonicalized = decodeViewReport(canonicalizeJson(bare), { + text: false, + }); + expect(countViewNodes(canonicalized.views[0]!.root)).toEqual({ + nodes: depth + 1, + depth, + }); + + // `ids --tree` one node per level; `ids` and the row reports at the + // section count T1.3-7's `query subtree` returns (root plus every level). + const idsTree = decodeIdsTreeReport( + JSON.parse(buildIdsTreeDocument(depth)), + "S-8 ids --tree", + ); + expect(countIdsNodes(idsTree.files[0]!.nodes)).toEqual({ + nodes: depth, + depth, + }); + const ids = identities(depth + 1); + expect( + decodeIdsReport({ files: [{ file: VIEWED_FILE, ids }] }).files[0]!.ids, + ).toHaveLength(depth + 1); + const rows = { + nodes: [ + { + identity: VIEWED_FILE, + sourceRange: { start: 0, end: TOWER_BYTES }, + tags: [], + }, + ...ids.map((identity, index) => ({ + identity, + sourceRange: { start: index, end: index + 1 }, + tags: ["t1"], + coverage: "none", + })), + ], + }; + expect(decodeNodeRowsReport(rows, "S-8 rows")).toHaveLength(depth + 2); + expect(decodeNodeSummaryRowsReport(rows, "S-8 rows")).toHaveLength(depth + 2); + expect(decodeNodeIdentityRowsReport(rows, "S-8 rows")).toHaveLength( + depth + 2, + ); + + // Edge surfaces at the chain's edge count, each through the bare-endpoint + // walk; a reachability witness the length of the chain. + const edges = ids.slice(0, -1).map((from, index) => ({ + from, + to: ids[index + 1]!, + kind: "contains", + })); + expect(decodeEdgesReport({ edges }, "S-8 edges")).toHaveLength(depth); + assertBareEdgeEndpoints({ edges }, "S-8 edges"); + const node = { + identity: VIEWED_FILE, + sourceRange: { start: 0, end: TOWER_BYTES }, + ownText: "", + subtreeText: "deep.\n", + hashes: { + ownHash: "o", + subtreeHash: "s", + effectiveHash: "e", + metadataHash: "m", + }, + tags: [], + edges: { incoming: edges, outgoing: edges }, + }; + expect(decodeNodeReport(node, "S-8 node").incomingEdges).toHaveLength(depth); + assertNodeEdgeListsBare(node, "S-8 node"); + const reachable = { reachable: true, path: ids }; + expect(decodeReachableReport(reachable, "S-8 reachable").path).toHaveLength( + depth + 1, + ); + assertBareEdgeEndpoints(reachable, "S-8 reachable"); + + // The flat 12.7 surfaces at the same count: findings-only, occurrences, + // `at`, and the exit-2 error document. + const findingsOnly = JSON.parse( + `{"findings":[${(() => { + const out: string[] = []; + appendFindings(out, flat); + return out.join(""); + })()}]}`, + ) as unknown; + expect( + decodeFindingsReport(findingsOnly, "S-8 findings").findings, + ).toHaveLength(flat + 1); + expect( + decodeOccurrencesReport( + { findings: [], occurrences: fileView.occurrences }, + "S-8 occurrences", + ).occurrences, + ).toHaveLength(flat); + const at = { + findings: (findingsOnly as { findings: unknown[] }).findings, + resolution: { + section: { + identity: { unavailable: true }, + range: { start: 0, end: 10 }, + }, + occurrence: null, + }, + }; + expect(decodeAtReport(at, "S-8 at").findings).toHaveLength(flat + 1); + const error = decodeErrorDocument( + { error: (findingsOnly as { findings: unknown[] }).findings[0] }, + "S-8 error", + ); + expect(error.error.locations).toHaveLength(flat); +}, 120_000); + +// --------------------------------------------------------------------------- +// 3. The capture gate: the largest synthetic document through the H-2 path + +// S-3's stand-in mechanism: an argv-driven Node script written into a fresh +// TestWorkspace and driven through the same ProductBinding shape product +// invocations use. It streams a staged file to standard output in 64 KiB +// chunks and lets the event loop drain before exiting — never process.exit, +// which could drop a pipe's pending writes: that is the product-side defect +// a truncated capture is indistinguishable from (H-11), and the gate must +// know its stand-in emitted every byte. +const EMIT_SOURCE = `import { createReadStream } from "node:fs"; + +const [file] = process.argv.slice(2); +const source = createReadStream(file, { highWaterMark: 1 << 16 }); +source.on("error", (error) => { + process.stderr.write(String(error)); + process.exitCode = 1; +}); +source.pipe(process.stdout, { end: false }); +`; + +/** Write JSON text pieces to a file under back-pressure; the byte length. */ +async function writePieces( + path: string, + pieces: readonly string[], +): Promise<number> { + const stream = createWriteStream(path); + const failure = new Promise<never>((_, reject) => { + stream.once("error", reject); + }); + let bytes = 0; + for (const piece of pieces) { + bytes += Buffer.byteLength(piece, "utf8"); + if (!stream.write(piece)) { + await Promise.race([once(stream, "drain"), failure]); + } + } + await Promise.race([ + new Promise<void>((resolve) => { + stream.end(resolve); + }), + failure, + ]); + return bytes; +} + +/** Byte identity of the emitted file and the captured bytes, streamed. */ +async function assertCapturedFile( + path: string, + captured: Uint8Array, +): Promise<void> { + let offset = 0; + for await (const chunk of createReadStream(path, { + highWaterMark: 1 << 20, + })) { + const bytes = chunk as Buffer; + if (!bytes.equals(captured.subarray(offset, offset + bytes.length))) { + throw new Error( + `S-8: the captured stdout diverges from the emitted document at byte ${String(offset)} (H-2 capture; H-11)`, + ); + } + offset += bytes.length; + } + expect(offset).toBe(captured.length); +} + +/** + * The blowup tower's text members, JSON-escaped: level k's own text is its + * two U+2028 separators (the innermost: separator, content line, separator), + * its subtree text every level from k inward — built inward-out so each + * level's string is one concatenation, never a recursion. + */ +function blowupTowerText(depth: number): TowerText { + const own = (level: number): string => + level === depth + ? `${SEPARATOR_ESCAPED}deep.${SEPARATOR_ESCAPED}` + : `${SEPARATOR_ESCAPED}${SEPARATOR_ESCAPED}`; + const subtree: string[] = new Array<string>(depth + 1).fill(""); + subtree[depth] = own(depth); + for (let level = depth - 1; level >= 1; level -= 1) { + subtree[level] = own(level) + subtree[level + 1]!; + } + return { own, subtree: (level) => subtree[level]! }; +} + +/** Decoded characters of one tower's subtree text at level k. */ +const decodedSubtreeLength = (depth: number, level: number): number => + 2 * (depth - level) + 7; + +test("S-8: capture gate — the largest synthetic document (`view --text` blowup) captured complete and identical, then decoded", async () => { + const workspace = await TestWorkspace.create({ + files: { "emit.mjs": EMIT_SOURCE }, + }); + onTestFinished(() => workspace.dispose()); + const binding: ProductBinding = { + label: "S-8 stand-in", + command: process.execPath, + prefixArgs: [workspace.path("emit.mjs")], + }; + + const text = blowupTowerText(BLOWUP_DEPTH); + const documentPath = workspace.path("answer.json"); + const documentBytes = await writePieces( + documentPath, + buildViewDocument({ + towers: BLOWUP_TOWERS, + depth: BLOWUP_DEPTH, + text, + rootText: { own: "", subtree: text.subtree(1).repeat(BLOWUP_TOWERS) }, + identity: "string", + flat: 0, + }), + ); + // The document holds every level's re-emitted text: per tower + // Σ_k (12·(D − k) + 17) escaped characters, and it dwarfs the staged + // input it answers (S-8: "past its staged input's own size"). + const perTowerText = + (12 * BLOWUP_DEPTH * (BLOWUP_DEPTH - 1)) / 2 + 17 * BLOWUP_DEPTH; + expect(documentBytes).toBeGreaterThan(BLOWUP_TOWERS * perTowerText); + expect(documentBytes).toBeLessThan( + BLOWUP_TOWERS * perTowerText + 16 * 1024 * 1024, + ); + expect(documentBytes).toBeGreaterThan(500 * LARGEST_STAGED_INPUT_BYTES); + // The capture cap is dimensioned to this scale with headroom (H-11). + expect(DEFAULT_MAX_OUTPUT_BYTES).toBeGreaterThanOrEqual(2 * documentBytes); + + // A cap just below the document surfaces as the typed overflow error — + // loud, never a truncated capture (run first so its buffers are released + // before the complete capture below). + await expect( + runProduct(binding, { + cwd: workspace.root, + argv: ["answer.json"], + timeoutMs: 120_000, + maxOutputBytes: documentBytes - 1, + }), + ).rejects.toBeInstanceOf(ProductRunOutputOverflowError); + + // The complete capture through the H-2 path: complete, byte-identical. + const result = await runProduct(binding, { + cwd: workspace.root, + argv: ["answer.json"], + timeoutMs: 120_000, + }); + expect(result.signal).toBeNull(); + expect(result.exitCode).toBe(0); + expect(result.stderrBytes.length).toBe(0); + expect(result.stdoutBytes.length).toBe(documentBytes); + await assertCapturedFile(documentPath, result.stdoutBytes); + + // Evaluation from the capture itself (capture through evaluation): the + // stdout-to-document step product tests use, the form-exact decode, the + // marker and P-11 walks, and the text datum sizes the blowup implies. + const doc = parseJsonStdout(result, "S-8 capture"); + const view = decodeViewReport(doc, { text: true }, "S-8 blowup view --text"); + const root = view.views[0]!.root; + expect(countViewNodes(root)).toEqual({ + nodes: BLOWUP_TOWERS * BLOWUP_DEPTH + 1, + depth: BLOWUP_DEPTH, + }); + expect(root.children).toHaveLength(BLOWUP_TOWERS); + let reEmitted = 0; + const stack: ViewNode[] = [...root.children]; + while (stack.length > 0) { + const node = stack.pop()!; + expect(typeof node.subtreeText).toBe("string"); + reEmitted += (node.subtreeText as string).length; + stack.push(...node.children); + } + let expectedReEmitted = 0; + for (let level = 1; level <= BLOWUP_DEPTH; level += 1) { + expectedReEmitted += + BLOWUP_TOWERS * decodedSubtreeLength(BLOWUP_DEPTH, level); + } + expect(reEmitted).toBe(expectedReEmitted); + const outer = root.children[0]!; + expect(outer.subtreeText).toBe( + `${SEPARATOR.repeat(2 * BLOWUP_DEPTH - 1)}deep.${SEPARATOR}`, + ); + expect(innermost(outer).ownText).toBe(`${SEPARATOR}deep.${SEPARATOR}`); + assertUnavailabilityMarkerForms(doc, "S-8 blowup"); + expect(documentCarriesUnavailability(doc)).toBe(false); + const summary = decodeNodeTextSummary( + { ownText: root.ownText, subtreeText: root.subtreeText }, + "S-8 text summary", + ); + expect(summary.subtreeText).toHaveLength( + BLOWUP_TOWERS * decodedSubtreeLength(BLOWUP_DEPTH, 1), + ); +}, 600_000); diff --git a/test/suite/registry/section-10.2-10.3.ts b/test/suite/registry/section-10.2-10.3.ts index 23a68c9..233d107 100644 --- a/test/suite/registry/section-10.2-10.3.ts +++ b/test/suite/registry/section-10.2-10.3.ts @@ -249,7 +249,7 @@ function requireItem( * recursively — equality of information content where concrete member order * is shape territory (H-3/H-4). */ -function canonicalJson(value: unknown): string { +export function canonicalJson(value: unknown): string { // H-11: an explicit stack, never native recursion per nesting level. type Item = { readonly render: unknown } | { readonly text: string }; const pieces: string[] = []; @@ -306,7 +306,10 @@ function assertSameInformation( } /** Every string leaf of a decoded JSON value (array elements and members). */ -function collectStringLeaves(value: unknown, into: string[] = []): string[] { +export function collectStringLeaves( + value: unknown, + into: string[] = [], +): string[] { // H-11: an explicit stack, never native recursion per nesting level. const stack: unknown[] = [value]; while (stack.length > 0) { diff --git a/test/suite/registry/section-10.4.ts b/test/suite/registry/section-10.4.ts index fbf24b9..a194908 100644 --- a/test/suite/registry/section-10.4.ts +++ b/test/suite/registry/section-10.4.ts @@ -287,7 +287,10 @@ async function expectItemStatus( } /** Every string leaf of a decoded JSON value (array elements and members). */ -function collectStringLeaves(value: unknown, into: string[] = []): string[] { +export function collectStringLeaves( + value: unknown, + into: string[] = [], +): string[] { // H-11: an explicit stack, never native recursion per nesting level. const stack: unknown[] = [value]; while (stack.length > 0) { diff --git a/test/suite/registry/section-10.6.ts b/test/suite/registry/section-10.6.ts index 8686de4..13c7cff 100644 --- a/test/suite/registry/section-10.6.ts +++ b/test/suite/registry/section-10.6.ts @@ -296,7 +296,7 @@ function assertBlockedBy( * recursively — equality of information content where concrete member order * is shape territory (H-3/H-4). */ -function canonicalJson(value: unknown): string { +export function canonicalJson(value: unknown): string { // H-11: an explicit stack, never native recursion per nesting level. type Item = { readonly render: unknown } | { readonly text: string }; const pieces: string[] = []; @@ -353,7 +353,10 @@ function assertSameInformation( } /** Every string leaf of a decoded JSON value (array elements and members). */ -function collectStringLeaves(value: unknown, into: string[] = []): string[] { +export function collectStringLeaves( + value: unknown, + into: string[] = [], +): string[] { // H-11: an explicit stack, never native recursion per nesting level. const stack: unknown[] = [value]; while (stack.length > 0) { diff --git a/test/suite/registry/section-10.7-i.ts b/test/suite/registry/section-10.7-i.ts index 6c043c9..3746318 100644 --- a/test/suite/registry/section-10.7-i.ts +++ b/test/suite/registry/section-10.7-i.ts @@ -395,7 +395,7 @@ function assertStoredCounts( * recursively — equality of information content where concrete member order * is shape territory (H-3/H-4). */ -function canonicalJson(value: unknown): string { +export function canonicalJson(value: unknown): string { // H-11: an explicit stack, never native recursion per nesting level. type Item = { readonly render: unknown } | { readonly text: string }; const pieces: string[] = []; @@ -452,7 +452,10 @@ function assertSameInformation( } /** Every string leaf of a decoded JSON value (array elements and members). */ -function collectStringLeaves(value: unknown, into: string[] = []): string[] { +export function collectStringLeaves( + value: unknown, + into: string[] = [], +): string[] { // H-11: an explicit stack, never native recursion per nesting level. const stack: unknown[] = [value]; while (stack.length > 0) { diff --git a/test/suite/registry/section-10.7-ii.ts b/test/suite/registry/section-10.7-ii.ts index 7fba0ef..c73ad8a 100644 --- a/test/suite/registry/section-10.7-ii.ts +++ b/test/suite/registry/section-10.7-ii.ts @@ -459,7 +459,7 @@ function assertBlockedBy( * recursively — equality of information content where concrete member order * is shape territory (H-3/H-4). */ -function canonicalJson(value: unknown): string { +export function canonicalJson(value: unknown): string { // H-11: an explicit stack, never native recursion per nesting level. type Item = { readonly render: unknown } | { readonly text: string }; const pieces: string[] = []; @@ -516,7 +516,10 @@ function assertSameInformation( } /** Every string leaf of a decoded JSON value (array elements and members). */ -function collectStringLeaves(value: unknown, into: string[] = []): string[] { +export function collectStringLeaves( + value: unknown, + into: string[] = [], +): string[] { // H-11: an explicit stack, never native recursion per nesting level. const stack: unknown[] = [value]; while (stack.length > 0) { diff --git a/test/suite/registry/section-12.0-i.ts b/test/suite/registry/section-12.0-i.ts index aadb793..1b7722c 100644 --- a/test/suite/registry/section-12.0-i.ts +++ b/test/suite/registry/section-12.0-i.ts @@ -404,7 +404,7 @@ interface SweepStoryOptions { * mapped element-wise, never reordered — array order stays significant; * object key order is formatting, not information (TEST-SPEC §11). */ -function canonicalizeJson(value: unknown): unknown { +export function canonicalizeJson(value: unknown): unknown { // H-11: an explicit stack, never native recursion per nesting level. type Container = unknown[] | Record<string, unknown>; const isContainer = (candidate: unknown): candidate is Container => diff --git a/test/suite/registry/section-16-p11.ts b/test/suite/registry/section-16-p11.ts index ac51774..c16ee82 100644 --- a/test/suite/registry/section-16-p11.ts +++ b/test/suite/registry/section-16-p11.ts @@ -102,7 +102,11 @@ import { runProduct, } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import { drawFuzzMutation, FUZZ_BASE_FILES } from "./section-16-p8.js"; +import { + drawFuzzMutation, + FUZZ_BASE_FILES, + MAX_MUTATIONS_PER_TRIAL, +} from "./section-16-p8.js"; // --------------------------------------------------------------------------- // The mutable surface: the spec and code sources of the shared fuzz base @@ -366,7 +370,8 @@ export const genAvailabilityTrial: Gen<AvailabilityTrial> = (choices) => { ]), ); const mutations: string[] = []; - const mutationCount = 1 + choices.intInclusive(0, 2); + const mutationCount = + 1 + choices.intInclusive(0, MAX_MUTATIONS_PER_TRIAL - 1); for (let i = 0; i < mutationCount; i += 1) { const path = choices.pick(MUTATION_TARGETS); const current = files.get(path); @@ -449,9 +454,10 @@ async function runAvailabilityCommand( * form decode has already run `assertUnavailabilityMarkerForms` over the * whole document (adapters/forms.ts), so every object spelling a member * named `unavailable` is exactly the marker `{"unavailable": true}` - * (SPEC 12.7) — presence of the member is presence of the marker. + * (SPEC 12.7) — presence of the member is presence of the marker. Exported + * for S-8, which drives this walk at the suite's staged answer scale. */ -function documentCarriesUnavailability(value: unknown): boolean { +export function documentCarriesUnavailability(value: unknown): boolean { // H-11: an explicit stack, never native recursion per nesting level — the // fuzzed `view` answers carry the depth-2048 and depth-4096 section towers // the suite stages (P-8, P-11), past V8's frame budget; no depth cap. diff --git a/test/suite/registry/section-16-p8.ts b/test/suite/registry/section-16-p8.ts index 13662d1..1d06fae 100644 --- a/test/suite/registry/section-16-p8.ts +++ b/test/suite/registry/section-16-p8.ts @@ -269,7 +269,7 @@ const UTF16LE_BOM: readonly number[] = [0xff, 0xfe]; const UTF16BE_BOM: readonly number[] = [0xfe, 0xff]; /** Line-terminator sequences LF is rewritten to / runs are built from. */ -const TERMINATOR_SEQUENCES: ReadonlyArray< +export const TERMINATOR_SEQUENCES: ReadonlyArray< readonly [string, readonly number[]] > = [ ["CR", [0x0d]], @@ -282,8 +282,22 @@ const TERMINATOR_SEQUENCES: ReadonlyArray< ]; // Every nesting draw is genuinely giant (P-8 "giant nesting"); the smallest -// entry first so counterexamples shrink toward the shallowest tower. -const NESTING_DEPTHS: readonly number[] = [512, 2048, 4096]; +// entry first so counterexamples shrink toward the shallowest tower. Exported +// (with `sectionTowerSource` and `MAX_MUTATIONS_PER_TRIAL`) so S-8's capacity +// gate derives the suite's staged maxima from the generator itself (H-11). +export const NESTING_DEPTHS: readonly number[] = [512, 2048, 4096]; + +/** + * The MDX section tower a nesting mutation stages: `depth` nested `<S id="g">` + * openers, balanced ones closing around one content line, unclosed ones left + * open (an unparseable file). Byte-exact — S-2/S-8 stage and size the same + * tower the fuzz draws stage. + */ +export function sectionTowerSource(depth: number, balanced: boolean): string { + return balanced + ? `${'<S id="g">\n'.repeat(depth)}deep.\n${"</S>\n".repeat(depth)}` + : '<S id="g">\n'.repeat(depth); +} function spliceBytes( bytes: Uint8Array, @@ -400,9 +414,7 @@ function mutateNesting( let shape: string; if (path.endsWith(".mdx")) { shape = balanced ? "balanced section tower" : "unclosed section tower"; - tower = balanced - ? `${'<S id="g">\n'.repeat(depth)}deep.\n${"</S>\n".repeat(depth)}` - : '<S id="g">\n'.repeat(depth); + tower = sectionTowerSource(depth, balanced); } else { shape = balanced ? "balanced parenthesis tower" @@ -506,6 +518,9 @@ export interface FuzzTrial { readonly commands: ReadonlyArray<readonly string[]>; } +/** Mutations applied per trial: 1 + a draw in [0, MAX_MUTATIONS_PER_TRIAL - 1]. */ +export const MAX_MUTATIONS_PER_TRIAL = 3; + /** The P-8 trial generator (see the module header). */ export const genFuzzTrial: Gen<FuzzTrial> = (choices) => { const files = new Map<string, Uint8Array>( @@ -515,7 +530,8 @@ export const genFuzzTrial: Gen<FuzzTrial> = (choices) => { ]), ); const mutations: string[] = []; - const mutationCount = 1 + choices.intInclusive(0, 2); + const mutationCount = + 1 + choices.intInclusive(0, MAX_MUTATIONS_PER_TRIAL - 1); for (let i = 0; i < mutationCount; i += 1) { const path = choices.pick(MUTATION_TARGETS); const current = files.get(path); From fd7216b978b0a7a3693818ea65d8b79c6edf5526 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 06:04:55 +0000 Subject: [PATCH 192/226] =?UTF-8?q?sdg(phase-9):=20add=20the=20S-2=20scale?= =?UTF-8?q?=20vectors=20=E2=80=94=20the=204096-deep=20tower=20and=20the=20?= =?UTF-8?q?196,830-byte=20largest=20staged=20document=20through=20the=20bu?= =?UTF-8?q?ilder,=20read=20back=20byte-complete;=20the=20staged-maxima=20d?= =?UTF-8?q?erivation=20shared=20with=20S-8=20in=20staged-scale.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 8 of the Phase 9 plan. TEST-SPEC §17 S-2 requires the workspace builder self-test to stage the suite's maxima — a document nested at least at P-8's giant-nesting floor and one at the largest document size the suite stages — and read each back byte-complete, so a truncating writer or recursion-limited serializer cannot silently stage shallower or smaller inputs than declared. - `test/self/staged-scale.ts` (new): S-8's derivation of the staged maxima moved out of the S-8 test file into a shared, non-test module — the generator constants (NESTING_DEPTHS, MAX_MUTATIONS_PER_TRIAL, TERMINATOR_SEQUENCES, sectionTowerSource) derived into DEEPEST_STAGED_TOWER (4096), TOWER_BYTES (65,542), LARGEST_BASE_FILE (`specs/A.mdx`, 204 bytes), LARGEST_STAGED_INPUT_BYTES (196,830) and `largestStagedDocument()` (the base plus the whole mutation budget of appended towers, built by concatenation, never recursion). - S-2 (`test/self/s2-workspace-builder.test.ts`): two new vectors. (a) one `.mdx` file that is the depth-4096 balanced tower P-8 stages, declared through `create`; (b) the largest staged document, staged exactly as P-8's driver stages a trial (base workspace declared, mutated bytes written over `specs/A.mdx` through `file`). Each is read back from disk with plain `fs` and compared whole (first-mismatch diagnosis), its declared byte length asserted, its opener/closer/content-line counts recounted from the bytes (still 4096 deep — three towers for (b)), and the builder's listing checked to report the file once, as a regular file, at the full size; (b) also verifies the base's other entries stayed byte-identical. - S-8: imports the shared module (imports it no longer needs dropped) and additionally asserts the bound is attained — the largest base is an `.mdx` (so the appended tower is the section tower the mix is sized with) and `largestStagedDocument()` is exactly LARGEST_STAGED_INPUT_BYTES. - AGENTS.md: the S-8 note becomes the staged-scale note (S-2 and S-8 share `staged-scale.ts`; the exact-size pins move only with a generator bound). Verification: `npm run typecheck` clean; `npm run format:check` clean; `npm run test:self` 17 files, 337/337 passed (335 before + the 2 vectors); S-2 and S-8 run alone: 18/18. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 2 +- specs/tmp/FIX_PLAN.md | 26 ---- test/self/s2-workspace-builder.test.ts | 133 ++++++++++++++++++++- test/self/s8-answer-scale-capacity.test.ts | 101 +++++----------- test/self/staged-scale.ts | 119 ++++++++++++++++++ 5 files changed, 281 insertions(+), 100 deletions(-) create mode 100644 test/self/staged-scale.ts diff --git a/AGENTS.md b/AGENTS.md index 2d35620..1ede85a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,4 +20,4 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Format code (Prettier, default config, `src/` and `test/` only): `npm run format`; verify: `npm run format:check`. - CI: `.github/workflows/ci.yml` — harness-self (Linux), full suite (Linux, network disabled after setup via `.github/scripts/run-without-network.sh`), and the Windows E-6 leg on every pull request. The `e6-linux-outputs` upload step of the full-suite job has no `if: always()`, so it is skipped whenever any Linux test fails (the run ends with no artifact): the Windows byte-identity test then fails loudly on the missing manifest until suite-linux is green — that arm's verdict is meaningful only on a green Linux run. - Answer-document scale (TEST-SPEC H-11): the suite stages section towers 4096 deep (`NESTING_DEPTHS` in `test/suite/registry/section-16-p8.ts`), past V8's limits in this Node — a plain recursive function gets ~9.9k frames, and `JSON.stringify`/`structuredClone` throw `RangeError` at that nesting while `JSON.parse` is iterative. Harness walks over answer documents therefore use explicit stacks (`decodeViewNodeForm`, `assertUnavailabilityMarkerForms`, `describeJsonValue`'s fallback, and `query.ts`'s `walkForRangeData` and `decodeIdsTreeNode` — `ids --tree` nests one node per section level — in `test/helpers/adapters/`, and the registry modules' own generic JSON walkers, `canonicalJson`, `collectStringLeaves` and `canonicalizeJson` in `test/suite/registry/section-10.*.ts` and `section-12.0-i.ts`); a property reporting `RangeError: Maximum call stack size exceeded` as a harness error names a walk still recursing per level — its `Caused by` frames locate it. To drive a decoder at depth by hand, a temporary self-test (see above) building the document in a loop is enough: 200k-deep `ids --tree` and edge documents decode in well under a second. A walker its module does not export can be exercised the same way as a scratch `.ts` twin of it run under `node --experimental-strip-types` (Node 22). -- Answer-scale capacity gate (TEST-SPEC S-8): `test/self/s8-answer-scale-capacity.test.ts` derives the suite's staged maxima from the generators themselves — `NESTING_DEPTHS`, `MAX_MUTATIONS_PER_TRIAL`, `TERMINATOR_SEQUENCES`, and the byte-exact tower builder `sectionTowerSource(depth, balanced)` exported by `test/suite/registry/section-16-p8.ts`, P-2/P-3's `specSubtreeTexts` oracle — replays the fixed CI seed set through `drawFixedSeedTrials(generator, runs)` (`test/helpers/property.ts`; the same per-seed PRNG stream `checkProperty` uses, no property body) to confirm the staged draws stay inside them, and drives every H-3/12.7 decoder and answer-document walk on synthetic documents at that scale: a `view` nested 8192 deep (two depth-4096 towers nested by a shuffle mutation) and the `view --text` blowup — two depth-4096 towers whose LF → U+2028 rewrite leaves every level's separators as content, ~204 MB in the `\u2028` spelling. The blowup document is written to a workspace file and streamed to stdout by a stand-in script through `runProduct`, so the test costs ~10 s and holds roughly 0.6–0.8 GB in the Vitest worker (the captured bytes, their UTF-8 string, and the parsed document coexist); run it alone with `npx vitest run --config test/vitest.config.ts --project self test/self/s8-answer-scale-capacity.test.ts`. `DEFAULT_MAX_OUTPUT_BYTES` in `test/helpers/subprocess.ts` (512 MiB) is gated by that test at no less than twice the blowup document — lower it or grow a generator's scale and S-8 fails, by design; memory for the cap is committed only as output arrives. +- Staged-scale gates (TEST-SPEC S-2, S-8): `test/self/staged-scale.ts` derives the suite's staged input maxima from the generators themselves — `NESTING_DEPTHS`, `MAX_MUTATIONS_PER_TRIAL`, `TERMINATOR_SEQUENCES`, and the byte-exact tower builder `sectionTowerSource(depth, balanced)` exported by `test/suite/registry/section-16-p8.ts` — into `DEEPEST_STAGED_TOWER` (4096), `TOWER_BYTES`, and `LARGEST_STAGED_INPUT_BYTES` (196,830 bytes: `specs/A.mdx` plus the whole mutation budget of appended towers, built by `largestStagedDocument()`); `test/self/s2-workspace-builder.test.ts` stages both maxima through the workspace builder and reads them back byte-complete (the input side; its exact-size pins move only when a generator bound does — update them deliberately), and `test/self/s8-answer-scale-capacity.test.ts` (the answer side, with P-2/P-3's `specSubtreeTexts` oracle) replays the fixed CI seed set through `drawFixedSeedTrials(generator, runs)` (`test/helpers/property.ts`; the same per-seed PRNG stream `checkProperty` uses, no property body) to confirm the staged draws stay inside them, and drives every H-3/12.7 decoder and answer-document walk on synthetic documents at that scale: a `view` nested 8192 deep (two depth-4096 towers nested by a shuffle mutation) and the `view --text` blowup — two depth-4096 towers whose LF → U+2028 rewrite leaves every level's separators as content, ~204 MB in the `\u2028` spelling. The blowup document is written to a workspace file and streamed to stdout by a stand-in script through `runProduct`, so the test costs ~10 s and holds roughly 0.6–0.8 GB in the Vitest worker (the captured bytes, their UTF-8 string, and the parsed document coexist); run it alone with `npx vitest run --config test/vitest.config.ts --project self test/self/s8-answer-scale-capacity.test.ts`. `DEFAULT_MAX_OUTPUT_BYTES` in `test/helpers/subprocess.ts` (512 MiB) is gated by that test at no less than twice the blowup document — lower it or grow a generator's scale and S-8 fails, by design; memory for the cap is committed only as output arrives. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 95cc44c..41fe9c8 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,32 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage B — H-11 answer-scale capacity (P-11 harness error), S-8, S-2, T1.3-7 -### Task 8 — S-2 scale vectors: ≥ 2048-deep document and the largest staged document, read back byte-complete - -Cites: TEST-SPEC §17 S-2 ("with scale vectors at the suite's staged maxima — a -document nested at least at P-8's giant-nesting floor and one at the largest -document size the suite stages (deterministic fixtures and generator draws -alike, 16), each read back byte-complete — so a truncating writer or -recursion-limited serializer cannot silently stage shallower or smaller inputs -than declared"); §16 P-8. - -Now: `test/self/s2-workspace-builder.test.ts` covers newline kinds, BOMs, -invalid UTF-8, tree fidelity, scripted git, root escape, dispose — no nesting or -size vector. - -Do: add two vectors to that file: (a) a workspace whose one `.mdx` file nests -sections 4096 levels deep (build the bytes iteratively; reuse or mirror the -generator P-8 uses so the vector is the tower the suite actually stages), and -(b) a file at the largest document size the suite stages (state the derivation -in a comment: the max over deterministic fixtures and generator size bounds, -sharing Task 7's derivation if it landed — otherwise compute it here and let -Task 7 reuse it). Write each through `test/helpers/workspace.ts`'s builder and -read it back from disk with plain `fs`, asserting byte-for-byte equality and -the declared byte length; assert the builder's own file/tree listing reports the -file once with the full size. No recursion in the vector construction. - -Verify: `npm run test:self` green; `npm run typecheck`. - ### Task 9 — T1.3-7 Depth: deterministic ≥ 2048-deep workspace (new test) Cites: TEST-SPEC T1.3-7 ("a valid workspace whose one file nests sections at diff --git a/test/self/s2-workspace-builder.test.ts b/test/self/s2-workspace-builder.test.ts index 499e89d..f51f8bb 100644 --- a/test/self/s2-workspace-builder.test.ts +++ b/test/self/s2-workspace-builder.test.ts @@ -4,7 +4,12 @@ // (contents, and byte-string file names on Linux — T1.5-2 staging), symbolic // links (verbatim targets: live, dangling, directory, cyclic, external — // T7-5, T13.4-6), and git fixtures with scripted commits carrying pinned, -// platform-independent identities and timestamps (E-6). Certification cannot +// platform-independent identities and timestamps (E-6) — and scale vectors +// at the suite's staged maxima (`staged-scale.ts`, shared with S-8): the +// 4096-deep section tower P-8's giant-nesting draws stage and the largest +// document any draw stages, each read back byte-complete, so a truncating +// writer or recursion-limited serializer cannot silently stage shallower or +// smaller inputs than declared (H-11's input side). Certification cannot // exercise builder bugs that make fixtures diverge from their declarations, // so this self-test must pass before any fixture is trusted. // @@ -26,6 +31,15 @@ import { TestWorkspace, } from "../helpers/workspace.js"; import type { WorkspaceDecl } from "../helpers/workspace.js"; +import { FUZZ_BASE_FILES } from "../suite/registry/section-16-p8.js"; +import { + DEEPEST_STAGED_TOWER, + GIANT_NESTING_FLOOR, + LARGEST_BASE_FILE, + LARGEST_STAGED_INPUT_BYTES, + largestStagedDocument, + TOWER_SOURCE, +} from "./staged-scale.js"; const onLinux = process.platform === "linux"; const onPosix = process.platform !== "win32"; @@ -381,3 +395,120 @@ test("dispose() removes the workspace entirely, read-only git objects included", code: "ENOENT", }); }); + +// --------------------------------------------------------------------------- +// Scale vectors (S-2): the suite's staged maxima, read back byte-complete. +// Both vectors are built by string repetition and buffer concatenation — +// never by recursion — and compared as whole byte arrays read back from disk +// through plain `fs`, independently of the builder's own readers. + +/** Whole-array comparison with a diagnosable first-mismatch report. */ +function expectByteComplete(actual: Uint8Array, expected: Uint8Array): void { + expect(actual.length).toBe(expected.length); + let mismatch = -1; + for (let index = 0; index < expected.length; index += 1) { + if (actual[index] !== expected[index]) { + mismatch = index; + break; + } + } + expect(mismatch, "first differing byte offset (-1 = identical)").toBe(-1); +} + +/** Occurrences of `needle` in `haystack`, scanned iteratively. */ +function countOccurrences(haystack: Uint8Array, needle: string): number { + const buffer = Buffer.from( + haystack.buffer, + haystack.byteOffset, + haystack.length, + ); + let count = 0; + for ( + let index = buffer.indexOf(needle, 0, "utf8"); + index >= 0; + index = buffer.indexOf(needle, index + 1, "utf8") + ) { + count += 1; + } + return count; +} + +test("scale vector: one `.mdx` file nesting sections 4096 deep — P-8's staged tower, read back byte-complete", async () => { + // The tower the suite stages (sectionTowerSource(4096, balanced), the + // bytes a P-8 nesting draw appends): 11 bytes per opener line, one + // six-byte content line, 5 bytes per closer line. + const expected = utf8(TOWER_SOURCE); + expect(DEEPEST_STAGED_TOWER).toBe(4096); + expect(DEEPEST_STAGED_TOWER).toBeGreaterThanOrEqual(GIANT_NESTING_FLOOR); + expect(expected.length).toBe( + DEEPEST_STAGED_TOWER * 11 + 6 + DEEPEST_STAGED_TOWER * 5, + ); + + // Declared through the builder's declarative path (create → file). + const workspace = await makeWorkspace({ + files: { "specs/tower.mdx": TOWER_SOURCE }, + }); + + // The builder's own listing reports the file once, as a regular file, at + // the full declared size. + expect(await workspace.readdirNames()).toEqual(["specs"]); + expect(await workspace.readdirNames("specs")).toEqual(["tower.mdx"]); + expect(await workspace.kind("specs/tower.mdx")).toBe("file"); + const abs = path.join(workspace.root, "specs", "tower.mdx"); + expect((await fsp.stat(abs)).size).toBe(expected.length); + + // Read back from disk with plain fs: byte-complete, and still 4096 levels + // deep — every opener and closer present, so the staged depth is the + // declared depth, at or past P-8's floor. + const actual = new Uint8Array(await fsp.readFile(abs)); + expectByteComplete(actual, expected); + expect(countOccurrences(actual, '<S id="g">\n')).toBe(DEEPEST_STAGED_TOWER); + expect(countOccurrences(actual, "</S>\n")).toBe(DEEPEST_STAGED_TOWER); + expect(countOccurrences(actual, "deep.\n")).toBe(1); +}); + +test("scale vector: the largest document the suite stages (fuzz base plus the whole mutation budget of towers), read back byte-complete", async () => { + // Derivation (staged-scale.ts, shared with S-8): the largest staged file + // is the largest fuzz base file (`specs/A.mdx`) with all three mutations + // of P-8's budget appending the deepest balanced tower — 204 + 3 × 65 542 + // = 196 830 bytes; every deterministic fixture is far smaller (the + // largest, T4.1's own-text probe, ~33 KiB) and so is every P-2/P-3, P-4, + // and P-9 draw. Staged exactly as P-8's driver stages a trial: the base + // workspace declared, then the mutated bytes written over the base file + // through `file` — the imperative path. + const [basePath] = LARGEST_BASE_FILE; + const expected = largestStagedDocument(); + expect(expected.length).toBe(LARGEST_STAGED_INPUT_BYTES); + expect(LARGEST_STAGED_INPUT_BYTES).toBe(196_830); + expect(basePath).toBe("specs/A.mdx"); + + const workspace = await makeWorkspace({ + files: Object.fromEntries(FUZZ_BASE_FILES), + }); + await workspace.file(basePath, expected); + + // The builder's own listing reports the file once, as a regular file, at + // the full declared size — the base workspace's other entries untouched. + expect(await workspace.readdirNames("specs")).toEqual(["A.mdx", "B.mdx"]); + expect(await workspace.kind(basePath)).toBe("file"); + const abs = path.join(workspace.root, ...basePath.split("/")); + expect((await fsp.stat(abs)).size).toBe(LARGEST_STAGED_INPUT_BYTES); + + // Read back from disk with plain fs, byte-complete: the base text intact + // at the front, all three towers behind it. + const actual = new Uint8Array(await fsp.readFile(abs)); + expectByteComplete(actual, expected); + expectSameBytes( + actual.subarray(0, Buffer.byteLength(LARGEST_BASE_FILE[1], "utf8")), + utf8(LARGEST_BASE_FILE[1]), + ); + expect(countOccurrences(actual, '<S id="g">\n')).toBe( + 3 * DEEPEST_STAGED_TOWER, + ); + expect(countOccurrences(actual, "deep.\n")).toBe(3); + for (const [rel, text] of FUZZ_BASE_FILES) { + if (rel !== basePath) { + expectSameBytes(await workspace.readBytes(rel), utf8(text)); + } + } +}); diff --git a/test/self/s8-answer-scale-capacity.test.ts b/test/self/s8-answer-scale-capacity.test.ts index 3ca40bd..6ab9e7f 100644 --- a/test/self/s8-answer-scale-capacity.test.ts +++ b/test/self/s8-answer-scale-capacity.test.ts @@ -10,8 +10,10 @@ // // 1. the scale is DERIVED from the suite's own generators — P-8/P-11's // towers and mutation budget, P-2/P-3's expansion oracle — never -// assumed; the constants below state the derivation, and the fixed CI -// seed set (E-5) is replayed to confirm the staged draws stay inside it; +// assumed; `staged-scale.ts` states the derivation (shared with S-2, +// which stages the same maxima through the workspace builder), and the +// fixed CI seed set (E-5) is replayed to confirm the staged draws stay +// inside it; // 2. synthetic conforming-form documents at that scale are built // iteratively (never by recursion — `JSON.stringify` itself overflows // at these depths) and driven through every H-3/12.7 decoder and every @@ -86,85 +88,35 @@ import { specSubtreeTexts, } from "../suite/registry/section-16-p2-p3.js"; import { - FUZZ_BASE_FILES, genFuzzTrial, MAX_MUTATIONS_PER_TRIAL, - NESTING_DEPTHS, - sectionTowerSource, - TERMINATOR_SEQUENCES, } from "../suite/registry/section-16-p8.js"; +import { + DEEPEST_STAGED_TOWER, + FATTEST_TERMINATOR, + GIANT_NESTING_FLOOR, + LARGEST_BASE_BYTES, + LARGEST_BASE_FILE, + LARGEST_STAGED_INPUT_BYTES, + largestStagedDocument, + TOWER_BYTES, +} from "./staged-scale.js"; // --------------------------------------------------------------------------- // 1. The scale the suite stages — derived from the generators, not assumed // -// Nesting. P-8's giant-nesting draws stage balanced towers `<S id="g">` × -// depth (NESTING_DEPTHS; the deepest 4096, the floor 2048 that T1.3-7 -// anchors). A trial applies up to MAX_MUTATIONS_PER_TRIAL mutations to the -// same file, and a shuffle mutation relocates one contiguous byte range, so -// tower + tower + shuffle can drop the second tower into the first's -// innermost level: the deepest section chain any P-8/P-11 draw can stage is -// 2 × 4096 = 8192 (a third tower would need a fourth mutation). Every `view` -// and `ids --tree` answer over such an input nests one node per level. -const GIANT_NESTING_FLOOR = 2048; -const DEEPEST_STAGED_TOWER = Math.max(...NESTING_DEPTHS); +// Nesting and document size are derived once, in `staged-scale.ts` +// (GIANT_NESTING_FLOOR, DEEPEST_STAGED_TOWER, TOWER_BYTES, +// LARGEST_STAGED_INPUT_BYTES and their derivation comments) — the same +// constants S-2 stages through the workspace builder. A trial applies up to +// MAX_MUTATIONS_PER_TRIAL mutations to the same file, and a shuffle mutation +// relocates one contiguous byte range, so tower + tower + shuffle can drop +// the second tower into the first's innermost level: the deepest section +// chain any P-8/P-11 draw can stage is 2 × 4096 = 8192 (a third tower would +// need a fourth mutation). Every `view` and `ids --tree` answer over such an +// input nests one node per level. const SYNTHETIC_DEPTH = 2 * DEEPEST_STAGED_TOWER; -// Document size. The largest staged file is the largest fuzz base file with -// every mutation of the budget appending the deepest balanced tower. The -// competing growth is a terminator rewrite (every LF → the fattest sequence -// of TERMINATOR_SEQUENCES, U+2028 at three bytes): `towers` towers plus -// `rewrites` rewrites grow each line feed to at most 2^(rewrites − 1) × 3 -// bytes (LFLF doublings, then the fattest sequence), and every such mix is -// computed below — the all-towers mix wins. Splices (≤ 8 bytes), garbage -// (≤ 64), BOMs (≤ 3), and terminator runs (≤ 64 × 3) are smaller than any -// tower; truncate and shuffle never grow a file. P-2/P-3 documents (≤ 3 -// files × ≤ 6 sections of single-line constructs), P-4/P-9's (≤ 3 sections -// per file, prose runs ≤ 8 characters), and every deterministic fixture -// (the largest: T4.1's ~33 KiB own-text probe) are far smaller. -const TOWER_SOURCE = sectionTowerSource(DEEPEST_STAGED_TOWER, true); -const TOWER_BYTES = Buffer.byteLength(TOWER_SOURCE, "utf8"); -const FATTEST_TERMINATOR = Math.max( - ...TERMINATOR_SEQUENCES.map(([, sequence]) => sequence.length), -); - -function countLineFeeds(text: string): number { - let count = 0; - for ( - let index = text.indexOf("\n"); - index >= 0; - index = text.indexOf("\n", index + 1) - ) { - count += 1; - } - return count; -} - -/** Every tower/rewrite mix of the mutation budget over one base file. */ -function stagedSizeCandidates(base: string): number[] { - const bytes = Buffer.byteLength(base, "utf8"); - const feeds = countLineFeeds(base); - const towerFeeds = countLineFeeds(TOWER_SOURCE); - const candidates: number[] = []; - for (let towers = 0; towers <= MAX_MUTATIONS_PER_TRIAL; towers += 1) { - const rewrites = MAX_MUTATIONS_PER_TRIAL - towers; - const bytesPerFeed = - rewrites === 0 ? 1 : 2 ** (rewrites - 1) * FATTEST_TERMINATOR; - candidates.push( - bytes + - towers * TOWER_BYTES + - (feeds + towers * towerFeeds) * (bytesPerFeed - 1), - ); - } - return candidates; -} - -const LARGEST_BASE_BYTES = Math.max( - ...FUZZ_BASE_FILES.map(([, text]) => Buffer.byteLength(text, "utf8")), -); -const LARGEST_STAGED_INPUT_BYTES = Math.max( - ...FUZZ_BASE_FILES.flatMap(([, text]) => stagedSizeCandidates(text)), -); - // Expansion blowup. SPEC 3 defines a line terminator as CRLF, a lone LF, or // a lone CR — nothing else — so after P-8's LF → U+2028 rewrite a tower's // tags no longer stand on lines of their own: no line is dropped, and every @@ -207,6 +159,11 @@ test("S-8: the derived scale — deepest chain, largest staged input, blowup inp ); expect(LARGEST_STAGED_INPUT_BYTES).toBeGreaterThan(190_000); expect(LARGEST_STAGED_INPUT_BYTES).toBeLessThan(200_000); + // Attained, not merely bounded: the largest base is an `.mdx` file, so a + // nesting draw over it appends the section tower the mix is sized with, + // and the document S-2 stages is exactly that mix. + expect(LARGEST_BASE_FILE[0].endsWith(".mdx")).toBe(true); + expect(largestStagedDocument().length).toBe(LARGEST_STAGED_INPUT_BYTES); }); test("S-8: the fixed CI seed set stages within the derived scale (E-5 replay)", () => { diff --git a/test/self/staged-scale.ts b/test/self/staged-scale.ts new file mode 100644 index 0000000..ec4ac46 --- /dev/null +++ b/test/self/staged-scale.ts @@ -0,0 +1,119 @@ +// The suite's staged input maxima, derived from the generators themselves +// (TEST-SPEC 17 S-2 and S-8; §0 H-11): the deepest section tower P-8's +// giant-nesting draws stage and the largest document any staged draw or +// deterministic fixture reaches. S-2 stages both through the workspace +// builder and reads them back byte-complete (the input side — a truncating +// writer or recursion-limited serializer cannot stage shallower or smaller +// inputs than declared); S-8 sizes every decoder, walk, and capture limit +// against the answers a conforming product may emit over them (the answer +// side). One derivation, so neither gate can drift from what the suite +// actually stages, and a grown generator bound moves both. + +import { Buffer } from "node:buffer"; +import { + FUZZ_BASE_FILES, + MAX_MUTATIONS_PER_TRIAL, + NESTING_DEPTHS, + sectionTowerSource, + TERMINATOR_SEQUENCES, +} from "../suite/registry/section-16-p8.js"; + +// Nesting. P-8's giant-nesting draws stage balanced towers `<S id="g">` × +// depth (NESTING_DEPTHS; the deepest 4096, the floor 2048 that T1.3-7 +// anchors). A trial applies up to MAX_MUTATIONS_PER_TRIAL mutations to the +// same file, and a shuffle mutation relocates one contiguous byte range, so +// tower + tower + shuffle can drop the second tower into the first's +// innermost level: the deepest section chain any P-8/P-11 draw can stage is +// 2 × 4096 = 8192 (a third tower would need a fourth mutation). Every `view` +// and `ids --tree` answer over such an input nests one node per level. +/** P-8's test-strength floor on staged nesting (TEST-SPEC §16 P-8). */ +export const GIANT_NESTING_FLOOR = 2048; +/** The deepest tower any nesting draw stages. */ +export const DEEPEST_STAGED_TOWER = Math.max(...NESTING_DEPTHS); + +// Document size. The largest staged file is the largest fuzz base file with +// every mutation of the budget appending the deepest balanced tower. The +// competing growth is a terminator rewrite (every LF → the fattest sequence +// of TERMINATOR_SEQUENCES, U+2028 at three bytes): `towers` towers plus +// `rewrites` rewrites grow each line feed to at most 2^(rewrites − 1) × 3 +// bytes (LFLF doublings, then the fattest sequence), and every such mix is +// computed below — the all-towers mix wins. Splices (≤ 8 bytes), garbage +// (≤ 64), BOMs (≤ 3), and terminator runs (≤ 64 × 3) are smaller than any +// tower; truncate and shuffle never grow a file. P-2/P-3 documents (≤ 3 +// files × ≤ 6 sections of single-line constructs), P-4/P-9's (≤ 3 sections +// per file, prose runs ≤ 8 characters), and every deterministic fixture +// (the largest: T4.1's ~33 KiB own-text probe) are far smaller. +/** + * The deepest tower the suite stages, byte for byte — what a nesting draw + * over an `.mdx` file appends (`mutateNesting`, section-16-p8.ts). + */ +export const TOWER_SOURCE = sectionTowerSource(DEEPEST_STAGED_TOWER, true); +export const TOWER_BYTES = Buffer.byteLength(TOWER_SOURCE, "utf8"); +export const FATTEST_TERMINATOR = Math.max( + ...TERMINATOR_SEQUENCES.map(([, sequence]) => sequence.length), +); + +export function countLineFeeds(text: string): number { + let count = 0; + for ( + let index = text.indexOf("\n"); + index >= 0; + index = text.indexOf("\n", index + 1) + ) { + count += 1; + } + return count; +} + +/** Every tower/rewrite mix of the mutation budget over one base file. */ +export function stagedSizeCandidates(base: string): number[] { + const bytes = Buffer.byteLength(base, "utf8"); + const feeds = countLineFeeds(base); + const towerFeeds = countLineFeeds(TOWER_SOURCE); + const candidates: number[] = []; + for (let towers = 0; towers <= MAX_MUTATIONS_PER_TRIAL; towers += 1) { + const rewrites = MAX_MUTATIONS_PER_TRIAL - towers; + const bytesPerFeed = + rewrites === 0 ? 1 : 2 ** (rewrites - 1) * FATTEST_TERMINATOR; + candidates.push( + bytes + + towers * TOWER_BYTES + + (feeds + towers * towerFeeds) * (bytesPerFeed - 1), + ); + } + return candidates; +} + +/** The largest fuzz base file (path, text); a tie resolves to the first. */ +export const LARGEST_BASE_FILE: readonly [string, string] = + FUZZ_BASE_FILES.reduce((largest, candidate) => + Buffer.byteLength(candidate[1], "utf8") > + Buffer.byteLength(largest[1], "utf8") + ? candidate + : largest, + ); +export const LARGEST_BASE_BYTES = Buffer.byteLength( + LARGEST_BASE_FILE[1], + "utf8", +); +export const LARGEST_STAGED_INPUT_BYTES = Math.max( + ...FUZZ_BASE_FILES.flatMap(([, text]) => stagedSizeCandidates(text)), +); + +/** + * The largest document the suite stages, byte for byte: the largest base + * file with the whole mutation budget spent on appended deepest towers — + * exactly what MAX_MUTATIONS_PER_TRIAL nesting draws over it (depth + * DEEPEST_STAGED_TOWER, balanced, appending rather than replacing) produce, + * the tower being the section tower because that base is an `.mdx` file + * (asserted by S-8's derivation test). Built by concatenation, never by + * recursion, and sized at LARGEST_STAGED_INPUT_BYTES. + */ +export function largestStagedDocument(): Uint8Array { + const parts = [Buffer.from(LARGEST_BASE_FILE[1], "utf8")]; + const tower = Buffer.from(TOWER_SOURCE, "utf8"); + for (let index = 0; index < MAX_MUTATIONS_PER_TRIAL; index += 1) { + parts.push(tower); + } + return Buffer.concat(parts); +} From 20ee9fde6885106a7663ab58e354e422e6b548d7 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 06:18:47 +0000 Subject: [PATCH 193/226] =?UTF-8?q?sdg(phase-9):=20add=20the=20T1.3-7=20de?= =?UTF-8?q?pth=20anchor=20=E2=80=94=20a=20valid=202048-deep=20chained-id?= =?UTF-8?q?=20section=20tower=20builds,=20`query=20subtree`=20and=20`view`?= =?UTF-8?q?=20serve=20every=20level,=20walked=20iteratively;=20P-11=20hang?= =?UTF-8?q?-guard=20sizing=20recorded=20as=20Task=209a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 63 +++++---- test/suite/registry/section-1.3.ts | 207 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 242 insertions(+), 29 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 41fe9c8..b9a6385 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,34 +65,45 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage B — H-11 answer-scale capacity (P-11 harness error), S-8, S-2, T1.3-7 -### Task 9 — T1.3-7 Depth: deterministic ≥ 2048-deep workspace (new test) - -Cites: TEST-SPEC T1.3-7 ("a valid workspace whose one file nests sections at -least 2048 levels deep (P-8's giant-nesting floor, 16) builds with exit 0; -`query subtree` on the root returns the root plus every section, in document -order, the count asserted; `view` serves the full positional tree. The -deterministic anchor of P-8's floor outside the generator machinery, and a -deterministic exercise of the harness's answer-scale capacity (H-11, S-8)"); -SPEC 1.3, 11.1, 11.4. - -Now: no registry entry, no H-7 map entry, nothing named T1.3-7 under `test/`. - -Do: in `test/suite/registry/section-1.3.ts` add a `T1.3-7` body: stage one -`.mdx` file with a 2048-deep chain of nested sections (distinct valid IDs; -build the bytes iteratively; keep every other section-1.3 fixture untouched), -`build` → exit 0 (no findings); `query subtree` on the root (existing -query-adapter decoding) → exactly the root plus 2048 sections, in document -order, IDs asserted by count and by first/last/sampled identities; `view` on -the file → the full positional tree decoded through `decodeViewReport`, depth -asserted by iterative walk. Register the ID in the module's exported array, -add `"T1.3-7": ["1.3"]` (plus 11.1/11.4 if the H-7 map convention lists every -cited section — follow the neighbouring entries) to `traceability.ts`. -Prerequisite: Task 5 — done (the `view` decode no longer overflows). +### Task 9a — P-11: size the per-invocation hang guard to the staged answer scale (H-11) + +Cites: TEST-SPEC H-11 (the harness is dimensioned to the staged scale), H-8/H-10 +(the hang guard bounds only "the invocation terminates" and is never an +assertion input), P-11 (its answer arms request `view --text` over the mutated +base), S-8; AGENTS.md's S-8 note (the `view --text` blowup: two depth-4096 +towers under P-8's LF → U+2028 rewrite, ~204 MB in the `\u2028` spelling — the +largest answer SPEC.md permits over a staged input). + +Now: `FUZZ_COMMAND_TIMEOUT_MS = 10_000` in `test/suite/registry/section-16-p11.ts` +("generously above any plausible answer time for these staged inputs") is sized +to parse time, not to a conforming product emitting P-11's largest answer. +Measured at fd7216b against the built product on this branch: `view --text +specs/A.mdx` over two balanced depth-4096 towers (`sectionTowerSource(4096, +true)` twice) with every LF rewritten to U+0020 — a one-line 163,856-byte file +with the same quadratic expansion as the U+2028 rewrite, in a smaller JSON +spelling — exits 1 (the towers' 14.2 findings accompany the document) with a +125,810,349-byte pretty-printed answer after 16.6 s wall clock (14.2 s user, +6.7 s sys); the `\u2028` spelling is larger, so slower still. Past the guard, +the driver kills the invocation and the P-11 module's hang-guard conversion +(near its line 415) reports "invocation was still running when the harness's +hang guard killed" — a diagnosed failure against a conforming product, H-8 +inverted. Relayed by iterations 8 and 9 outside their tasks; substantiated by +iteration 10; no earlier task covered it. + +Do: rederive the guard from the staged answer scale and state the derivation in +its comment — a constant with margin over the measured blowup (≥ 4×, so 60 s or +more: CI runners are slower than the measuring machine), or a budget that grows +with the captured output — keeping it purely the H-8 termination bound (H-10), +never an assertion input. Check the P-11 entry's body budget (`timeoutMs: +420_000`: three fixed seeds × 2–4 mutations × the availability arms) against +the new worst case (invocations per body × guard) and raise it if needed, so a +slow-but-terminating product fails as a hang-guard diagnosis of one invocation, +never as a Vitest body timeout. Never lower the staged scale (S-8 pins it). Verify: `npx vitest run --config test/vitest.config.ts --project suite -test/suite/section-1.3.test.ts` — passes against the built product or fails as -a diagnosed product failure, never a harness error; `npm run test:self` green -(S-1, S-7). +test/suite/section-16-p11.test.ts` — passes against the built product (no +hang-guard kill of an invocation still emitting its answer) or fails as a +diagnosed product failure; `npm run test:self` green (S-8 unchanged). ## Stage C — CONF-DISC code-group surface (fixture and suite) diff --git a/test/suite/registry/section-1.3.ts b/test/suite/registry/section-1.3.ts index c40c1c6..b33f180 100644 --- a/test/suite/registry/section-1.3.ts +++ b/test/suite/registry/section-1.3.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §1.3 (requirement IDs) — SUITE-02: T1.3-1 … T1.3-6. +// TEST-SPEC §1.3 (requirement IDs) — SUITE-02: T1.3-1 … T1.3-7. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -14,7 +14,10 @@ // props, code groups, `markdown`, `coverage`, `policy`, or git; the command // surface is `build` (error reporting of 14.1–14.4, plus 14.17 as T1.3-6's // invalid-form arms stage it) plus `query nodes`. T1.3-5's cross-file -// duplicate-ID arm is the multi-file case. +// duplicate-ID arm is the multi-file case. T1.3-7 stands outside that +// scope — its command surface is `query subtree` and `view`, and +// CERTIFICATIONS.md places the scale-capacity class outside certification +// by construction. // // Location assertions: fixtures are staged as prefix + offending construct + // suffix, all pure ASCII (string indices are byte offsets), and each negative @@ -24,10 +27,11 @@ // terminator) still passes; every other staged construct lies outside the // widened window, so a finding attributed to the wrong construct fails. -import type { Finding } from "../../helpers/adapters/index.js"; +import type { Finding, ViewNode } from "../../helpers/adapters/index.js"; import { assertReportMentions, decodeNodeRowsReport, + decodeViewReport, } from "../../helpers/adapters/index.js"; import { fail } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; @@ -520,6 +524,202 @@ const T1_3_6 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- +// T1.3-7 Depth — the deterministic anchor of P-8's giant-nesting floor. +// +// SPEC 1.3 bounds no nesting depth: its structural rule — a child's id is its +// parent's id plus "." plus exactly one segment — holds at every level. One +// valid file nests sections DEPTH_FLOOR levels deep. Because every id spells +// its whole ancestor chain, the file is quadratic in the depth (~4.2 MB at +// 2048 with one-letter segments) and both it and the expected identities are +// built iteratively; the answers (~4.5 MB of `query subtree` rows, ~13 MB of +// `view`) are walked iteratively too — H-11, S-8: never one frame per level. +// P-8's own tower repeats `id="g"` at every level, which 1.3 rejects (14.2) +// from the second level on — right for a robustness draw, wrong for the valid +// workspace T1.3-7 stages, so this fixture chains its ids instead. + +/** P-8's giant-nesting floor (TEST-SPEC P-8, 16), staged here deterministically. */ +const DEPTH_FLOOR = 2048; + +/** + * One-letter segments cycling through the alphabet: a level's identity is a + * function of its position, so a level the product drops, duplicates, or + * reorders shifts every deeper identity and the sequence comparison names + * the first shifted position. + */ +const DEPTH_SEGMENTS = "abcdefghijklmnopqrstuvwxyz"; + +interface DepthTower { + /** The file's bytes. */ + readonly source: string; + /** Each level's `id` value, outermost first. */ + readonly ids: readonly string[]; +} + +/** Build the chain iteratively: level k's id is level k−1's id plus "." plus its own segment. */ +function depthTower(depth: number): DepthTower { + const ids: string[] = []; + const openers: string[] = []; + let id = ""; + for (let level = 1; level <= depth; level += 1) { + const segment = DEPTH_SEGMENTS[(level - 1) % DEPTH_SEGMENTS.length]!; + id = level === 1 ? segment : `${id}.${segment}`; + ids.push(id); + openers.push(`<S id="${id}">\n`); + } + return { + source: `${openers.join("")}deep.\n${"</S>\n".repeat(depth)}`, + ids, + }; +} + +/** A long identity rendered within bounds for a diagnosis. */ +function abbreviateIdentity(identity: string): string { + const limit = 48; + return identity.length <= limit + ? JSON.stringify(identity) + : `${JSON.stringify(identity.slice(0, limit))}… (${identity.length} characters)`; +} + +/** + * Diagnosed, position-by-position comparison of a reported identity sequence + * against the expected one — the count and every position, so first, last, + * and every sampled identity are covered — without rendering either + * multi-megabyte sequence whole (`assertSameJson` would). + */ +function assertIdentitySequence( + actual: readonly string[], + expected: readonly string[], + context: string, +): void { + const shared = Math.min(actual.length, expected.length); + for (let index = 0; index < shared; index += 1) { + if (actual[index] !== expected[index]) { + fail( + `${context}: the identity at position ${index} differs\n` + + ` actual: ${abbreviateIdentity(actual[index]!)}\n` + + ` expected: ${abbreviateIdentity(expected[index]!)}`, + ); + } + } + if (actual.length !== expected.length) { + const detail = + actual.length > expected.length + ? `the first surplus identity is ${abbreviateIdentity(actual[expected.length]!)}` + : `the first missing identity is ${abbreviateIdentity(expected[actual.length]!)}`; + fail( + `${context}: ${expected.length} identities expected (the root plus ` + + `${expected.length - 1} sections), got ${actual.length}; ${detail}`, + ); + } +} + +/** + * Walk the positional tree iteratively (H-11): the staged file nests exactly + * one section per level, so the tree must be one chain — every node has one + * child until the deepest, which has none — and its preorder identities are + * returned for the sequence comparison. + */ +function chainIdentities(root: ViewNode, context: string): string[] { + const identities: string[] = []; + let node = root; + for (let level = 0; ; level += 1) { + if (typeof node.identity !== "string") { + fail( + `${context}: the node at nesting level ${level} reports its identity as ` + + "unavailable, but the file carries no finding — every identity of a " + + "valid file is defined (SPEC 11.2, 1.5)", + ); + } + identities.push(node.identity); + if (node.children.length === 0) return identities; + if (node.children.length !== 1) { + fail( + `${context}: the node at nesting level ${level} ` + + `(${abbreviateIdentity(node.identity)}) reports ${node.children.length} ` + + "children, but the staged file nests exactly one section per level " + + "(SPEC 11.4: the positional tree is defined by construct nesting alone)", + ); + } + node = node.children[0]!; + } +} + +const T1_3_7 = defineProductTest({ + id: "T1.3-7", + title: + "Depth: a valid 2048-deep section chain builds, and `query subtree` and `view` serve every level", + async run(product) { + const tower = depthTower(DEPTH_FLOOR); + const expectedIdentities = [ + "specs/A.mdx", + ...tower.ids.map((id) => `specs/A.mdx#${id}`), + ]; + const workspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + "specs/A.mdx": tower.source, + }, + }); + try { + await buildOk( + product, + workspace, + `T1.3-7 \`build\` over one file nesting sections ${DEPTH_FLOOR} levels deep — ` + + "a valid workspace, SPEC 1.3 bounding no depth", + ); + + // `query subtree` on the root: the root plus every section, in document + // order (SPEC 11.1) — the count and each identity by position. + const subtreeLabel = "T1.3-7 `query subtree specs/A.mdx` (the root)"; + const rows = decodeNodeRowsReport( + await runJson( + product, + workspace, + ["query", "subtree", "specs/A.mdx"], + subtreeLabel, + ), + subtreeLabel, + ); + assertIdentitySequence( + rows.map((row) => row.identity), + expectedIdentities, + `${subtreeLabel}: the root plus every section, in document order (SPEC 11.1)`, + ); + + // `view` on the file: the full positional tree — one chain, DEPTH_FLOOR + // levels deep, every identity defined (SPEC 11.4). + const viewLabel = "T1.3-7 `view specs/A.mdx`"; + const report = decodeViewReport( + await runJson(product, workspace, ["view", "specs/A.mdx"], viewLabel), + { text: false }, + viewLabel, + ); + if (report.findings.length !== 0) { + fail( + `${viewLabel}: ${report.findings.length} finding(s) accompany the answer, ` + + `but the workspace is valid — the ${DEPTH_FLOOR}-deep chain satisfies ` + + "1.3 at every level", + ); + } + if (report.views.length !== 1) { + fail( + `${viewLabel}: expected exactly one per-file view (the one requested ` + + `file), got ${report.views.length} (SPEC 11.4)`, + ); + } + assertIdentitySequence( + chainIdentities(report.views[0]!.root, viewLabel), + expectedIdentities, + `${viewLabel}: the full positional tree — the root and one section per ` + + `level, ${DEPTH_FLOOR} deep, in document order (SPEC 11.4)`, + ); + } finally { + await workspace.dispose(); + } + }, +}); + /** TEST-SPEC §1.3, in canonical ID order (SUITE-02). */ export const section13Tests: readonly ProductTestEntry[] = [ T1_3_1, @@ -528,4 +728,5 @@ export const section13Tests: readonly ProductTestEntry[] = [ T1_3_4, T1_3_5, T1_3_6, + T1_3_7, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 2281fd8..6717258 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -98,6 +98,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T1.3-4": ["1.3", "14"], "T1.3-5": ["1.3", "14"], "T1.3-6": ["1.3", "14"], + "T1.3-7": ["1.3", "11.1", "11.4"], "T1.4-1": ["1.4", "14"], "T1.4-2": ["1.4"], "T1.4-3": ["1.4"], From d091075c44d514e354c1e879e13d04afe38373c2 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 06:39:18 +0000 Subject: [PATCH 194/226] =?UTF-8?q?sdg(phase-9):=20size=20P-11's=20hang=20?= =?UTF-8?q?guard=20to=20the=20staged=20answer=20scale=20=E2=80=94=20120=20?= =?UTF-8?q?s=20from=20the=20measured=20`view=20--text`=20blowup,=20body=20?= =?UTF-8?q?budget=2020=20min,=20driver=20kills=20reported=20unshrunk=20(Ta?= =?UTF-8?q?sk=209a,=20H-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 9a of the Phase 9 re-descent plan (specs/tmp/FIX_PLAN.md), removed together with the now-empty Stage B heading; 38 tasks remain. - `FUZZ_COMMAND_TIMEOUT_MS` 10 s -> 120 s in test/suite/registry/section-16-p11.ts, derived in its comment from the largest answer a P-11 draw admits, measured at 20ee9fd through `runProduct` against the built product on a 4-core machine: `view --text specs/A.mdx` over two appended depth-4096 balanced section towers with every LF rewritten to U+2028 emits 58.6 MB in 17.0 s (LF -> U+0020: 9.3 s; `view`, `occurrences`, `at` at that scale 1.0-1.5 s; any unmutated-scale arm ~0.3 s). 120 s is 7x that maximum: the >= 4x margin plus headroom for the up-to-3.5x larger `
` spelling and slower CI runners. Purely the H-8 termination bound (H-10), never an assertion input. - Body budget 420 s -> 1,200,000 ms, sized to the diagnosis path: <= 144 invocations at conforming speeds run ~8.5 min even with every `view --text` arm at the staged maximum (the pinned seeds draw 18 such arms, 2 over towers, and 5 tower trials), plus one unshrunk guard kill, doubled for CI. The adversarial bound (every invocation just under the guard, ~4.8 h) exceeds the 45-minute CI job ceiling and is documented as uncoverable by any body budget. - Driver kills (hang guard, output cap) decline shrinking, since each shrink candidate would re-observe the kill at one full guard: `HarnessAssertionError.shrinkable` and `fail(message, { shrinkable: false })` in test/helpers/assertions.ts; `checkProperty` reports such a falsification as drawn, with a distinct note and `shrinkDeclined` on `PropertyFalsifiedError`, in test/helpers/property.ts; self-test added in test/self/property-infrastructure.test.ts. - Verification: test/suite/section-16-p11.test.ts passes against the built product (1/1 in 38 s, no guard kill); `npm run test:self` 338/338 with certification exact (C-1); typecheck and format clean. - AGENTS.md: the measured answer-scale timings and the re-measurement recipe. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 1 + specs/tmp/FIX_PLAN.md | 50 ++----------------- test/helpers/assertions.ts | 24 +++++++-- test/helpers/property.ts | 34 +++++++++---- test/self/property-infrastructure.test.ts | 44 ++++++++++++++++ test/suite/registry/section-16-p11.ts | 61 +++++++++++++++++++---- 6 files changed, 147 insertions(+), 67 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1ede85a..d3999d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,3 +21,4 @@ Build, test, and run instructions for this repository (nothing else belongs in t - CI: `.github/workflows/ci.yml` — harness-self (Linux), full suite (Linux, network disabled after setup via `.github/scripts/run-without-network.sh`), and the Windows E-6 leg on every pull request. The `e6-linux-outputs` upload step of the full-suite job has no `if: always()`, so it is skipped whenever any Linux test fails (the run ends with no artifact): the Windows byte-identity test then fails loudly on the missing manifest until suite-linux is green — that arm's verdict is meaningful only on a green Linux run. - Answer-document scale (TEST-SPEC H-11): the suite stages section towers 4096 deep (`NESTING_DEPTHS` in `test/suite/registry/section-16-p8.ts`), past V8's limits in this Node — a plain recursive function gets ~9.9k frames, and `JSON.stringify`/`structuredClone` throw `RangeError` at that nesting while `JSON.parse` is iterative. Harness walks over answer documents therefore use explicit stacks (`decodeViewNodeForm`, `assertUnavailabilityMarkerForms`, `describeJsonValue`'s fallback, and `query.ts`'s `walkForRangeData` and `decodeIdsTreeNode` — `ids --tree` nests one node per section level — in `test/helpers/adapters/`, and the registry modules' own generic JSON walkers, `canonicalJson`, `collectStringLeaves` and `canonicalizeJson` in `test/suite/registry/section-10.*.ts` and `section-12.0-i.ts`); a property reporting `RangeError: Maximum call stack size exceeded` as a harness error names a walk still recursing per level — its `Caused by` frames locate it. To drive a decoder at depth by hand, a temporary self-test (see above) building the document in a loop is enough: 200k-deep `ids --tree` and edge documents decode in well under a second. A walker its module does not export can be exercised the same way as a scratch `.ts` twin of it run under `node --experimental-strip-types` (Node 22). - Staged-scale gates (TEST-SPEC S-2, S-8): `test/self/staged-scale.ts` derives the suite's staged input maxima from the generators themselves — `NESTING_DEPTHS`, `MAX_MUTATIONS_PER_TRIAL`, `TERMINATOR_SEQUENCES`, and the byte-exact tower builder `sectionTowerSource(depth, balanced)` exported by `test/suite/registry/section-16-p8.ts` — into `DEEPEST_STAGED_TOWER` (4096), `TOWER_BYTES`, and `LARGEST_STAGED_INPUT_BYTES` (196,830 bytes: `specs/A.mdx` plus the whole mutation budget of appended towers, built by `largestStagedDocument()`); `test/self/s2-workspace-builder.test.ts` stages both maxima through the workspace builder and reads them back byte-complete (the input side; its exact-size pins move only when a generator bound does — update them deliberately), and `test/self/s8-answer-scale-capacity.test.ts` (the answer side, with P-2/P-3's `specSubtreeTexts` oracle) replays the fixed CI seed set through `drawFixedSeedTrials(generator, runs)` (`test/helpers/property.ts`; the same per-seed PRNG stream `checkProperty` uses, no property body) to confirm the staged draws stay inside them, and drives every H-3/12.7 decoder and answer-document walk on synthetic documents at that scale: a `view` nested 8192 deep (two depth-4096 towers nested by a shuffle mutation) and the `view --text` blowup — two depth-4096 towers whose LF → U+2028 rewrite leaves every level's separators as content, ~204 MB in the `\u2028` spelling. The blowup document is written to a workspace file and streamed to stdout by a stand-in script through `runProduct`, so the test costs ~10 s and holds roughly 0.6–0.8 GB in the Vitest worker (the captured bytes, their UTF-8 string, and the parsed document coexist); run it alone with `npx vitest run --config test/vitest.config.ts --project self test/self/s8-answer-scale-capacity.test.ts`. `DEFAULT_MAX_OUTPUT_BYTES` in `test/helpers/subprocess.ts` (512 MiB) is gated by that test at no less than twice the blowup document — lower it or grow a generator's scale and S-8 fails, by design; memory for the cap is committed only as output arrives. +- Answer-scale timings (TEST-SPEC H-11; `test/suite/registry/section-16-p11.ts`): P-11's per-invocation hang guard (`FUZZ_COMMAND_TIMEOUT_MS`, 120 s) and body budget (`timeoutMs`, 20 min) are derived in their comments from the largest answer a P-11 draw admits, measured through `runProduct` against the built product at 20ee9fd on a 4-core machine: `view --text specs/A.mdx` over `FUZZ_BASE_FILES` with `sectionTowerSource(4096, true)` appended twice to `specs/A.mdx` and every LF rewritten to U+2028 emits 58.6 MB and exits in 17.0 s (bare `view --text`: 16.5 s; the same input with LF → U+0020: 9.3 s; `view`, `occurrences`, and `at` at that scale: 1.0–1.5 s; any arm over an unmutated-scale draw: ~0.3 s), and the pinned seed set's 36 trials draw 5 tower trials (sections at most 2048 deep, no terminator rewrite) and 18 `view --text` answer arms, 2 of them over towers — a conforming P-11 sweep runs in ~35 s here. Re-measure with a temporary self-test (pattern above) that stages that input through `TestWorkspace.create`, times `runProduct(builtProductBinding(), { cwd, argv, timeoutMs: 900_000 })`, and prints through `console.info` under `--disable-console-intercept`; count what the pinned seeds stage with `drawFixedSeedTrials(genAvailabilityTrial, 12)` (`test/helpers/property.ts`). Revisit both constants whenever a generator bound (`NESTING_DEPTHS`, `MAX_MUTATIONS_PER_TRIAL`) or the product's answer time at that scale moves. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index b9a6385..f13402d 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -46,10 +46,10 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. failure; a harness error (crash, stack overflow, exhausted limit, decode exception outside the assertion protocol) is never acceptable. For a test in a certification scope, additionally run `npm run test:self` and confirm C-1. -- Work top to bottom (Stage A, the certification gate, is complete): Stage B - removes the H-11 harness error; Stage C adds the CONF-DISC code-group - surface; Stage D is the remaining suite gaps in section order (independent - of each other unless a task names a prerequisite). +- Work top to bottom (Stages A and B — the certification gate and the H-11 + answer-scale capacity work — are complete): Stage C adds the CONF-DISC + code-group surface; Stage D is the remaining suite gaps in section order + (independent of each other unless a task names a prerequisite). - Commit `sdg(phase-9): <imperative summary>`, ending every commit message with the two trailer lines `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>` and @@ -63,48 +63,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. --- -## Stage B — H-11 answer-scale capacity (P-11 harness error), S-8, S-2, T1.3-7 - -### Task 9a — P-11: size the per-invocation hang guard to the staged answer scale (H-11) - -Cites: TEST-SPEC H-11 (the harness is dimensioned to the staged scale), H-8/H-10 -(the hang guard bounds only "the invocation terminates" and is never an -assertion input), P-11 (its answer arms request `view --text` over the mutated -base), S-8; AGENTS.md's S-8 note (the `view --text` blowup: two depth-4096 -towers under P-8's LF → U+2028 rewrite, ~204 MB in the `\u2028` spelling — the -largest answer SPEC.md permits over a staged input). - -Now: `FUZZ_COMMAND_TIMEOUT_MS = 10_000` in `test/suite/registry/section-16-p11.ts` -("generously above any plausible answer time for these staged inputs") is sized -to parse time, not to a conforming product emitting P-11's largest answer. -Measured at fd7216b against the built product on this branch: `view --text -specs/A.mdx` over two balanced depth-4096 towers (`sectionTowerSource(4096, -true)` twice) with every LF rewritten to U+0020 — a one-line 163,856-byte file -with the same quadratic expansion as the U+2028 rewrite, in a smaller JSON -spelling — exits 1 (the towers' 14.2 findings accompany the document) with a -125,810,349-byte pretty-printed answer after 16.6 s wall clock (14.2 s user, -6.7 s sys); the `\u2028` spelling is larger, so slower still. Past the guard, -the driver kills the invocation and the P-11 module's hang-guard conversion -(near its line 415) reports "invocation was still running when the harness's -hang guard killed" — a diagnosed failure against a conforming product, H-8 -inverted. Relayed by iterations 8 and 9 outside their tasks; substantiated by -iteration 10; no earlier task covered it. - -Do: rederive the guard from the staged answer scale and state the derivation in -its comment — a constant with margin over the measured blowup (≥ 4×, so 60 s or -more: CI runners are slower than the measuring machine), or a budget that grows -with the captured output — keeping it purely the H-8 termination bound (H-10), -never an assertion input. Check the P-11 entry's body budget (`timeoutMs: -420_000`: three fixed seeds × 2–4 mutations × the availability arms) against -the new worst case (invocations per body × guard) and raise it if needed, so a -slow-but-terminating product fails as a hang-guard diagnosis of one invocation, -never as a Vitest body timeout. Never lower the staged scale (S-8 pins it). - -Verify: `npx vitest run --config test/vitest.config.ts --project suite -test/suite/section-16-p11.test.ts` — passes against the built product (no -hang-guard kill of an invocation still emitting its answer) or fails as a -diagnosed product failure; `npm run test:self` green (S-8 unchanged). - ## Stage C — CONF-DISC code-group surface (fixture and suite) ### Task 10 — CONF-DISC conformer: code groups of well-formed `.ts` sources and `query edges --from <path>` diff --git a/test/helpers/assertions.ts b/test/helpers/assertions.ts index 0184c71..f84ed54 100644 --- a/test/helpers/assertions.ts +++ b/test/helpers/assertions.ts @@ -33,15 +33,33 @@ import { summarizeResult } from "./subprocess.js"; * below); any other exception escaping a test body is a harness error. */ export class HarnessAssertionError extends Error { - constructor(message: string) { + /** + * Whether a property this failure falsifies is shrunk before it is + * reported (helpers/property.ts `checkProperty`); default true. Shrinking + * is bounded in property executions, and that bound stops bounding wall + * clock when every re-observation of the failure costs a full hang guard — + * an invocation the subprocess driver killed (P-11's termination clause, + * TEST-SPEC §16): such a failure declines shrinking, and the drawn + * counterexample is reported as is, with its seed (H-10). + */ + readonly shrinkable: boolean; + + constructor(message: string, options: FailOptions = {}) { super(message); this.name = "HarnessAssertionError"; + this.shrinkable = options.shrinkable ?? true; } } +/** Options of {@link fail}. */ +export interface FailOptions { + /** See {@link HarnessAssertionError.shrinkable}; default true. */ + readonly shrinkable?: boolean; +} + /** Throw a diagnosed assertion failure (H-8). */ -export function fail(message: string): never { - throw new HarnessAssertionError(message); +export function fail(message: string, options?: FailOptions): never { + throw new HarnessAssertionError(message, options); } /** View assertion input as bytes: strings are UTF-8, byte inputs are as-is. */ diff --git a/test/helpers/property.ts b/test/helpers/property.ts index a87d31d..a54894d 100644 --- a/test/helpers/property.ts +++ b/test/helpers/property.ts @@ -48,6 +48,14 @@ // rethrows it as a plain `Error` (never a diagnosed assertion failure) with // the seed attached for reproduction, matching the certification runner's // and the S-7 sweep's outcome taxonomy (H-8). +// +// A failure may decline shrinking (`fail(message, { shrinkable: false })`, +// helpers/assertions.ts): its drawn counterexample is reported as is, with +// its seed. The shrink budget is counted in property executions, so it +// bounds wall clock only while an execution is cheap; a failure whose every +// re-observation costs a full hang guard — an invocation the subprocess +// driver killed (P-11's termination clause) — would otherwise turn a bounded +// shrink into hours, past the body's own hang guard. import { HarnessAssertionError } from "./assertions.js"; @@ -235,6 +243,8 @@ export class PropertyFalsifiedError extends HarnessAssertionError { readonly shrinkSteps: number; /** Property executions spent shrinking. */ readonly shrinkExecutions: number; + /** True when the failure declined shrinking (`HarnessAssertionError.shrinkable`). */ + readonly shrinkDeclined: boolean; constructor(details: { readonly propertyName: string; @@ -248,9 +258,11 @@ export class PropertyFalsifiedError extends HarnessAssertionError { readonly assertionMessage: string; readonly shrinkSteps: number; readonly shrinkExecutions: number; + readonly shrinkDeclined: boolean; }) { - const shrinkNote = - details.shrinkSteps > 0 + const shrinkNote = details.shrinkDeclined + ? "\n (reported as drawn: this failure declines shrinking — each re-observation would cost a full hang guard)" + : details.shrinkSteps > 0 ? `\n shrunk from: ${details.renderedInitialValue}\n (${String(details.shrinkSteps)} accepted shrink steps, ${String(details.shrinkExecutions)} property executions)` : "\n (already minimal: no shrink candidate was accepted)"; super( @@ -269,6 +281,7 @@ export class PropertyFalsifiedError extends HarnessAssertionError { this.assertionMessage = details.assertionMessage; this.shrinkSteps = details.shrinkSteps; this.shrinkExecutions = details.shrinkExecutions; + this.shrinkDeclined = details.shrinkDeclined; } } @@ -405,13 +418,15 @@ export async function checkProperty<T>( renderedInput: renderValue(generated.value, options.render), }); } - const shrunk = await shrinkFalsification( - generator, - property, - { trial: generated, error }, - maxShrinkExecutions, - { name, seed, render: options.render }, - ); + const shrunk: ShrinkResult<T> = error.shrinkable + ? await shrinkFalsification( + generator, + property, + { trial: generated, error }, + maxShrinkExecutions, + { name, seed, render: options.render }, + ) + : { final: { trial: generated, error }, steps: 0, executions: 0 }; throw new PropertyFalsifiedError({ propertyName: name, seed, @@ -424,6 +439,7 @@ export async function checkProperty<T>( assertionMessage: shrunk.final.error.message, shrinkSteps: shrunk.steps, shrinkExecutions: shrunk.executions, + shrinkDeclined: !error.shrinkable, }); } } diff --git a/test/self/property-infrastructure.test.ts b/test/self/property-infrastructure.test.ts index 497307d..78a8d6b 100644 --- a/test/self/property-infrastructure.test.ts +++ b/test/self/property-infrastructure.test.ts @@ -147,6 +147,50 @@ test("a forced failure reports its seed and shrinks to the minimal counterexampl expect(error.assertionMessage).toBe("generated value 100 is >= 100"); }); +test("a failure that declines shrinking is reported as drawn, with its seed (P-11's killed invocations)", async () => { + let executions = 0; + const thrown = await captureRejection( + checkProperty( + "demo: every value stays below 100, unshrunk", + (choices) => choices.intInclusive(0, 100000), + (value) => { + executions += 1; + if (value >= 100) { + fail(`generated value ${String(value)} is >= 100`, { + shrinkable: false, + }); + } + }, + { runs: 50, seeds: [123456], env: {} }, + ), + ); + + // Still a diagnosed falsification naming its seed (H-8, H-10) … + expect(thrown).toBeInstanceOf(PropertyFalsifiedError); + const error = thrown as PropertyFalsifiedError; + expect(error.seed).toBe(123456); + expect(error.message).toContain(`${PROPERTY_SEED_ENV}=123456`); + + // … but no shrink candidate was executed: the property ran exactly once per + // trial up to the failing one, and the counterexample is the drawn value … + expect(error.shrinkDeclined).toBe(true); + expect(error.shrinkSteps).toBe(0); + expect(error.shrinkExecutions).toBe(0); + expect(executions).toBe(error.trial); + expect(error.value).toBe(error.initialValue); + expect(error.value).toBeGreaterThanOrEqual(100); + expect(error.assertionMessage).toBe( + `generated value ${String(error.value)} is >= 100`, + ); + + // … and the report says so rather than claiming minimality. + expect(error.message).toContain("reported as drawn"); + expect(error.message).not.toContain("already minimal"); + + // The default is unchanged: a plain failure shrinks. + expect(new HarnessAssertionError("plain").shrinkable).toBe(true); +}); + test("structured counterexamples shrink, deterministically across runs (TEST-SPEC 16, E-5)", async () => { // Inline generic combinator + inferred property parameter: the composition // style every section-16 property will use. diff --git a/test/suite/registry/section-16-p11.ts b/test/suite/registry/section-16-p11.ts index c16ee82..6d567c2 100644 --- a/test/suite/registry/section-16-p11.ts +++ b/test/suite/registry/section-16-p11.ts @@ -12,7 +12,10 @@ // driver's hang guard (helpers/subprocess.ts): a run killed by the // per-invocation timeout or the runaway-output cap is converted into a // *diagnosed assertion failure* (H-8), because termination is this -// property's assertion, not merely harness hygiene; +// property's assertion, not merely harness hygiene; the timeout is +// dimensioned to the staged answer scale (H-11; `FUZZ_COMMAND_TIMEOUT_MS` +// below), and a killed invocation is reported unshrunk, since every +// shrink candidate re-observing a kill would cost the full guard; // * stdout is one complete JSON document, never partial — the three // surfaces are JSON-only (SPEC 11: a single JSON document is the only // output form, with or without `--json`), so the entire stdout must @@ -405,11 +408,28 @@ export function renderAvailabilityTrial(trial: AvailabilityTrial): string { /** * Per-invocation hang guard. Purely the H-8 guard bounding the observation - * "the invocation terminates" — never an assertion input beyond that (H-10); - * generously above any plausible answer time for these staged inputs and - * small enough that a falsified termination clause shrinks in budget. + * "the invocation terminates" — never an assertion input beyond that (H-10) + * — dimensioned to the staged answer scale (H-11), not to parse time. The + * largest answer SPEC.md permits over a P-11 draw is `view --text` over + * `specs/A.mdx` carrying two appended depth-4096 section towers under P-8's + * LF → U+2028 rewrite (the whole mutation budget), whose quadratic text + * expansion S-8 sizes the capture to (~204 MB in the `\u2028` spelling). + * Measured at 20ee9fd through `runProduct` against the built product on a + * 4-core machine: that invocation emits 58.6 MB and terminates in 17.0 s + * (bare `view --text` over the same workspace 16.5 s; the same input with + * LF → U+0020, 9.3 s; the other surfaces at that scale — `view`, + * `occurrences`, `at` — 1.0–1.5 s; any arm over an unmutated-scale draw + * ~0.3 s). 120 s is 7× that maximum: the ≥ 4× margin a conforming product + * is owed over its measured answer time, plus headroom for the up-to-3.5× + * larger `\u2028` spelling and slower CI runners. So a conforming product + * is never killed while still emitting its answer (H-11: an exhausted + * harness limit is a harness defect, never a diagnosed product failure), + * and a genuinely hanging one costs one guard per diagnosis, reported + * unshrunk (runAvailabilityCommand). Re-measure with a temporary self-test + * staging `FUZZ_BASE_FILES` with `sectionTowerSource(4096, true)` appended + * twice to `specs/A.mdx` and every LF rewritten to U+2028 (see AGENTS.md). */ -const FUZZ_COMMAND_TIMEOUT_MS = 10_000; +const FUZZ_COMMAND_TIMEOUT_MS = 120_000; /** * Run one availability invocation, converting the hang-guard and @@ -429,12 +449,20 @@ async function runAvailabilityCommand( timeoutMs: FUZZ_COMMAND_TIMEOUT_MS, }); } catch (error) { + // Both driver kills are reported unshrunk (`shrinkable: false`): a + // shrink candidate can re-observe a kill only by waiting out the guard + // (or filling the output cap) again — one full guard per candidate — so + // shrinking's execution budget would stop bounding the body's wall + // clock (the entry's `timeoutMs` below). The drawn trial, at most three + // mutations and four invocations, is the reported counterexample, and + // its seed replays it (H-10). if (error instanceof ProductRunTimeoutError) { fail( `P-11: every invocation of the availability surfaces must terminate ` + `on fuzzed sources (TEST-SPEC §16 P-11; SPEC 11.2, 12.0), but the ` + `invocation was still running when the harness's hang guard killed ` + `it — ${error.message}`, + { shrinkable: false }, ); } if (error instanceof ProductRunOutputOverflowError) { @@ -443,6 +471,7 @@ async function runAvailabilityCommand( `complete JSON document (TEST-SPEC §16 P-11; SPEC 11, 12.0) — but ` + `the invocation emitted unbounded output until the harness's ` + `runaway-output guard killed it — ${error.message}`, + { shrinkable: false }, ); } throw error; @@ -608,10 +637,24 @@ const P_11 = defineProductTest({ "argument errors), answer in the three-state 12.7 datum forms, and exit " + "1 exactly when the answer carries a finding or an unavailable datum " + "(SPEC 11.2, 11.4, 12.7; TEST-SPEC §16 P-11)", - // Wall-clock hang guard only (H-10): three fixed seeds (E-5), a 2–4 - // invocation sweep per trial with no staging build, plus the shrink budget - // on falsification. - timeoutMs: 420_000, + // Wall-clock hang guard on the body only (H-10), sized to the diagnosis + // path, never an assertion input. The sweep is three fixed seeds (E-5) × + // 12 trials × 2–4 invocations, ≤ 144, each bounded by + // FUZZ_COMMAND_TIMEOUT_MS and with no staging build. A conforming product + // answers within the measured scale that guard is derived from — ≤ 17 s + // for a `view --text` arm at the staged maximum, ≤ 1.5 s for any other arm + // at tower scale (the pinned seeds draw 18 `view --text` answer arms, 2 of + // them over towers, and 5 tower trials in all; dry run at 20ee9fd) — so a + // sweep whose every text arm reached the maximum runs ≈ 8.5 min; a + // slow-but-terminating product then fails as one hang-guard kill on top, + // unshrunk (≈ 10.5 min in all), and 20 min doubles that for CI runners: + // the failure is the guard's diagnosis of one invocation, never this body + // timeout. Shrinking any other failure class costs answer time, not guard + // time (≤ 100 executions at ≤ 17 s per maximal-scale text arm). The + // adversarial bound — every invocation just under the guard — is ≈ 4.8 h, + // past the 45-minute CI job ceiling governing the whole suite; no body + // budget can cover it. + timeoutMs: 1_200_000, run: async (product) => { await checkProperty( "P-11 availability robustness", From f266007610f61f9018eca1f311aae3b9a74bcffe Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 06:56:43 +0000 Subject: [PATCH 195/226] =?UTF-8?q?sdg(phase-9):=20CONF-DISC=20conformer?= =?UTF-8?q?=20=E2=80=94=20code=20groups=20discovered=20under=20the=20one?= =?UTF-8?q?=20glob=20grammar=20and=20`query=20edges=20--from=20<path>`=20a?= =?UTF-8?q?s=20the=20code-set=20observation=20(Task=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend test/fixtures/conf-disc/product.mjs to CERTIFICATIONS.md §CONF-DISC's revised scope: `code` groups are accepted through the same pattern validation as spec groups (validatedGroups; outside-root patterns stay 14.14); discovery matches spec globs and code globs separately, passes the matches of either kind through the one 13.4 exclusion filter (generated modules beside sources, `.xspec/` paths, enabled emit destinations — so the DERIVED switch's single consumption point now governs both group sides), and keeps each surviving code match as an edgeless whole-file location by path alone (4.6; `#` paths are 14.19 on either side; no both-groups check — 14.14's rule stays dormant). `query edges [--from <graph-node>]` (11.1) is JSON-only: the empty edge enumeration for a discovered code source is byte-identical to the built product's `{"edges": []}` (the canonical serializer now pretty-prints with two-space indentation, keys still byte-sorted); a path in no configured group — an excluded derived path above all — is the exit-2 usage error with the 12.7 error document, judged after configuration loading and before the 13.3 gate; a spec root answers its `contains` edges to top-level sections and a section its children's (the parser now records section depth); `--to`, `--kinds`, named code units, and the other query subcommands are refused loudly with exit 70 (ScopeError), never a false answer or usage error. Verified by hand on a staged workspace (spec glob `specs/*.mdx`; code globs `src/**/*.ts`, `specs/*.ts`, `.xspec/*.ts`, `specs/*.md`; emission enabled): after `build`, `--from src/a.ts` → exit 0, 18 bytes equal to the product's; `--from specs/A.xspec.ts`, `.xspec/staged.ts`, `specs/A.md`, `nope/x.ts` → exit 2 error documents; unknown-path exit 2 persists on a failing workspace while a code path is gated (exit 1); bin-derived.mjs answers those derived paths exit 0. `npm run test:self`: 17 files, 338/338 passed (C-1 holds: each DISC violator still fails exactly its set). typecheck and format clean. FIX_PLAN.md: Task 10 removed; Task 11 rewritten to its true remainder (the switch already covers the code side — only bin-derived.mjs's header comment and the hand check remain). AGENTS.md: fixtures' exit-70 convention. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 2 +- specs/tmp/FIX_PLAN.md | 80 ++---- test/fixtures/conf-disc/product.mjs | 395 ++++++++++++++++++++++------ 3 files changed, 342 insertions(+), 135 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d3999d4..7ecde38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Run a subset of a test project by appending file paths, e.g. `npx vitest run --config test/vitest.config.ts --project suite test/suite/section-1.1-1.2.test.ts` — the way to observe new product-facing tests failing-as-diagnosed against the stub product (Phase 9 red-green). - Running tests also requires the system `git` executable on PATH: harness fixtures script local git repositories (`test/helpers/workspace.ts`). No git configuration is needed — the builder isolates all ambient git config and identity. - Harness self-tests and certification only (TEST-SPEC 17): `npm run test:self`. Its certification runner prints one `PASS`/`FAIL` line per (test, fixture) pair as it drives each fixture executable; a violator's `FAIL` lines are its expected outcomes, not failures — Vitest's own failures are the `×` lines and the final `Tests` summary, so when piping the output through `grep`, filter on those rather than on `FAIL`. -- Certification fixture products (CERTIFICATIONS.md, e.g. `test/fixtures/conf-core/`) are plain Node ESM programs with no build step and no dependencies: the certification runner (and manual debugging) invokes `node test/fixtures/<fixture>/bin.mjs <command> …` with a staged workspace as the working directory. Violator executables sit beside the conformer's entry as `bin-<deviation>.mjs` (e.g. `bin-nolock.mjs`) and run the same way. `test/fixtures/` is excluded from the harness typecheck; Prettier still formats it. +- Certification fixture products (CERTIFICATIONS.md, e.g. `test/fixtures/conf-core/`) are plain Node ESM programs with no build step and no dependencies: the certification runner (and manual debugging) invokes `node test/fixtures/<fixture>/bin.mjs <command> …` with a staged workspace as the working directory. Violator executables sit beside the conformer's entry as `bin-<deviation>.mjs` (e.g. `bin-nolock.mjs`) and run the same way. Every fixture exits 70 — outside SPEC 12.0's exit partition — for a fixture-internal crash (the stack on stderr), and the CONF-DISC fixture also for an invocation outside its certified scope (`xspec: fixture scope error: …` on stderr, e.g. `query nodes` or `query edges --kinds`): such an exit is a fixture-side condition, never a product verdict, so a test hitting it needs a fixture (or staging) fix, not a product diagnosis. `test/fixtures/` is excluded from the harness typecheck; Prettier still formats it. - To run a selection of product tests against one fixture executable that the certification manifest does not (yet) list — e.g. a violator before its manifest entry lands — put a temporary `*.test.ts` under `test/self/` (the self project's include pattern is `test/self/**/*.test.ts`; files elsewhere never run) that calls `runProductTests` from `test/self/certification-runner.ts` with a `ProductBinding` of the fixture (`command: process.execPath`, `prefixArgs: [<abs bin path>]`) and `productTestSuite.select([...ids])` from `test/suite/registry/index.ts`, run it as `npx vitest run --config test/vitest.config.ts --project self test/self/<file>.test.ts`, and delete the file before committing. - Windows-leg subset (TEST-SPEC E-6; run by the Windows CI job): `npm run test:windows`. Build the product first. Its byte-identity test compares this leg's representative-fixture outputs against the Linux leg's, read from the directory named by `XSPEC_E6_EXCHANGE_DIR`; the Linux outputs are written by the suite project's E-6 writer test (`test/suite/e6-exchange-writer.test.ts`) whenever that variable is set during `npm test`, and CI exchanges them as the `e6-linux-outputs` artifact (`.github/workflows/ci.yml`). Against the stub product the whole subset is red (diagnosed failures) before any exchange is consulted; once the product conforms, run the Linux suite with the variable set, then the Windows subset with the same variable — with the variable unset or the directory absent, the byte-identity test fails loudly by design (never skips). The T11.6-1 drive-mismatch test (`test/windows/e6-drive-mismatch.test.ts`) additionally stages a substituted drive mapping (`subst`), which exists only on Windows: on any other platform it fails loudly after its same-drive premise arm (never skips), so a fully green `npm run test:windows` needs an actual Windows machine. - Local-only suite (TEST-SPEC E-2; separately invocable, never run in CI, currently empty): `npm run test:local`. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index f13402d..332f4cd 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,50 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage C — CONF-DISC code-group surface (fixture and suite) -### Task 10 — CONF-DISC conformer: code groups of well-formed `.ts` sources and `query edges --from <path>` - -Cites: CERTIFICATIONS.md §CONF-DISC Scope (revised): "code groups (7.2) of -well-formed `.ts` sources spelling no marker, spec-module import, or `text` -call (4) — each discovered code source an edgeless whole-file code location -(4.6)"; command surface "`query edges --from <path>` (11.1) as the observation -of the discovered code set — for a discovered code source's whole-file -location, exit 0 with its empty edge enumeration, the JSON document 11 makes -its only output form; for a path in no configured group, an excluded derived -path included, the usage error of 12.0 (exit 2, the error document of 12.7), a -check preceding the gate of 13.3"; the 13.4 exclusion applies to code globs too -(the module `build` generates next to a source, files under `.xspec/`, enabled -emit destinations). SPEC 7.2, 11.1, 12.0, 12.7, 13.1, 13.4. - -Now: `test/fixtures/conf-disc/product.mjs` refuses any non-empty `code` group -("outside this fixture's scope", ≈ line 526) and implements no `query` -command; `build` on a discovered spec source generates the next-to-source -module (13.1) that a code glob would match. - -Do: extend the conformer: accept `code` groups (same glob grammar, dot-segment -rule, no link following, byte-wise matching as the spec side; patterns -resolving outside the root → 14.14), discover `.ts` matches, exclude 13.4's -derived paths from the code set exactly as from the spec set (generated -modules next to sources, `.xspec/` paths, enabled emit destinations), treat -each discovered code source as an edgeless whole-file location (no parsing -needed beyond that the file is a well-formed `.ts` the scope stages; keep -14.14's both-groups rule dormant but implemented if cheap). Add `query edges ---from <path>`: for a discovered code source's whole-file location, exit 0 and -the empty edge enumeration in the exact 11/12.7 JSON form the harness's query -adapter decodes (`test/helpers/adapters/query.ts`; match the real product's -form byte-for-byte for the empty case); for a path in no configured group -(derived paths included), exit 2 with the 12.7 error document (stable code and -concerned path as the existing usage-error path renders them), decided before -any 13.3 gate. Keep every existing behavior and the other three violators' -switches unchanged. Update the file header's scope description. - -Verify: by hand on a staged workspace with a spec group and a code glob such as -`src/**/*.ts` that also matches `specs/*.ts` generated modules: after `build`, -`query edges --from src/a.ts` → exit 0, empty enumeration; `--from -specs/A.xspec.ts` (or whatever 13.1 name the fixture generates) → exit 2 error -document; `--from .xspec/graph.json` → exit 2. `npm run test:self` stays green -(T7-4..T7-6 as currently implemented still pass; each DISC violator still fails -exactly its set). - ### Task 11 — VIOL-DISC-DERIVED: code-group side of the dropped 13.4 exclusion Cites: CERTIFICATIONS.md §VIOL-DISC-DERIVED Deviation (revised): "a path … @@ -119,20 +75,28 @@ match — on the code side an edgeless whole-file location"; Expected failures: enabled destination — enters the discovered code set, so `query edges --from` answers it exit 0 where the arm asserts the unknown-path refusal (12.0)". -Now: the `noDerivedExclusion` switch in `test/fixtures/conf-disc/product.mjs` -(consumed ≈ line 1040) drops the exclusion on the spec side only; the -code-group side does not exist before Task 10. - -Do: make the same single switch also skip the 13.4 exclusion for code-group -matches (Task 10's code discovery), so `query edges --from <derived path -matched by the code glob>` answers exit 0 with an empty enumeration under -`bin-derived.mjs`; update `bin-derived.mjs`'s header comment to the revised -deviation text. No other behavior changes. Prerequisite: Task 10. - -Verify: by hand (Task 10's workspace): `node test/fixtures/conf-disc/ -bin-derived.mjs query edges --from specs/<generated module>` → exit 0, while -`bin.mjs` → exit 2; `npm run test:self` green. Once Task 12 lands, the -certification shows DERIVED failing T7-6 on both group sides. +Now: since Task 10 (the commit adding code groups and `query edges` to the +fixture), `discoverSources` in `test/fixtures/conf-disc/product.mjs` passes +the spec-glob matches and the code-glob matches through one 13.4 exclusion +filter, and `noDerivedExclusion` skips it for both kinds — verified by hand +on a workspace whose code globs match the generated module, a `.ts` file +under `.xspec/`, and the enabled emit destination: `bin-derived.mjs query +edges --from specs/A.xspec.ts` (likewise `--from .xspec/staged.ts` and +`--from specs/A.md`) → exit 0 with the empty enumeration, while `bin.mjs` → +exit 2. Only `bin-derived.mjs`'s header comment still describes the +spec-side-only deviation ("when matched by a spec-group glob … a non-`.mdx` +occupant then surfaces as 14.19"). + +Do: update `bin-derived.mjs`'s header comment to the revised deviation text +(both group sides — on the code side each such path an edgeless whole-file +location that `query edges --from` answers exit 0). No behavior changes. + +Verify: re-run the hand check above (stage `xspec.config.ts` with +`specs: { main: ["specs/*.mdx"] }`, `code: { src: ["src/**/*.ts", +"specs/*.ts", ".xspec/*.ts", "specs/*.md"] }`, `markdown: { emit: true }`, +one `specs/A.mdx`, `src/a.ts`, `.xspec/staged.ts`, `specs/A.md`; `build` +first); `npm run test:self` green. Once Task 12 lands, the certification +shows DERIVED failing T7-6 on both group sides. ### Task 12 — T7-6: code-group exclusion arm observed through `query edges --from` diff --git a/test/fixtures/conf-disc/product.mjs b/test/fixtures/conf-disc/product.mjs index 5d7614e..50e08f1 100644 --- a/test/fixtures/conf-disc/product.mjs +++ b/test/fixtures/conf-disc/product.mjs @@ -14,18 +14,25 @@ // file's directory to a discovered source, an undiscovered target failing // with 14.15; `markdown` with `emit: true` and default destinations, // classified by configuration alone (7.3); symbolic links present in the -// tree; no code groups (`code` appears only as the empty map), `coverage`, -// `policy`, or git; content of derived and emitted files beyond path is out -// of scope. +// tree; code groups (7.2) of well-formed `.ts` sources spelling no marker, +// spec-module import, or `text` call — each discovered code source an +// edgeless whole-file code location (4.6), nothing in scope giving a code +// file an edge — under the same glob grammar; no `coverage`, `policy`, or +// git; content of derived and emitted files beyond path is out of scope. // - Command surface: `build` and `ids` (12.3) as the observation of the -// discovered set, the configuration-error behavior of 14.14/12.0 for -// patterns resolving outside the workspace root, and the source-error -// reporting of 14.15. +// discovered spec set; `query edges --from <path>` (11.1) as the +// observation of the discovered code set — a discovered code source's +// whole-file location answers exit 0 with its empty edge enumeration, and +// a path in no configured group (an excluded derived path included) is the +// usage error of 12.0, a check preceding the gate of 13.3; the +// configuration-error behavior of 14.14/12.0 for patterns resolving +// outside the workspace root, and the source-error reporting of 14.15 and +// 14.19. // - Contracts under certification: glob semantics of 7 — `*`, `?`, `**`, // byte-wise case-sensitive matching, the dot-segment rule, every other // character a literal — discovery's refusal to follow symbolic links, and // the source exclusion of 13.4 (`.xspec.` names, `.xspec/` paths, and -// enabled Markdown emit destinations in no group). +// enabled Markdown emit destinations in no spec or code group). // // Key mechanisms: // - The glob matcher is a port of the harness oracle's discovery half @@ -50,14 +57,23 @@ // the entire stdout, and without it stdout stays empty. // - Discovery pipeline order (SPEC 7, 13.4): walk plain files (symbolic links // never discovered, never traversed — so link cycles cannot hang the walk), -// match the union of all groups' globs, then apply the 13.4 source -// exclusion to the matches — paths whose file name contains `.xspec.`, -// files under `.xspec/`, and, exactly while `markdown.emit` is true, the -// default emit destinations (`X.md` beside each discovered `X.mdx` source; +// match the spec groups' globs and the code groups' globs — one matcher, +// the same grammar, dot-segment rule, and byte-wise comparison on both +// sides — then apply the 13.4 source exclusion to the matches of either +// kind — paths whose file name contains `.xspec.`, files under `.xspec/`, +// and, exactly while `markdown.emit` is true, the default emit +// destinations (`X.md` beside each discovered `X.mdx` spec source; // destinations exist by configuration alone, whether or not emission has -// run, 7.3). A surviving match without the `.mdx` extension, or whose path -// contains `#`, is a 14.19 finding; exclusion precedes that check, so an -// excluded occupant of an emit destination is silently no source. +// run, 7.3) — so the module `build` generates beside a source is excluded +// from a code glob exactly as from a spec glob. A surviving spec match +// without the `.mdx` extension, or a surviving match of either kind whose +// path contains `#`, is a 14.19 finding; exclusion precedes that check, so +// an excluded occupant of an emit destination is silently no source. A +// surviving code match is a discovered code source as it stands: nothing +// in scope gives a code file an edge, so it is never read — its whole-file +// location is the only graph node it contributes (4.6). The both-groups +// rule of 14.14 stays dormant (the staged code globs match no spec-group +// file); no check for it is implemented here. // - Sources are scanned by a hand-rolled MDX-lite lexer for exactly the // scope's constructs: spec module imports at line start (2.1) and // `<S>`/`<Spec>` opening/self-closing/closing tags with quoted or braced @@ -82,6 +98,24 @@ // workspace-relative path (UTF-8 byte comparison, not code-unit order), IDs // within a file in document order; `--json` emits the single JSON document // as the entire stdout (12.0). +// - `query edges [--from <graph-node>]` (11.1): JSON-only — the single edge +// enumeration `{"edges": [{"from", "to", "kind"}, …]}` is the entire +// stdout with or without `--json`, and so is the 12.7 error document on +// exit 2 (12.0). The `--from` check runs after configuration loading and +// before the 13.3 gate (12.0): a graph node is a discovered code source's +// whole-file location (its path), a discovered spec source's root (its +// path), or a section it spells (`path#id`); any other spelling — a +// derived path the exclusion kept out of every group above all — is the +// unknown-graph-node usage error, exit 2, whatever findings the workspace +// carries; an unparseable named spec file masks the identity check and +// the gate reports the findings (exit 1). The answer is recomputed from +// sources as `ids` does: a code location has no edges; a spec root's +// outgoing edges are `contains` to its top-level sections and a section's +// `contains` to its direct children (5.2) — the scope's sources spell no +// reference, so no other kind arises; without `--from`, every edge, files +// in byte order. `--to`, `--kinds`, named code units, and the other +// `query` subcommands are outside this fixture's scope and are refused +// loudly (exit 70, outside the 12.0 partition, never a false answer). // // Determinism (SPEC 12.0): no wall clock, no randomness, no absolute paths in // any output; files in byte order of workspace-relative path; all JSON is @@ -102,11 +136,14 @@ // broken links stay ignored and directory links stay untraversed, so the // walk still terminates and T7-5 fails by assertion, not by hang. // - §VIOL-DISC-DERIVED (CERT-17, bin-derived.mjs): `noDerivedExclusion`, -// consumed in `discoverSources`' exclusion filter — the 13.4 source -// exclusion is not applied to glob matches, so `.xspec.`-named files, -// files under `.xspec/` (where a pattern spells the dot segment), and -// occupants of enabled emit destinations are treated as ordinary matches -// (a non-`.mdx` occupant then surfaces as 14.19). +// consumed in `discoverSources`' exclusion filter — the one filter both +// group kinds pass through — so the 13.4 source exclusion is not applied +// to glob matches of either kind: `.xspec.`-named files, files under +// `.xspec/` (where a pattern spells the dot segment), and occupants of +// enabled emit destinations are treated as ordinary matches — on the +// spec side a non-`.mdx` occupant then surfaces as 14.19; on the code +// side each such path enters the discovered code set as an edgeless +// whole-file location, so `query edges --from` answers it exit 0. import { Buffer } from "node:buffer"; import * as fsp from "node:fs/promises"; @@ -143,6 +180,16 @@ class FindingsError extends Error { } } +/** + * An invocation outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC) + * that a conforming product would answer — a `query` subcommand, flag, or + * graph-node form no in-scope observation uses. Refused loudly with an exit + * code outside the 12.0 partition (as a crash is, below), so a test reaching + * it fails on its exit-code assertion with the cause on stderr — never on a + * fabricated answer or a false usage error. + */ +class ScopeError extends Error {} + /** * @typedef {{ condition: string, message: string, file?: string, * location?: { start: number, end: number } }} Finding @@ -168,9 +215,13 @@ function sortKeysDeep(value) { return value; } -/** One canonical serializer for emitted JSON. */ +/** + * One canonical serializer for emitted JSON: byte-sorted keys, two-space + * indentation — the real product's form, so the empty edge enumeration of + * `query edges` (`{"edges": []}`) matches it byte for byte. + */ function canonicalJson(value) { - return JSON.stringify(sortKeysDeep(value)); + return JSON.stringify(sortKeysDeep(value), null, 2); } /** Whether anything (file, directory, or symlink) occupies the path. */ @@ -190,7 +241,8 @@ async function pathOccupied(absPath) { // header for the hook point each named switch is consumed at: // `dialectMetachars` (§VIOL-DISC-DIALECT → parseSegment), // `followFileSymlinks` (§VIOL-DISC-SYMLINK → walkPlainFiles), -// `noDerivedExclusion` (§VIOL-DISC-DERIVED → discoverSources). +// `noDerivedExclusion` (§VIOL-DISC-DERIVED → discoverSources, both group +// kinds). // --------------------------------------------------------------------------- let deviations = {}; @@ -445,11 +497,33 @@ function validatedPattern(glob, groupName) { return normalized; } +/** + * Validate one group map (SPEC 7.1 spec groups, 7.2 code groups — the same + * glob grammar and pattern rules on both sides): each group a list of glob + * strings, each pattern validated and resolved by {@link validatedPattern}. + * @param {Record<string, unknown>} map @param {string} kind + * @param {string} section @returns {Record<string, string[]>} + */ +function validatedGroups(map, kind, section) { + /** @type {Record<string, string[]>} */ + const groups = {}; + for (const [name, globs] of Object.entries(map)) { + if (!Array.isArray(globs)) { + throw new UsageError( + `configuration error: ${kind} group ${name} must be a list of glob strings (SPEC ${section})`, + ); + } + groups[name] = globs.map((glob) => validatedPattern(glob, name)); + } + return groups; +} + /** * Load and validate the configuration; returns the workspace root, the spec - * groups (patterns validated and resolved), and the emission switch. The - * in-scope shape (CERTIFICATIONS.md §CONF-DISC) is spec groups of glob - * strings, an optional `code` that MUST be the empty map, and an optional + * groups and the code groups (patterns validated and resolved under one + * grammar, SPEC 7), and the emission switch. The in-scope shape + * (CERTIFICATIONS.md §CONF-DISC) is spec groups and code groups of glob + * strings (either map may be empty) and an optional * `markdown: { emit: boolean }` with default destinations — no `outDir`, no * `coverage` or `policy` keys; anything else is refused loudly as a * configuration error rather than half-implemented (SPEC 7, 14.14). @@ -501,16 +575,9 @@ async function parseAndValidateConfig(configPath) { "configuration error: `specs` is required and must be a map of groups (SPEC 7)", ); } + const groups = validatedGroups(specs, "spec", "7.1"); /** @type {Record<string, string[]>} */ - const groups = {}; - for (const [name, globs] of Object.entries(specs)) { - if (!Array.isArray(globs)) { - throw new UsageError( - `configuration error: spec group ${name} must be a list of glob strings (SPEC 7.1)`, - ); - } - groups[name] = globs.map((glob) => validatedPattern(glob, name)); - } + let codeGroups = {}; if (data.code !== undefined) { const code = data.code; if (code === null || typeof code !== "object" || Array.isArray(code)) { @@ -518,14 +585,7 @@ async function parseAndValidateConfig(configPath) { "configuration error: `code` must be a map of groups (SPEC 7.2)", ); } - if (Object.keys(code).length > 0) { - // SPEC 7 allows code groups; §CONF-DISC's scope does not (`code` - // appears only as the empty map). Refuse loudly rather than - // half-implement code discovery. - throw new UsageError( - "configuration error: non-empty `code` groups are outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC; SPEC 7.2, 14.14)", - ); - } + codeGroups = validatedGroups(code, "code", "7.2"); } let emit = false; if (data.markdown !== undefined) { @@ -553,7 +613,7 @@ async function parseAndValidateConfig(configPath) { } emit = markdown.emit; } - return { root: path.dirname(configPath), groups, emit }; + return { root: path.dirname(configPath), groups, codeGroups, emit }; } // --------------------------------------------------------------------------- @@ -1002,31 +1062,40 @@ function isXspecClassified(rel) { /** * Discover the workspace's sources (SPEC 7, 13.4): walk plain files, match - * the union of all spec groups' globs, apply the 13.4 source exclusion, and - * validate surviving matches' paths (14.19). Returns byte-ordered sources - * plus any 14.19 findings. + * the spec groups' globs and the code groups' globs, apply the 13.4 source + * exclusion to the matches of either kind, and validate surviving matches' + * paths (14.19). Returns the byte-ordered spec sources and code sources plus + * any 14.19 findings. A code source is discovered by path alone: nothing in + * scope gives a code file an edge, so it is never read — its whole-file + * location is its only graph node (4.6). * * §VIOL-DISC-DERIVED hook (CERT-17, bin-derived.mjs): the exclusion filter - * below is the deviation's single consumption point — under - * `noDerivedExclusion` the 13.4 exclusion is skipped and every glob match is - * an ordinary match, so an excluded-under-the-conformer path enters the - * discovered set (or, lacking `.mdx`, surfaces as 14.19); glob semantics, + * below — the one filter both group kinds pass through — is the deviation's + * single consumption point: under `noDerivedExclusion` the 13.4 exclusion is + * skipped and every glob match of either kind is an ordinary match, so an + * excluded-under-the-conformer path enters the discovered spec set (or, + * lacking `.mdx`, surfaces as 14.19) or the discovered code set (an edgeless + * whole-file location, answered by `query edges --from`); glob semantics, * the dot-segment rule, link behavior, and the import and empty-map rules * are unchanged. */ async function discoverSources(config) { const walked = await walkPlainFiles(config.root); walked.sort(compareUtf8); - const allPatterns = Object.values(config.groups).flat(); - const matched = walked.filter((rel) => - allPatterns.some((pattern) => globMatches(pattern, rel)), + const matchingAny = (patterns) => (rel) => + patterns.some((pattern) => globMatches(pattern, rel)); + const specMatched = walked.filter( + matchingAny(Object.values(config.groups).flat()), + ); + const codeMatched = walked.filter( + matchingAny(Object.values(config.codeGroups).flat()), ); // The enabled Markdown emit destinations exist by configuration alone // (SPEC 7.3): `X.md` beside each discovered `X.mdx` spec source, whether or // not emission has run. Destination paths never end in `.mdx`, so this - // exclusion can never remove a source and the provisional set below is the - // final source set. - const provisional = matched.filter( + // exclusion can never remove a spec source and the provisional set below + // is the final spec source set. + const provisional = specMatched.filter( (rel) => !isXspecClassified(rel) && rel.endsWith(".mdx"), ); const destinations = new Set( @@ -1036,21 +1105,24 @@ async function discoverSources(config) { ); const excluded = (rel) => isXspecClassified(rel) || destinations.has(rel); // §VIOL-DISC-DERIVED (CERT-17): under `noDerivedExclusion` the 13.4 - // exclusion is skipped entirely — every glob match is an ordinary match. - const kept = deviations.noDerivedExclusion - ? matched - : matched.filter((rel) => !excluded(rel)); + // exclusion is skipped entirely — every glob match of either kind is an + // ordinary match. + const kept = (matched) => + deviations.noDerivedExclusion + ? matched + : matched.filter((rel) => !excluded(rel)); /** @type {Finding[]} */ const findings = []; + const hashFinding = (rel) => ({ + condition: "14.19", + message: `invalid source path: the discovered path ${JSON.stringify(rel)} contains "#" (SPEC 7, 1.5, 14.19)`, + file: rel, + }); /** @type {string[]} */ const sources = []; - for (const rel of kept) { + for (const rel of kept(specMatched)) { if (rel.includes("#")) { - findings.push({ - condition: "14.19", - message: `invalid source path: the discovered path ${JSON.stringify(rel)} contains "#" (SPEC 7, 1.5, 14.19)`, - file: rel, - }); + findings.push(hashFinding(rel)); continue; } if (!rel.endsWith(".mdx")) { @@ -1063,7 +1135,16 @@ async function discoverSources(config) { } sources.push(rel); } - return { sources, findings }; + /** @type {string[]} */ + const codeSources = []; + for (const rel of kept(codeMatched)) { + if (rel.includes("#")) { + findings.push(hashFinding(rel)); + continue; + } + codeSources.push(rel); + } + return { sources, codeSources, findings }; } // --------------------------------------------------------------------------- @@ -1111,7 +1192,10 @@ const IMPORT_RE = * { at, message } (an unparseable source, SPEC 14.20). */ function parseMdx(text) { - /** @type {{ id: string | null, openStart: number, openEnd: number }[]} */ + // `depth` is the section's nesting depth — the number of open sections + // enclosing it; 0 for a top-level section — from which `query edges` + // derives the `contains` edges (SPEC 5.2). + /** @type {{ id: string | null, openStart: number, openEnd: number, depth: number }[]} */ const sections = []; /** @type {{ binding: string, specifier: string, start: number, end: number }[]} */ const imports = []; @@ -1234,7 +1318,7 @@ function parseMdx(text) { } if (name === "id" && quoted !== undefined) id = quoted; } - sections.push({ id, openStart: i, openEnd: j }); + sections.push({ id, openStart: i, openEnd: j, depth }); if (!selfClosing) depth += 1; i = j; continue; @@ -1319,10 +1403,12 @@ function resolveImportTarget(fromRel, specifier) { const RESERVED_BINDINGS = new Set(["S", "Spec", "text"]); /** - * Load the workspace: configuration, discovery, every discovered source's - * analysis, and import resolution. Files in byte order of workspace-relative - * path — deterministic (SPEC 12.0). An unparseable file (14.20) masks the - * conditions inside itself (SPEC 14). + * Load the workspace: configuration, discovery, every discovered spec + * source's analysis, and import resolution; the discovered code sources are + * carried by path alone (never read — edgeless whole-file locations, 4.6). + * Files in byte order of workspace-relative path — deterministic (SPEC + * 12.0). An unparseable file (14.20) masks the conditions inside itself + * (SPEC 14). */ async function loadWorkspace(cwd, configFlag) { const config = await loadConfig(cwd, configFlag); @@ -1379,7 +1465,7 @@ async function loadWorkspace(cwd, configFlag) { } } } - return { config, files, findings }; + return { config, files, codeSources: discovery.codeSources, findings }; } // --------------------------------------------------------------------------- @@ -1624,6 +1710,152 @@ async function commandIds(io, cwd, argv) { return 0; } +const QUERY_FLAGS = { + "--from": "value", + "--to": "value", + "--kinds": "value", + "--json": "bool", + "--config": "value", +}; + +/** JSON-only surfaces (SPEC 11): the single document is the entire stdout. */ +function emitJsonOnly(io, doc) { + io.stdout(canonicalJson(doc) + "\n"); +} + +/** + * The `contains` edges (SPEC 5.2) a parsed spec source contributes: root → + * each top-level section, and each section → its direct children, in + * document order of the containing node, then of the contained one. Section + * identities are `path#id` over the spelled (chain-form, 1.3) IDs; callers + * answer only past the 13.3 gate, so every section here spells one. + * @param {{ rel: string, sections: { id: string | null, depth: number }[] }} record + * @returns {{ from: string, to: string, kind: string }[]} + */ +function containsEdges(record) { + const edges = []; + const identity = (section) => `${record.rel}#${String(section.id)}`; + const { sections } = record; + for (let i = -1; i < sections.length; i += 1) { + const from = i < 0 ? record.rel : identity(sections[i]); + const depth = i < 0 ? -1 : sections[i].depth; + for (let j = i + 1; j < sections.length; j += 1) { + if (sections[j].depth <= depth) break; + if (sections[j].depth === depth + 1) { + edges.push({ from, to: identity(sections[j]), kind: "contains" }); + } + } + } + return edges; +} + +/** The unknown-graph-node usage error of SPEC 11.1/12.0 for `--from`. */ +function unknownGraphNode(spelling) { + return new UsageError( + `query edges: unknown graph node ${JSON.stringify(spelling)} for --from — expected a requirement node (path#id, or a bare path for a spec source's root) or a discovered code source's whole-file location; a path in no configured group is unknown (SPEC 11.1, 1.5, 4.6, 12.0)`, + ); +} + +/** + * `xspec query edges [--from <graph-node>]` (SPEC 11.1, scoped): the + * observation of the discovered code set. JSON-only (11, 12.0). The `--from` + * check (12.0) runs after configuration loading — a configuration error + * precedes it — and before the 13.3 gate: the spelling must name a graph + * node of the current discovery — a discovered code source's whole-file + * location (its path, 4.6), a discovered spec source's root (its path), or + * a section it spells (`path#id`) — else it is the unknown-graph-node usage + * error, exit 2, whatever findings the workspace carries; a derived path the + * 13.4 exclusion kept out of every group is such an unknown path. An + * unparseable named spec file masks its identity check, the gate then + * reporting the findings (exit 1, 12.0). Past the gate the answer is + * recomputed from sources as `ids` does: a code location has no edges + * (nothing in scope gives a code file one), a spec node's outgoing edges are + * its `contains` edges (5.2), and without `--from` every edge is enumerated, + * files in byte order. `--to`, `--kinds`, named code units, and the other + * `query` subcommands are outside this fixture's scope (ScopeError, exit + * 70). + */ +async function commandQuery(io, cwd, argv) { + const subcommand = argv[0]; + if (subcommand === undefined) { + throw new UsageError("query: missing subcommand (SPEC 11.1, 12.0)"); + } + if (subcommand !== "edges") { + const known = ["node", "nodes", "subtree", "ancestors", "reachable"]; + if (known.includes(subcommand)) { + throw new ScopeError( + `query ${subcommand} is outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC: query edges --from <path> only)`, + ); + } + throw new UsageError( + `query: unknown subcommand ${subcommand} (SPEC 11.1, 12.0)`, + ); + } + const { flags } = parseArgs(argv.slice(1), QUERY_FLAGS, [0, 0]); + for (const flag of ["--to", "--kinds"]) { + if (flags[flag] !== undefined) { + throw new ScopeError( + `query edges ${flag} is outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC: query edges --from <path> only)`, + ); + } + } + // Syntax alone (SPEC 12.0): a graph-node spelling holds at most one `#`, a + // non-empty path part, and, when a `#` is present, a non-empty id part — + // judged before configuration is loaded. + const from = flags["--from"]; + let fromPath = null; + let fromId = null; + if (from !== undefined) { + const parts = from.split("#"); + if (parts.length > 2 || parts[0] === "" || parts[1] === "") { + throw new UsageError( + `query edges: malformed graph-node identity ${JSON.stringify(from)} for --from (SPEC 1.5, 12.0)`, + ); + } + fromPath = parts[0]; + fromId = parts[1] ?? null; + } + const ws = await loadWorkspace(cwd, flags["--config"]); + // The argument check precedes the gate (SPEC 12.0), judged from the + // current discovery: a path in no configured group is unknown (11.1). + let record = null; + if (fromPath !== null) { + if (ws.codeSources.includes(fromPath)) { + if (fromId !== null) { + throw new ScopeError( + `named code units (${JSON.stringify(from)}) are outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC: whole-file code locations only)`, + ); + } + } else if (ws.files.has(fromPath)) { + record = ws.files.get(fromPath); + // An unparseable named file masks the identity check; the gate below + // reports its findings (12.0). + if ( + fromId !== null && + record.failure === null && + !record.sections.some((section) => section.id === fromId) + ) { + throw unknownGraphNode(from); + } + } else { + throw unknownGraphNode(from); + } + } + if (ws.findings.length > 0) { + throw new FindingsError(ws.findings); + } + let edges; + if (fromPath === null) { + edges = [...ws.files.values()].flatMap(containsEdges); + } else if (record === null) { + edges = []; // a whole-file code location: edgeless in scope (4.6) + } else { + edges = containsEdges(record).filter((edge) => edge.from === from); + } + emitJsonOnly(io, { edges }); + return 0; +} + // --------------------------------------------------------------------------- // Entry: deviation seam + dispatch // --------------------------------------------------------------------------- @@ -1645,7 +1877,9 @@ export async function runXspec(argv, cwd, options = {}) { /** Dispatch one parsed invocation and map its outcome to SPEC 12.0's codes. */ async function dispatchCommand(io, cwd, argv) { - const wantsJson = argv.includes("--json"); + // JSON output is in effect with `--json`, or when the invoked surface is + // JSON-only (SPEC 12.0) — `query` (11) — governing error delivery too. + const wantsJson = argv.includes("--json") || argv[0] === "query"; try { const command = argv[0]; const rest = argv.slice(1); @@ -1654,9 +1888,11 @@ async function dispatchCommand(io, cwd, argv) { return await commandBuild(io, cwd, rest); case "ids": return await commandIds(io, cwd, rest); + case "query": + return await commandQuery(io, cwd, rest); default: throw new UsageError( - `unknown command ${String(command)} (SPEC 12.0; this fixture's surface is build and ids, CERTIFICATIONS.md §CONF-DISC)`, + `unknown command ${String(command)} (SPEC 12.0; this fixture's surface is build, ids, and query edges, CERTIFICATIONS.md §CONF-DISC)`, ); } } catch (error) { @@ -1688,6 +1924,13 @@ async function dispatchCommand(io, cwd, argv) { emitFindings(io, wantsJson, error.findings); return 1; } + if (error instanceof ScopeError) { + // Outside this fixture's scope (CERTIFICATIONS.md §CONF-DISC): refused + // loudly, outside the 12.0 partition — never answered, never + // misreported as a usage error. + io.stderr(`xspec: fixture scope error: ${error.message}\n`); + return 70; + } // A crash is a fixture bug: exit outside the 12.0 partition so every // exit-code assertion fails loudly and the diagnosis carries the stack. io.stderr( From 39b659d9353581527d430a31d544d0b5020b80c3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 07:03:11 +0000 Subject: [PATCH 196/226] =?UTF-8?q?sdg(phase-9):=20VIOL-DISC-DERIVED=20hea?= =?UTF-8?q?der=20=E2=80=94=20describe=20the=20revised=20deviation=20on=20b?= =?UTF-8?q?oth=20group=20sides=20(Task=2011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite test/fixtures/conf-disc/bin-derived.mjs's header comment to CERTIFICATIONS.md §VIOL-DISC-DERIVED's revised deviation: a 13.4-excluded path matched by a spec-group or code-group glob is an ordinary match — on the spec side it enters the discovered spec set (a non-`.mdx` occupant then 14.19); on the code side an edgeless whole-file location in the discovered code set, so `query edges --from` answers it exit 0 where the conformer refuses the unknown path (12.0) — one rule of 13.4 dropped at product.mjs's single exclusion filter that both group kinds pass through; glob semantics, the dot-segment rule, link behavior, 14.19 for non-`.mdx` matches, and the import and empty-map rules unchanged. No behavior change. Verified by hand on the plan's staged workspace (spec glob `specs/*.mdx`; code globs `src/**/*.ts`, `specs/*.ts`, `.xspec/*.ts`, `specs/*.md`; emission enabled; `build` first): `bin.mjs query edges --from` answers `src/a.ts` exit 0 and refuses `specs/A.xspec.ts`, `.xspec/staged.ts`, `specs/A.md` exit 2 (unknown graph node), while `bin-derived.mjs` answers all four exit 0 with the empty enumeration — identical before and after the edit. `npm run test:self`: 17 files, 338/338 passed (C-1 holds); typecheck and format clean. FIX_PLAN.md: Task 11 removed (Stage C continues with Task 12). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 33 ------------------------- test/fixtures/conf-disc/bin-derived.mjs | 20 ++++++++++----- 2 files changed, 14 insertions(+), 39 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 332f4cd..1e78171 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,39 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage C — CONF-DISC code-group surface (fixture and suite) -### Task 11 — VIOL-DISC-DERIVED: code-group side of the dropped 13.4 exclusion - -Cites: CERTIFICATIONS.md §VIOL-DISC-DERIVED Deviation (revised): "a path … -when matched by a spec-group or code-group glob, is treated as an ordinary -match — on the code side an edgeless whole-file location"; Expected failures: -"on the code-group side, each excluded path the code glob matches — the module -`build` generated next to its source, the staged file under `.xspec/`, the -enabled destination — enters the discovered code set, so `query edges --from` -answers it exit 0 where the arm asserts the unknown-path refusal (12.0)". - -Now: since Task 10 (the commit adding code groups and `query edges` to the -fixture), `discoverSources` in `test/fixtures/conf-disc/product.mjs` passes -the spec-glob matches and the code-glob matches through one 13.4 exclusion -filter, and `noDerivedExclusion` skips it for both kinds — verified by hand -on a workspace whose code globs match the generated module, a `.ts` file -under `.xspec/`, and the enabled emit destination: `bin-derived.mjs query -edges --from specs/A.xspec.ts` (likewise `--from .xspec/staged.ts` and -`--from specs/A.md`) → exit 0 with the empty enumeration, while `bin.mjs` → -exit 2. Only `bin-derived.mjs`'s header comment still describes the -spec-side-only deviation ("when matched by a spec-group glob … a non-`.mdx` -occupant then surfaces as 14.19"). - -Do: update `bin-derived.mjs`'s header comment to the revised deviation text -(both group sides — on the code side each such path an edgeless whole-file -location that `query edges --from` answers exit 0). No behavior changes. - -Verify: re-run the hand check above (stage `xspec.config.ts` with -`specs: { main: ["specs/*.mdx"] }`, `code: { src: ["src/**/*.ts", -"specs/*.ts", ".xspec/*.ts", "specs/*.md"] }`, `markdown: { emit: true }`, -one `specs/A.mdx`, `src/a.ts`, `.xspec/staged.ts`, `specs/A.md`; `build` -first); `npm run test:self` green. Once Task 12 lands, the certification -shows DERIVED failing T7-6 on both group sides. - ### Task 12 — T7-6: code-group exclusion arm observed through `query edges --from` Cites: TEST-SPEC T7-6 ("Derived files are never discovered as sources even when diff --git a/test/fixtures/conf-disc/bin-derived.mjs b/test/fixtures/conf-disc/bin-derived.mjs index fec16d8..acff432 100644 --- a/test/fixtures/conf-disc/bin-derived.mjs +++ b/test/fixtures/conf-disc/bin-derived.mjs @@ -3,12 +3,20 @@ // §VIOL-DISC-DERIVED). The CONF-DISC conformer with exactly one behavioral // deviation: discovery does not apply the source exclusion of 13.4 — a path // whose file name contains `.xspec.`, a file under `.xspec/`, or a file at -// an enabled Markdown emit destination, when matched by a spec-group glob, -// is treated as an ordinary match (a non-`.mdx` occupant then surfaces as -// 14.19). Glob semantics, the dot-segment rule, link behavior, and the -// import and empty-map rules are unchanged. Certifies T7-6 (C-1): exactly it -// fails against this fixture — on its exclusion arms — while every other -// §CONF-DISC in-scope test passes. +// an enabled Markdown emit destination, when matched by a spec-group or +// code-group glob, is treated as an ordinary match: on the spec side it +// enters the discovered spec set (a non-`.mdx` occupant then surfaces as +// 14.19); on the code side it enters the discovered code set as an edgeless +// whole-file location, as every discovered code source of the scope is, so +// `query edges --from` answers it exit 0 where the conformer refuses the +// unknown path (12.0). A single deviation: one rule of 13.4 (derived files +// are never sources) dropped, consumed at product.mjs's one exclusion +// filter that both group kinds pass through. Glob semantics, the +// dot-segment rule, link behavior, 14.19 for non-`.mdx` matches, and the +// import and empty-map rules are unchanged. Certifies T7-6 (C-1): exactly +// it fails against this fixture — on its exclusion arms, spec-group and +// code-group sides alike — while every other §CONF-DISC in-scope test +// passes. import { runXspec } from "./product.mjs"; const code = await runXspec(process.argv.slice(2), process.cwd(), { From 5a98aac46e2ab44a624c73377dd3318c364c8de3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 07:14:41 +0000 Subject: [PATCH 197/226] =?UTF-8?q?sdg(phase-9):=20T7-6=20code-group=20exc?= =?UTF-8?q?lusion=20arm=20=E2=80=94=20each=20excluded=20code-glob=20match?= =?UTF-8?q?=20refused=20by=20`query=20edges=20--from`,=20beside=20the=20di?= =?UTF-8?q?scovered=20source's=20edgeless=20answer=20(Task=2012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 12 of the Phase 9 re-descent plan (specs/tmp/FIX_PLAN.md), removed together with the now-empty Stage C heading; 35 tasks remain, Stage D next. test/suite/registry/section-7-discovery.ts, T7-6 arm (a'): a fresh workspace with spec glob `specs/*.mdx` and a code group whose globs `src/**/*.ts`, `specs/*.ts`, `.xspec/*.ts`, `specs/*.md` match one well-formed code source (src/plain.ts — no marker, spec-module import, or `text` call), the module `build` generates beside specs/A.mdx (specs/A.xspec.ts, 13.1), a staged `.ts` file under `.xspec/`, and, with emission enabled, the emit destination specs/A.md — and no spec-group file (14.14's both-groups rule dormant); no bracket or brace character, no symbolic link (the DIALECT and SYMLINK staging constraints). After `build` (exit 0) and a premise check that each excluded path exists as a plain file, `query edges --from src/plain.ts` answers exit 0 with an empty edge enumeration decoded through `decodeEdgesReport` (the query adapter), and `--from` each excluded path is the usage error of 12.0: exit 2 through `expectExit`, the 12.7 error document through `expectErrorDocument`, a non-empty stderr diagnostic, and a modifies-nothing compare (`assertLeavesUnchanged`) around the refusal — T7-3's idiom for code discovery, no product-specific wording asserted. Module header and the T7-6 comment block describe both group sides; title extended. Verification: test/suite/section-7-discovery.test.ts passes against the built product (3/3; the product already refuses each excluded path and answers `{"edges": []}` for src/plain.ts). `npm run test:self`: 17 files, 338/338 — the CONF-DISC conformer, VIOL-DISC-DIALECT, and VIOL-DISC-SYMLINK pass T7-6, VIOL-DISC-DERIVED fails it (C-1 exact); by hand on the arm's staging, bin-derived.mjs answers specs/A.xspec.ts, .xspec/staged.ts, and specs/A.md exit 0 where bin.mjs answers exit 2, so the code-side arm discriminates on its own. typecheck and format clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 47 +---- test/suite/registry/section-7-discovery.ts | 216 ++++++++++++++++++++- 2 files changed, 211 insertions(+), 52 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 1e78171..d52e3d0 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -46,9 +46,9 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. failure; a harness error (crash, stack overflow, exhausted limit, decode exception outside the assertion protocol) is never acceptable. For a test in a certification scope, additionally run `npm run test:self` and confirm C-1. -- Work top to bottom (Stages A and B — the certification gate and the H-11 - answer-scale capacity work — are complete): Stage C adds the CONF-DISC - code-group surface; Stage D is the remaining suite gaps in section order +- Work top to bottom (Stages A–C — the certification gate, the H-11 + answer-scale capacity work, and the CONF-DISC code-group surface — are + complete): Stage D is the remaining suite gaps in section order (independent of each other unless a task names a prerequisite). - Commit `sdg(phase-9): <imperative summary>`, ending every commit message with the two trailer lines @@ -63,47 +63,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. --- -## Stage C — CONF-DISC code-group surface (fixture and suite) - -### Task 12 — T7-6: code-group exclusion arm observed through `query edges --from` - -Cites: TEST-SPEC T7-6 ("Derived files are never discovered as sources even when -globs match them (`.xspec.` names, `.xspec/` paths, Markdown emit destinations -while emission is enabled, 13.4)" — for every group kind); CERTIFICATIONS.md -§CONF-DISC staging constraints ("T7-6's exclusion arms are staged on both group -sides — the spec side observed through `ids`, the code side through `query -edges --from <path>` (11.1): on a workspace passing `build`'s validations, each -excluded path a code glob matches — the module `build` generated next to its -source (13.1) above all, a file under `.xspec/`, and an enabled emit -destination — is refused as a path in no configured group (exit 2, 12.0), -beside a discovered code source's whole-file location answering exit 0, T7-3's -idiom for code discovery; and the staged code globs match, beyond those -derived-classified paths, only the well-formed `.ts` sources above, no -spec-group file among them"); SPEC 7.2, 11.1, 12.0, 13.1, 13.4. - -Now: `test/suite/registry/section-7-discovery.ts` T7-6 (≈ lines 599–812) has -derived-file exclusion arms observed through `ids` only (spec side), the -import arms, the no-match and empty-map arms; no code group, no `query edges`. - -Do: add a code-group exclusion arm to T7-6: configuration with a spec group -and a code group whose glob (e.g. `**/*.ts` under the root, or a pattern -covering `specs/` and `src/`) matches one well-formed `.ts` code source -(no marker, no spec import, no `text` call), the module `build` generates next -to the spec source, a staged `.ts` file under `.xspec/`, and, with `markdown` -emission enabled, a staged file at a source's emit destination — and matches no -spec-group file; `build` → exit 0; then `query edges --from <code source>` → -exit 0 with an empty edge enumeration (decoded through the query adapter); -`query edges --from <each excluded path>` → exit 2 with the 12.7 error document -(the existing usage-error decoding), never a finding, nothing modified. Use -the query adapter's existing `edges` decoding; do not add product-specific -wording assertions. - -Verify: `npx vitest run --config test/vitest.config.ts --project suite -test/suite/section-7-discovery.test.ts` (passes against the built product or a -diagnosed product failure); `npm run test:self`: CONF-DISC conformer passes -T7-6 (Task 10), VIOL-DISC-DERIVED fails exactly T7-6 (Task 11), DIALECT and -SYMLINK unchanged. - ## Stage D — remaining suite gaps, in TEST-SPEC section order ### Task 13 — T1.2-3: locate the root's ignored reason through the adapter, not a literal diff --git a/test/suite/registry/section-7-discovery.ts b/test/suite/registry/section-7-discovery.ts index 447cf1e..525eb58 100644 --- a/test/suite/registry/section-7-discovery.ts +++ b/test/suite/registry/section-7-discovery.ts @@ -33,6 +33,15 @@ // `markdown` is absent, and wildcards never match the dot segment — the // CERTIFICATIONS.md CONF-DISC staging constraints for these two tests. // +// T7-6's code-group exclusion arm observes the code side through `query +// edges --from <path>` (11.1), T7-3's idiom for code discovery: a discovered +// code source's whole-file location (4.6) answers exit 0 with its edge +// enumeration — empty, nothing staged here giving a code file an edge — +// while a path in no configured group, an excluded derived path above all, +// is unknown to `--from`: the usage error of 12.0, judged after +// configuration loading and before the 13.3 gate, exit 2 with the 12.7 +// error document (the CONF-DISC staging constraint for that arm). +// // Conservative operationalizations (H-3/H-4): // - Listing comparisons sort both sides bytewise by file path: these tests // assert discovery membership; the report's file ordering is 12.3's own @@ -51,7 +60,10 @@ import { Buffer } from "node:buffer"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; -import { decodeIdsReport } from "../../helpers/adapters/index.js"; +import { + decodeEdgesReport, + decodeIdsReport, +} from "../../helpers/adapters/index.js"; import { assertExitCode, fail, @@ -60,17 +72,22 @@ import { import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; -import { runProduct } from "../../helpers/subprocess.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import { runProduct, summarizeResult } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import type { WorkspaceDecl } from "../../helpers/workspace.js"; import { assertConditionCounts, + assertEdgeSetEqual, assertFindingLocated, assertSameJson, buildFindings, buildOk, byteWindow, expectConfigurationError, + expectErrorDocument, + expectExit, + runJson, } from "./support.js"; // --------------------------------------------------------------------------- @@ -601,9 +618,9 @@ const T7_5 = defineProductTest({ // Exclusion arms (SPEC 13.4: derived files are never sources — paths whose // file name contains `.xspec.`, files under `.xspec/`, and files at the -// configured Markdown emit destinations are excluded from every group), all -// staged over spec groups (the CERTIFICATIONS.md CONF-DISC staging -// constraint; `code` appears in this test only as the empty map): +// configured Markdown emit destinations are excluded from every spec and +// code group), staged on both group sides (the CERTIFICATIONS.md CONF-DISC +// staging constraint). The spec side, observed through `ids`: // // specs/A.mdx the one real source (id `a`) // specs/A.md user-authored file at A.mdx's emit destination — @@ -636,6 +653,87 @@ const EXCLUSION_EXPECTED: readonly ListingEntry[] = [ { file: "specs/A.mdx", ids: ["a"] }, ]; +// The code side, observed through `query edges --from <path>` (SPEC 11.1; +// module header). The spec group `specs/*.mdx` matches specs/A.mdx alone; +// the code group's globs match: +// +// src/plain.ts the one code source: well-formed TypeScript spelling +// no marker, no spec-module import, no `text` call +// (4) — discovered, its whole-file code location (4.6) +// a graph node with no edges +// specs/A.xspec.ts the module `build` generates beside specs/A.mdx +// (13.1), matched by `specs/*.ts` — a file the product +// itself wrote under an everyday code glob: excluded +// .xspec/staged.ts a staged `.ts` file under `.xspec/`, matched by +// `.xspec/*.ts` (the dot segment spelled literally): +// excluded +// specs/A.md specs/A.mdx's enabled Markdown emit destination +// (7.3) — user-authored before `build`, emitted by it +// — matched by `specs/*.md`: excluded +// +// and no spec-group file: `specs/*.md` does not match `specs/A.mdx`, so +// 14.14's both-groups rule stays dormant; `specs/*.ts` also matches +// whatever `.xspec.`-named companions the module has (13.1), derived like +// the module. No pattern carries a bracket or brace character and nothing +// is a symbolic link (the VIOL-DISC-DIALECT and VIOL-DISC-SYMLINK staging +// constraints). Each excluded path, in no configured group, is unknown to +// `--from` — the usage error of 12.0, judged after configuration loading +// and before the 13.3 gate: exit 2 with the 12.7 error document, never a +// finding, nothing modified — beside the discovered source's exit-0 control +// showing the group live. +const CODE_EXCLUSION_CONFIG = `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/*.mdx"] + }, + code: { + impl: ["src/**/*.ts", "specs/*.ts", ".xspec/*.ts", "specs/*.md"] + }, + markdown: { emit: true } +}) +`; + +/** The staged code source: well-formed, no marker, spec import, or text call. */ +const PLAIN_TS = "export const plain = 1;\n"; + +/** A well-formed `.ts` file staged under `.xspec/` — derived by path alone. */ +const STAGED_UNDER_XSPEC_TS = "export const staged = 2;\n"; + +/** The excluded paths the code globs match: each arm's premise and rule. */ +const CODE_EXCLUDED: readonly { + readonly path: string; + readonly premise: string; + readonly why: string; +}[] = [ + { + path: "specs/A.xspec.ts", + premise: + "NAME.mdx generates NAME.xspec.ts in the source file's directory " + + "(SPEC 13.1)", + why: + "the module `build` generated beside specs/A.mdx (13.1), matched by " + + "specs/*.ts", + }, + { + path: ".xspec/staged.ts", + premise: + "the staged file under .xspec/ is a derived path of nothing and a " + + "recorded derived file of nothing, so `build` neither replaces nor " + + "removes it (SPEC 12.1, 13.4)", + why: "a file under .xspec/, matched by .xspec/*.ts", + }, + { + path: "specs/A.md", + premise: + "with emission enabled, `build` emits specs/A.mdx's Markdown at " + + "specs/A.md (SPEC 13.2)", + why: + "specs/A.mdx's enabled Markdown emit destination (7.3), matched by " + + "specs/*.md", + }, +]; + // Import arms (SPEC 2.1/7: imports resolve references between files but // never add files to the workspace — the designated file must already be a // discovered source of a configured spec group, else 14.15). @@ -648,9 +746,10 @@ const T7_6 = defineProductTest({ title: "discovery boundaries: derived files (`.xspec.` names, `.xspec/` " + "paths, enabled Markdown emit destinations) are never discovered as " + - "sources even when globs match them; an import never adds an unmatched " + - "file (14.15); a no-match group and empty specs/code maps are valid " + - "with zero sources (SPEC 7, 13.4, 2.1)", + "sources of a spec or a code group even when globs match them — the " + + "code side observed through `query edges --from`; an import never " + + "adds an unmatched file (14.15); a no-match group and empty " + + "specs/code maps are valid with zero sources (SPEC 7, 13.4, 2.1, 11.1)", run: async (product) => { // (a) Derived-file exclusion, before and after a `build`. await withWorkspace( @@ -690,6 +789,107 @@ const T7_6 = defineProductTest({ }, ); + // (a') Code-group exclusion, observed through `query edges --from` + // (11.1): after `build`, the discovered code source answers exit 0 with + // its empty edge enumeration, while each excluded path the code globs + // match is unknown — exit 2 with the 12.7 error document, no finding, + // nothing modified. + await withWorkspace( + { + files: { + "xspec.config.ts": CODE_EXCLUSION_CONFIG, + "specs/A.mdx": mdxSection("a"), + "specs/A.md": "User-authored file at the emit destination.\n", + "src/plain.ts": PLAIN_TS, + ".xspec/staged.ts": STAGED_UNDER_XSPEC_TS, + }, + }, + async (workspace) => { + await buildOk( + product, + workspace, + "T7-6 (code-group exclusion): `build` — the workspace passes " + + "build's validations: src/plain.ts is a well-formed code " + + "source, and every other code-glob match is a derived file in " + + "no group (SPEC 7.2, 13.4)", + ); + // Premises: each excluded path exists as a plain file after the + // build — the generated module at its 13.1 path, the emitted + // Markdown at its 13.2 destination, and the staged file under + // .xspec/, which `build` has no derived path to replace and no + // record to remove (12.1, 13.4) — so the refusals below observe the + // exclusion of existing, glob-matched paths, never a merely absent + // one. + for (const excluded of CODE_EXCLUDED) { + const kind = await workspace.kind(excluded.path); + if (kind !== "file") { + fail( + `T7-6 (code-group exclusion): after \`build\`, expected a ` + + `plain file at ${excluded.path} — ${excluded.premise}; ` + + `found ${kind}`, + ); + } + } + const controlLabel = + "T7-6 (code-group exclusion) `query edges --from src/plain.ts`"; + const edges = decodeEdgesReport( + await runJson( + product, + workspace, + ["query", "edges", "--from", "src/plain.ts"], + `${controlLabel} — the discovered code source's whole-file ` + + `location is a graph node, so the query answers (SPEC 7.2, ` + + `4.6, 11.1)`, + ), + controlLabel, + ); + assertEdgeSetEqual( + edges, + [], + `${controlLabel}: the edge enumeration is empty — src/plain.ts ` + + `spells no marker, spec-module import, or text call, so its ` + + `whole-file location sources no edge (SPEC 4.3, 4.5, 4.6, 5.2)`, + ); + for (const excluded of CODE_EXCLUDED) { + const label = + "T7-6 (code-group exclusion) `query edges --from " + + `${excluded.path}\``; + const result = await assertLeavesUnchanged( + workspace.root, + () => + expectExit( + product, + workspace, + ["query", "edges", "--from", excluded.path], + 2, + `${label} — ${excluded.why}: a derived file is in no ` + + `spec or code group (SPEC 13.4), so the path is unknown ` + + `to --from, a usage error judged after configuration ` + + `loading and before the 13.3 gate — never a finding ` + + `(SPEC 11.1, 12.0)`, + ), + `${label}: a refused query modifies nothing — the argument ` + + `check precedes the gate, and a built workspace's graph data ` + + `already matches its sources (SPEC 12.0, 13.3)`, + ); + expectErrorDocument( + result, + `${label} — query's single JSON document is its only output ` + + `form, so JSON output is in effect without --json and the ` + + `exit-2 error document is the entire stdout (SPEC 11, 12.0, ` + + `12.7, H-5)`, + ); + if (result.stderrBytes.length === 0) { + fail( + `${label}: the usage error must be a standard-error ` + + `diagnostic (SPEC 12.0); stderr is empty — ` + + `${summarizeResult(result)}`, + ); + } + } + }, + ); + // (b) An import never adds an unmatched file: other/unlisted.mdx exists // on disk and the specifier resolves to it against the importing file's // directory (2.1), but no group matches it — so the import is invalid From ca7cb7a34ca5a4b34e6413c97281e5f9b587909d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 07:20:49 +0000 Subject: [PATCH 198/226] =?UTF-8?q?sdg(phase-9):=20T1.2-3=20=E2=80=94=20lo?= =?UTF-8?q?cate=20the=20root's=20ignored=20reason=20through=20the=20covera?= =?UTF-8?q?ge=20adapter,=20not=20a=20literal;=20the=20same=20one-line=20pi?= =?UTF-8?q?n=20in=20T1.5-1=20(Task=2013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1.2-3 asserted the root's ignored reason list literally as ["root node"]; SPEC 8.2 names the reason in prose and pins no wording (H-3), so the test now classifies each reported reason string onto its SPEC 8.2 identity through `classifyIgnoredReasons` — the discipline T8.2-1 applies — and asserts the root-node reason alone. T1.5-1 (section-1.5.ts) carried the identical literal over both ignored roots and is classified the same way. Counts and identity assertions are unchanged. Task 13 removed from FIX_PLAN.md (34 remain). Verified: section-1.1-1.2 6/6 and section-1.5 3/3 against the built product; typecheck and format clean; `npm run test:self` 338/338. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 17 ----------------- test/suite/registry/section-1.1-1.2.ts | 17 +++++++++++++---- test/suite/registry/section-1.5.ts | 13 ++++++++++--- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index d52e3d0..fa55f80 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,23 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 13 — T1.2-3: locate the root's ignored reason through the adapter, not a literal - -Cites: TEST-SPEC T1.2-3 (the root's reason is adapter-located; no wording is -pinned — the same discipline T8.2-1 applies through `classifyIgnoredReasons`); -SPEC 1.2, 8. - -Now: `test/suite/registry/section-1.1-1.2.ts` ≈ lines 742–745 asserts the reason -list literally as `["root node"]`. - -Do: replace the literal with the adapter classification used by T8.2-1 -(`classifyIgnoredReasons` or the helper the coverage adapter exports): assert -the root's entry classifies as the root-node reason and nothing else; keep the -count/identity assertions. - -Verify: `npx vitest run --config test/vitest.config.ts --project suite -test/suite/section-1.1-1.2.test.ts`; `npm run test:self` green. - ### Task 14 — T1.3-6: valueless-`id` (`<S id>`) masking arm — bearer's own code is 14.17, never 14.1 Cites: TEST-SPEC T1.3-6 (masking: a bearer whose `id` is not a plain static diff --git a/test/suite/registry/section-1.1-1.2.ts b/test/suite/registry/section-1.1-1.2.ts index 116b820..ee30460 100644 --- a/test/suite/registry/section-1.1-1.2.ts +++ b/test/suite/registry/section-1.1-1.2.ts @@ -15,6 +15,7 @@ // specifier `./NAME.xspec` (SPEC 4) resolves by Node's extension lookup. import { + classifyIgnoredReasons, decodeCoverageReport, decodeNodeReport, decodeNodeRowsReport, @@ -672,7 +673,7 @@ export default defineConfig({ const T1_2_3 = defineProductTest({ id: "T1.2-3", title: - "roots are never coverage targets: ignored with reason `root node`, absent from required/covered/uncovered, unmatched by `--coverage`, coverage attribute absent (SPEC 1.2, 8, 11)", + "roots are never coverage targets: ignored with the root-node reason (adapter-located, no wording pinned), absent from required/covered/uncovered, unmatched by `--coverage`, coverage attribute absent (SPEC 1.2, 8, 11)", run: async (product) => { const workspace = await TestWorkspace.create({ files: { @@ -739,10 +740,18 @@ const T1_2_3 = defineProductTest({ JSON.stringify(profile.ignored.map((entry) => entry.identity)), ); } + // The reason's spelling is output shape (SPEC 8.2 names it in prose and + // pins no wording): classify each reported reason string onto its SPEC + // 8.2 identity through the coverage adapter — the discipline T8.2-1 + // applies — and assert the identities: the root-node reason, alone. assertSameJson( - rootEntry.reasons, - ["root node"], - `${coverageLabel}: the root's exclusion reasons (SPEC 8.2 — only the root-node reason applies here)`, + classifyIgnoredReasons( + rootEntry.reasons, + `${coverageLabel} ignored ${rootEntry.identity}`, + ), + ["root"], + `${coverageLabel}: the root's exclusion reasons — the root-node reason and nothing else ` + + `(SPEC 8.2; adapter-located, H-3)`, ); // `query nodes --coverage …` matches no root (SPEC 11). diff --git a/test/suite/registry/section-1.5.ts b/test/suite/registry/section-1.5.ts index c423e00..a105e4e 100644 --- a/test/suite/registry/section-1.5.ts +++ b/test/suite/registry/section-1.5.ts @@ -28,6 +28,7 @@ import { Buffer } from "node:buffer"; import type { Finding } from "../../helpers/adapters/index.js"; import { assertReportMentions, + classifyIgnoredReasons, decodeCoverageReport, decodeIdsReport, decodeImpactReport, @@ -392,10 +393,16 @@ const T1_5_1 = defineProductTest({ `${coverageLabel}: the ignored roots are identified by bare workspace-relative path (SPEC 1.5)`, ); for (const ignored of profile.ignored) { + // Reason spellings are output shape (SPEC 8.2 pins no wording): + // classified onto their SPEC 8.2 identities through the coverage + // adapter, as T8.2-1 and T1.2-3 do (H-3). assertSameJson( - ignored.reasons, - ["root node"], - `${coverageLabel}: ${ignored.identity} exclusion reasons (targets: "all" — only the root-node reason applies, SPEC 8.2)`, + classifyIgnoredReasons( + ignored.reasons, + `${coverageLabel} ignored ${ignored.identity}`, + ), + ["root"], + `${coverageLabel}: ${ignored.identity} exclusion reasons (targets: "all" — the root-node reason and nothing else, SPEC 8.2)`, ); } const coverageHumanLabel = "T1.5-1 `coverage` (human report)"; From d39eec61b8f37b01eb8ece8bb73ec92bb9575891 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 07:26:48 +0000 Subject: [PATCH 199/226] =?UTF-8?q?sdg(phase-9):=20T1.3-6=20valueless-`id`?= =?UTF-8?q?=20arm=20=E2=80=94=20a=20bare=20`<S=20id>`=20bearer=20reports?= =?UTF-8?q?=20exactly=20one=2014.17=20and=20never=2014.1,=20masking=20its?= =?UTF-8?q?=20children=20like=20the=20repeated=20and=20braced=20bearers=20?= =?UTF-8?q?(Task=2014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third invalid-form arm of TEST-SPEC T1.3-6 joins `INVALID_ID_FORM_ARMS` in test/suite/registry/section-1.3.ts: `<S id>` enclosing the same `a.b` child and `zzz` grandchild, driven through the shared `runInvalidIdFormArm` — exact per-condition counts { 14.17: 1, 14.2: 1 } over the whole `build --json` report (so a 14.1 or 14.20 anywhere fails), the 14.17 located within the bearer's construct window and the 14.2 within the grandchild's. The arm-family comment records why this arm's discriminating assertion is the bearer's own code: a product reading the bare name as an absent `id` masks the same children under condition 1 (SPEC 14.1, 14.2, 14.17; 2.7). Verification: `npx vitest run … test/suite/section-1.3.test.ts` 7/7 against the built product; `npm run test:self` 338/338 (CONF-VALID passes T1.3-6 with the new arm; the VALID violators keep exactly their certified sets). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 21 --------------------- test/suite/registry/section-1.3.ts | 28 +++++++++++++++++++--------- 2 files changed, 19 insertions(+), 30 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index fa55f80..e8b4c95 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,27 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 14 — T1.3-6: valueless-`id` (`<S id>`) masking arm — bearer's own code is 14.17, never 14.1 - -Cites: TEST-SPEC T1.3-6 (masking: a bearer whose `id` is not a plain static -string withdraws identity for its subtree; the valueless spelling `<S id>` is -the 2.7 form-invalid prop — condition 14.17 on the bearer, and never 14.1); -SPEC 1.3, 2.7, 14.1, 14.17. - -Now: `test/suite/registry/section-1.3.ts` ≈ lines 409–414 stages only the -repeated-`id` and braced-`id` bearers. - -Do: add a third bearer arm in the same fixture family: `<S id>` (no value) -enclosing a valid descendant; `build` → the bearer reports exactly one 14.17 -finding located at the prop/tag as the existing arms locate theirs, no 14.1 -anywhere, and the descendants' masking outcome identical to the other two -arms (same identities/unavailability). Use exact per-condition counts as -`section-11.2.ts` 968–971 does. - -Verify: `npx vitest run … test/suite/section-1.3.test.ts`; `npm run test:self` -green (CONF-VALID's in-scope T1.3-6 still passes the conformer, both VALID -violators still fail exactly their sets). - ### Task 15 — T2.7-3: valueless `<S id>`, `<S id="x" coverage>`, `<S id="x" tags>` arms; export the shared `tags` fixture Cites: TEST-SPEC T2.7-3 (every prop of 2.7 spelled valueless is form-invalid — diff --git a/test/suite/registry/section-1.3.ts b/test/suite/registry/section-1.3.ts index b33f180..ce5db10 100644 --- a/test/suite/registry/section-1.3.ts +++ b/test/suite/registry/section-1.3.ts @@ -388,12 +388,16 @@ const MASK_SOURCE = `${MASK_PREFIX}${MASK_GRANDCHILD}${MASK_MID}${MASK_BAD_CHILD // children exactly as a missing `id` does — SPEC 2.7, 14.2, 14.17). Each arm // stages one bearer with an immediate child whose ID the structural rule // would otherwise judge — `a.b` extends none of the bearer's spelled value -// candidates (`one`, `two`, `x`) and is multi-segment against the empty -// prefix, so a product that fails to mask, or silently adopts one of the -// spelled values as the identity, reports an extra 14.2 — and a grandchild -// whose structural check runs normally against its parent's spelled id -// `a.b`. A valid sibling precedes the bearer so the bearer's construct is a -// proper sub-range of the file and its location assertion has teeth. +// candidates (`one`, `two`, `x`; the valueless bearer spells none) and is +// multi-segment against the empty prefix, so a product that fails to mask, +// or silently adopts one of the spelled values as the identity, reports an +// extra 14.2 — and a grandchild whose structural check runs normally +// against its parent's spelled id `a.b`. The valueless arm (`<S id>`, the +// bare name — T2.7-3's form) masks the same children whether a product +// reads it as condition 17 or as an absent `id` (condition 1), so its +// discriminating assertion is the bearer's own code: exactly one 14.17 and +// no 14.1. A valid sibling precedes the bearer so the bearer's construct is +// a proper sub-range of the file and its location assertion has teeth. interface InvalidIdFormArm { /** Which T1.3-6 invalid-form case this is (failure diagnostics). */ readonly name: string; @@ -419,6 +423,11 @@ const INVALID_ID_FORM_ARMS: readonly InvalidIdFormArm[] = [ bearerOpen: '<S id={"x"}>\nBearer: the id value is not a quoted static string literal.\n\n', }, + { + name: "a valueless-`id` section (`<S id>`)", + bearerOpen: + "<S id>\nBearer: the id prop is the bare name, spelling no value at all.\n\n", + }, ]; /** @@ -471,7 +480,7 @@ async function runInvalidIdFormArm( const T1_3_6 = defineProductTest({ id: "T1.3-6", title: - "missing-id masking: immediate children of an id-less section report no 14.2, while their other conditions and the grandchildren's structural checks still report; a repeated-`id` or braced-`id` bearer reports 14.17 — never 14.1 — masking the same way (SPEC 1.3, 2.7, 14.1, 14.2, 14.17)", + "missing-id masking: immediate children of an id-less section report no 14.2, while their other conditions and the grandchildren's structural checks still report; a repeated-`id`, braced-`id`, or valueless-`id` (`<S id>`) bearer reports 14.17 — never 14.1 — masking the same way (SPEC 1.3, 2.7, 14.1, 14.2, 14.17)", run: async (product) => { const context = "T1.3-6 `build --json` over an id-less section with children"; @@ -516,8 +525,9 @@ const T1_3_6 = defineProductTest({ `${context}: the immediate child's own 14.4 finding (other conditions are not masked)`, ); - // Invalid-form arms: a repeated `id` and a braced `id={"x"}` each report - // condition 17 and mask 14.2 for the immediate children the same way. + // Invalid-form arms: a repeated `id`, a braced `id={"x"}`, and a + // valueless `<S id>` each report condition 17 — the bare name is never + // `missing-id` — and mask 14.2 for the immediate children the same way. for (const arm of INVALID_ID_FORM_ARMS) { await runInvalidIdFormArm(product, arm); } From a80dba5573e1f95f1a91600a8c4b7ce2a0b994c1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 07:38:59 +0000 Subject: [PATCH 200/226] =?UTF-8?q?sdg(phase-9):=20T2.7-3=20valueless=20`<?= =?UTF-8?q?S=20id>`,=20`<S=20id=3D"x"=20coverage>`,=20`<S=20id=3D"x"=20tag?= =?UTF-8?q?s>`=20arms=20=E2=80=94=20one=2014.17=20each=20at=20the=20openin?= =?UTF-8?q?g=20tag,=20never=2014.1=20for=20the=20bare=20id;=20export=20the?= =?UTF-8?q?=20`<S=20id=3D"x"=20tags>`=20file=20as=20VALUELESS=5FTAGS=5FFIX?= =?UTF-8?q?TURE=20for=20T11.4-3's=20view=20arm=20(Task=2015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three arms join T2.7-3's invalid-prop matrix, each staged as the one-defect file (valid sibling, then the bearer) and reported as exactly one 14.17 and nothing else, located within the opening tag's byte window; the `<S id>` arm names 14.1 as the forbidden misreading ahead of the exact count, so a product reporting missing-id fails with the SPEC 14.1 reason. The `<S id="x" tags>` file is exported with its path, bytes, `id`, opening tag, section range, attribute entries in tag order (byte offsets), and finding expectation; T2.7-3 slices the declared offsets back against the bytes before staging. The repeated-unknown arm and the loop stage through the same `invalidPropSource` template at one `INVALID_PROP_FILE`. Verified: `npm run build`; `section-2.7.test.ts` 3/3 against the built product (the product already reports the bare-name forms as 14.17, located at the attribute itself); `npm run test:self` 338/338, C-1 intact; typecheck and format clean. AGENTS.md: a hand-staged scratch workspace needs no `node_modules` to run the built product. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 2 +- specs/tmp/FIX_PLAN.md | 21 ---- test/suite/registry/section-2.7.ts | 190 +++++++++++++++++++++++++++-- 3 files changed, 180 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7ecde38..fb8a86a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Requires Node.js >= 22 and npm. Install dependencies: `npm ci`. - One npm package (`xspec`) holding two distinct programs: the product under `src/` and the test harness under `test/`. The harness never imports product code; it drives the built `xspec` executable as a subprocess. -- Build the product: `npm run build` — compiles `src/` (TypeScript ESM, `src/tsconfig.json`) to `dist/`; the `xspec` bin is `dist/cli/bin.js`. Run it: `node dist/cli/bin.js`. +- Build the product: `npm run build` — compiles `src/` (TypeScript ESM, `src/tsconfig.json`) to `dist/`; the `xspec` bin is `dist/cli/bin.js`. Run it: `node dist/cli/bin.js`. A hand-staged scratch workspace (an `xspec.config.ts` plus sources, anywhere on disk) needs no `node_modules`: the product resolves the configuration's `import … from "xspec"` itself, so `cd <workspace> && node /abs/path/to/dist/cli/bin.js build --json` works as is — the quickest way to eyeball a finding's exact location before pinning it in a test. - The built product enables Node's on-disk V8 compile cache (`node:module` `enableCompileCache`; default directory under the OS temp dir, e.g. `/tmp/node-compile-cache`). The first invocations after `npm run build` repopulate it, so one-off CLI timings are slower than steady state; the cache affects timing only, never output. Steady-state per-invocation cost is ~0.26s for any command that parses the configuration (the TypeScript compiler module is loaded through `createRequire` in `src/core/ts-module.ts` — importing that CJS file through the ESM loader instead costs ~0.2s more per invocation in format sniffing and named-export lexing; keep any new `typescript` use routed through that module) and ~0.12s for the store-backed fast paths (`query` and `at` on a workspace whose `.xspec/graph.json` verifies against the current bytes) — the numbers that matter when a test sweeping many CLI invocations nears its timeout. - Typecheck both programs: `npm run typecheck` (`src/tsconfig.json`, then `test/tsconfig.json`; the harness is not typechecked by Vitest at run time). `test/fixtures/` is excluded from the harness typecheck: fixture projects are data compiled or executed at test run time and may contain deliberate type errors (e.g. the S-4 fixture). - Consumer fixture programs are compiled through the harness's TypeScript tooling driver (`test/helpers/tooling.ts`), which resolves `@types/node` from this repository's own `node_modules` — `npm ci` (dev dependencies included) must have run for consumer compilation to work. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index e8b4c95..59f6eab 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,27 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 15 — T2.7-3: valueless `<S id>`, `<S id="x" coverage>`, `<S id="x" tags>` arms; export the shared `tags` fixture - -Cites: TEST-SPEC T2.7-3 (every prop of 2.7 spelled valueless is form-invalid — -14.17; `<S id>` reports 14.17 and no 14.1); T11.4-3 ("the valueless prop must be -staged as T2.7-3's `<S id="x" tags>` — one fixture shared by build and view"); -SPEC 2.5, 2.6, 2.7, 14.17. - -Now: `test/suite/registry/section-2.7.ts` stages only valueless `d`. - -Do: add three arms — valueless `id` (exactly one 14.17 on the bearer, zero -14.1), valueless `coverage` on `<S id="x" coverage>`, valueless `tags` on -`<S id="x" tags>` — each exit 1 with exactly one 14.17 finding located at the -prop, no other finding. Define the `<S id="x" tags>` source bytes as an -exported constant (with its expected finding location) so -`section-11.4.ts` (Task 39) stages byte-identical content for `view`; keep -the module's canonical ID ordering. - -Verify: `npx vitest run … test/suite/section-2.7.test.ts`; `npm run test:self` -green (CONF-AVAIL's revised bare-valueless → 14.17 behavior is in scope for -T11.4-3; nothing to change for T2.7-3 itself). - ### Task 16 — T4.5-4: callee-side arm — a shadowing local `text` makes `text(SPEC.a)` a condition-18 finding Cites: TEST-SPEC T4.5-4 (callee side: an inner-scope `function text(x: diff --git a/test/suite/registry/section-2.7.ts b/test/suite/registry/section-2.7.ts index 7e02622..4b1ab96 100644 --- a/test/suite/registry/section-2.7.ts +++ b/test/suite/registry/section-2.7.ts @@ -16,8 +16,10 @@ // prop (defined or unknown), an unknown prop, and a spread attribute are // invalid (14.17); `id`/`coverage`/`tags` values MUST be quoted-form static // string literals — single- or double-quoted alike (2.4) — and any other -// value form is invalid (14.17); `d` MUST be a braced expression — a quoted -// or valueless `d` is invalid (14.17), and a braced `d` value that is not a +// value form, a braced expression or the bare valueless name alike, is +// invalid (14.17) — a bare `<S id>` is condition 17, never the missing-id +// condition 1 (14.1); `d` MUST be a braced expression — a quoted or +// valueless `d` is invalid (14.17), and a braced `d` value that is not a // static reference or an array literal of them is a dynamic argument (14.8). // // Location assertions follow the SUITE-08 discipline: negative fixtures are @@ -27,14 +29,22 @@ // support.ts byteWindow); the valid sibling section and every other staged // construct lie outside the widened window. // +// The valueless-`tags` file is exported as VALUELESS_TAGS_FIXTURE — its exact +// bytes, attribute offsets, and finding location — because TEST-SPEC T11.4-3 +// stages the same bytes for `view`: build and view share one fixture, the +// 14.17 beside the view being the condition the build reports here. +// // No certification fixture scopes any T2.7 test (CERTIFICATIONS.md keeps the // 2.7 negative matrix among the representatively-certified ones), so only // TEST-SPEC's own requirements bind these fixtures. +import { Buffer } from "node:buffer"; + import type { ImpactReport, ImpactRequirementEntry, NodeReport, + SourceRange, } from "../../helpers/adapters/index.js"; import { decodeImpactReport, @@ -49,6 +59,7 @@ import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; +import type { FindingSourceExpectation } from "./support.js"; import { assertConditionCounts, assertFindingLocated, @@ -88,6 +99,17 @@ export default defineConfig({ // location assertion has teeth. const SIBLING = '<S id="ok">\nA valid sibling section.\n</S>\n\n'; +// T2.7-3's one-defect file: the sibling, then the offending section — its +// opening tag the staged construct, its body and closing tag fixed — at one +// workspace-relative path. +const INVALID_PROP_FILE = "specs/A.mdx"; +const INVALID_PROP_BODY = "\nBody text.\n</S>"; + +/** The one-defect file's exact bytes for an offending opening tag. */ +function invalidPropSource(construct: string): string { + return `${SIBLING}${construct}${INVALID_PROP_BODY}\n`; +} + /** Stage a fresh workspace (config plus `files`), run `body`, dispose (H-1). */ async function withWorkspace<T>( config: string, @@ -628,10 +650,17 @@ const T2_7_2 = defineProductTest({ // --------------------------------------------------------------------------- // The invalid-prop matrix (SPEC 2.7 → 14.17/14.8), each arm a fresh minimal -// workspace whose offending opening tag is the one staged defect. The quoted +// workspace whose offending opening tag is the one staged defect, reported +// as exactly one finding of the arm's condition and nothing else. The quoted // `d` names the existing sibling, so a product wrongly accepting quoted-form // `d` resolves it and builds clean — caught by the exit-1 expectation rather -// than accidentally passing via an unresolved-reference finding. +// than accidentally passing via an unresolved-reference finding. The three +// valueless string-prop arms are the bare-name forms — `<S id>`, +// `<S id="x" coverage>`, `<S id="x" tags>` — of 2.7's "any other value +// form" (14.17); the bare `<S id>` is condition 17, never the missing-id +// condition 1 (14.1), so a product reading it as an absent `id` fails on the +// bearer's own code (its masking of children: T1.3-6; the view side of the +// bare-name forms: T11.2-2, T11.4-3). interface InvalidPropArm { /** Which SPEC 2.7 prop rule this violates (failure diagnostics). */ readonly name: string; @@ -639,6 +668,109 @@ interface InvalidPropArm { readonly construct: string; /** The SPEC 14 condition the arm must report. */ readonly condition: "14.17" | "14.8"; + /** + * A condition the arm discriminates against — the one a product misreading + * the construct would report instead — checked ahead of the exact count so + * the failure states the SPEC reason (the count alone rejects it too). + */ + readonly forbids?: { readonly condition: string; readonly reason: string }; +} + +/** A staged attribute's exact bytes — the `view` entry form of SPEC 11.4. */ +export interface StagedAttribute { + /** The attribute's name as spelled. */ + readonly name: string; + /** Its byte range within the file (SPEC 1.7). */ + readonly range: SourceRange; + /** Its source text: the name through its value's last character, or the bare name. */ + readonly text: string; +} + +/** + * T2.7-3's valueless-`tags` file, the fixture T11.4-3 shares for `view` + * (TEST-SPEC T11.4-3: one fixture for build and view). Pure ASCII, so string + * indices are byte offsets; every offset derives from the exact parts, and + * T2.7-3 slices each back against `source` before staging. + */ +export interface ValuelessTagsFixture { + /** Workspace-relative path the file is staged at. */ + readonly file: string; + /** The file's exact bytes: the valid sibling section, then the bearer. */ + readonly source: string; + /** The bearer's spelled `id` value — well-formed, its identity defined (SPEC 11.2). */ + readonly id: string; + /** The bearer's opening tag, exactly — the one staged defect. */ + readonly construct: string; + /** The bearer's construct range, opening tag through closing tag (SPEC 1.7). */ + readonly sectionRange: SourceRange; + /** The bearer's attributes in tag order: `id="x"`, then the bare `tags`. */ + readonly attributes: readonly StagedAttribute[]; + /** Where the one 14.17 finding must locate: the opening tag's window (SPEC 14). */ + readonly finding: FindingSourceExpectation; +} + +function valuelessTagsFixture(): ValuelessTagsFixture { + const construct = '<S id="x" tags>'; + const idText = 'id="x"'; + const tagsText = "tags"; + const tagStart = Buffer.byteLength(SIBLING, "utf8"); + const idStart = tagStart + Buffer.byteLength("<S ", "utf8"); + const tagsStart = idStart + Buffer.byteLength(`${idText} `, "utf8"); + return { + file: INVALID_PROP_FILE, + source: invalidPropSource(construct), + id: "x", + construct, + sectionRange: { + start: tagStart, + end: tagStart + Buffer.byteLength(construct + INVALID_PROP_BODY, "utf8"), + }, + attributes: [ + { + name: "id", + range: { start: idStart, end: idStart + idText.length }, + text: idText, + }, + { + name: "tags", + range: { start: tagsStart, end: tagsStart + tagsText.length }, + text: tagsText, + }, + ], + finding: { + file: INVALID_PROP_FILE, + window: byteWindow(SIBLING, construct), + }, + }; +} + +export const VALUELESS_TAGS_FIXTURE: ValuelessTagsFixture = + valuelessTagsFixture(); + +/** + * The exported fixture's declared offsets against its own bytes (staging + * integrity, T11.4-3's slice-check precedent): each attribute's range slices + * to its text and the section range to the bearer's whole construct, so + * T11.4-3 asserts `view` against offsets the build arm has verified. + */ +function assertValuelessTagsFixture(): void { + const fixture = VALUELESS_TAGS_FIXTURE; + const context = "T2.7-3 staging: the exported valueless-`tags` fixture"; + const bytes = Buffer.from(fixture.source, "utf8"); + const slice = (range: SourceRange): string => + bytes.subarray(range.start, range.end).toString("utf8"); + for (const attribute of fixture.attributes) { + assertSameJson( + slice(attribute.range), + attribute.text, + `${context}: the \`${attribute.name}\` attribute's range slices to its text`, + ); + } + assertSameJson( + slice(fixture.sectionRange), + fixture.construct + INVALID_PROP_BODY, + `${context}: the section range slices to the bearer's whole construct`, + ); } const INVALID_PROP_ARMS: readonly InvalidPropArm[] = [ @@ -672,6 +804,28 @@ const INVALID_PROP_ARMS: readonly InvalidPropArm[] = [ construct: '<S id="sec" tags={"a"}>', condition: "14.17", }, + { + name: "a valueless `id` (the bare name `<S id>`)", + construct: "<S id>", + condition: "14.17", + forbids: { + condition: "14.1", + reason: + "a bare `<S id>` spells an id value not in quoted static-string " + + "form — condition 17, never condition 1 (SPEC 14.1, 2.7): a product " + + "reading the bare name as an absent `id` and reporting missing-id fails", + }, + }, + { + name: 'a valueless `coverage` (`<S id="x" coverage>`)', + construct: '<S id="x" coverage>', + condition: "14.17", + }, + { + name: 'a valueless `tags` (`<S id="x" tags>`, the file T11.4-3 shares)', + construct: VALUELESS_TAGS_FIXTURE.construct, + condition: "14.17", + }, { name: "a quoted `d` value", construct: '<S id="sec" d="ok">', @@ -721,20 +875,36 @@ const T2_7_3_QUOTED_IDENTITIES = ["specs/A.mdx", "specs/A.mdx#login"] as const; const T2_7_3 = defineProductTest({ id: "T2.7-3", title: - "repeated props (defined or unknown), unknown props, spread attributes, braced `id`/`coverage`/`tags` values, and quoted or valueless `d` fail with 14.17; a braced `d` holding a non-reference expression fails with 14.8; single-quoted `id`/`coverage`/`tags` build byte-identically in outputs to the double-quoted variants (SPEC 2.7, 2.4)", + 'repeated props (defined or unknown), unknown props, spread attributes, braced or valueless `id`/`coverage`/`tags` values — the bare `<S id>` reporting 14.17 and never 14.1 — and quoted or valueless `d` fail with 14.17, each arm exactly one finding located at its opening tag; a braced `d` holding a non-reference expression fails with 14.8; single-quoted `id`/`coverage`/`tags` build byte-identically in outputs to the double-quoted variants (SPEC 2.7, 2.4, 14.1); the `<S id="x" tags>` file is exported as the fixture T11.4-3 shares for `view`', run: async (product) => { + // The exported fixture's offsets are verified before its arm stages it + // (T11.4-3 stages the same bytes for `view`). + assertValuelessTagsFixture(); + for (const arm of INVALID_PROP_ARMS) { const context = `T2.7-3 \`build --json\` with ${arm.name}`; + const { forbids } = arm; await withWorkspace( SPECS_ONLY_CONFIG, - { "specs/A.mdx": `${SIBLING}${arm.construct}\nBody text.\n</S>\n` }, + { [INVALID_PROP_FILE]: invalidPropSource(arm.construct) }, async (workspace) => { const findings = await buildFindings(product, workspace, context); + if (forbids !== undefined) { + const wrong = findings.find( + (finding) => finding.condition === forbids.condition, + ); + if (wrong !== undefined) { + fail( + `${context}: ${forbids.reason}; got a ${forbids.condition} ` + + `finding (message: ${JSON.stringify(wrong.message)})`, + ); + } + } assertConditionCounts(findings, { [arm.condition]: 1 }, context); assertFindingLocated( findings[0]!, { - file: "specs/A.mdx", + file: INVALID_PROP_FILE, window: byteWindow(SIBLING, arm.construct), }, `${context}: the ${arm.condition} finding (SPEC 2.7)`, @@ -748,9 +918,7 @@ const T2_7_3 = defineProductTest({ "T2.7-3 `build --json` with a repeated unknown prop"; await withWorkspace( SPECS_ONLY_CONFIG, - { - "specs/A.mdx": `${SIBLING}${REPEATED_UNKNOWN_CONSTRUCT}\nBody text.\n</S>\n`, - }, + { [INVALID_PROP_FILE]: invalidPropSource(REPEATED_UNKNOWN_CONSTRUCT) }, async (workspace) => { const findings = await buildFindings( product, @@ -774,7 +942,7 @@ const T2_7_3 = defineProductTest({ assertFindingLocated( finding, { - file: "specs/A.mdx", + file: INVALID_PROP_FILE, window: byteWindow(SIBLING, REPEATED_UNKNOWN_CONSTRUCT), }, `${repeatedUnknown}: a 14.17 finding`, From 8a831b8e38b8e3d757e00e77eeaad20b36c6623e Mon Sep 17 00:00:00 2001 From: Liam Cardenas <liamcardenas@gmail.com> Date: Wed, 2 Sep 2026 07:50:17 +0000 Subject: [PATCH 201/226] =?UTF-8?q?sdg(phase-9):=20T4.5-4=20callee-side=20?= =?UTF-8?q?arm=20=E2=80=94=20an=20inner-scope=20`function=20text`=20shadow?= =?UTF-8?q?ing=20the=20imported=20`text`=20makes=20`text(SPEC.a)`=20there?= =?UTF-8?q?=20one=20located=2014.18=20from=20`build`=20and=20`check`,=20an?= =?UTF-8?q?d=20`occurrences=20--file`=20on=20the=20failing=20workspace=20l?= =?UTF-8?q?ists=20only=20the=20control=20call's=20`embeds`=20record=20(Tas?= =?UTF-8?q?k=2016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan's suggested `query edges` observation is unavailable here: the workspace fails `build`, so `query` reports the findings without answering (SPEC 13.3). Per TEST-SPEC T4.5-4 the edge-level observation is `occurrences --file src/app.ts`, answering on the failing workspace (11.2): no edge, no occurrence (5.7), so the exact record set — the control's `embeds` record with its call-expression range, whole-file source, and target — is simultaneously the no-edge assertion for the shadowed call. `check --json` sets 14.10 aside (staleness of a never-built workspace is 14.10's own business) and pins exactly the one 14.18. The arm-staging of `assertArmFailsWith` is factored into `stageOffendingStatement`, shared by both. Traceability: T4.5-4 now also covers 5.7 and 14. Verified: section-4.5 suite 7/7 against the built product; `npm run test:self` 338/338. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 25 --- test/suite/registry/section-4.5.ts | 234 ++++++++++++++++++++++++++-- test/suite/registry/traceability.ts | 2 +- 3 files changed, 223 insertions(+), 38 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 59f6eab..3b89e6b 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,31 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 16 — T4.5-4: callee-side arm — a shadowing local `text` makes `text(SPEC.a)` a condition-18 finding - -Cites: TEST-SPEC T4.5-4 (callee side: an inner-scope `function text(x: -unknown) {}` shadows the imported `text`; `text(SPEC.a)` inside that scope is -a condition-18 finding at that use; `build`/`check` exit 1; no `embeds` edge -or occurrence for it; `occurrences --file` answers on the failing workspace -with the finding; a control call outside the scope lists its `embeds` -occurrence); SPEC 4.5, 5.7, 11.3, 14.18. - -Now: `test/suite/registry/section-4.5.ts` ≈ lines 772–830 hold only the -`const SPEC` (binding-side) arm. - -Do: add the callee-side arm: a code file importing `SPEC` and `text`, with a -block/function that declares `function text(x: unknown) {}` and calls -`text(SPEC.a)` inside it, plus one control `text(SPEC.b)` at module scope. -Assert: `build` and `check` exit 1 with exactly one condition-18 finding at -the shadowed use's range; `query edges` (or the graph read the file already -uses) shows no `embeds` edge from the shadowed call and one from the control; -`occurrences --file <code file>` (11.2: answers per file on a failing -workspace) lists the control's `embeds` occurrence and reports the finding -for the shadowed call, in the adapter's decoded form. - -Verify: `npx vitest run … test/suite/section-4.5.test.ts`; `npm run test:self` -green. - ### Task 17 — T4.6-3: value-side boundary — `const s = text(SPEC.a)` attributes to `path#f` inside `f`, `path` at top level, never `path#s` Cites: TEST-SPEC T4.6-3 (attribution boundary: a `text(...)` call whose value diff --git a/test/suite/registry/section-4.5.ts b/test/suite/registry/section-4.5.ts index a1e70d3..a2ebe67 100644 --- a/test/suite/registry/section-4.5.ts +++ b/test/suite/registry/section-4.5.ts @@ -42,6 +42,14 @@ // - T4.5-5 arms likewise stage exactly one unsanctioned value-level use // each; the exact condition-count assertion {"14.18": 1} pins the // classification (SPEC 4.5, 14.18). +// - T4.5-4's callee-side arm stages exactly one shadowed `text(SPEC.a)` call +// beside one module-scope control call. The workspace fails `build`, so +// `query` reports the findings without answering (SPEC 13.3): the arm's +// edge-level observation is `occurrences --file` over the code file, +// which answers on the failing workspace (11.2) — a construct that +// records no edge records no occurrence (5.7), so the exact record set +// (the control's `embeds` record alone, its range, source, and target +// pinned) is simultaneously the no-edge assertion for the shadowed call. // - Location assertions: every offending statement is staged at a known byte // offset in a pure-ASCII `src/app.ts`, so string indices are byte offsets // and each finding must fall within the offending statement's own byte @@ -53,17 +61,21 @@ import type { CoverageReport, GraphEdge, ImpactedCodeEntry, + OccurrenceRecord, } from "../../helpers/adapters/index.js"; import { decodeCoverageReport, decodeEdgesReport, + decodeFindingsReport, decodeImpactReport, + decodeOccurrencesReport, } from "../../helpers/adapters/index.js"; import { assertBytesEqual, assertExitCode, assertStderrEmpty, fail, + parseJsonStdout, } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; @@ -203,22 +215,25 @@ interface OffendingStatementArm { readonly offending: string; } +/** An arm's staged `src/app.ts` text and its offending statement's window. */ +interface StagedOffendingStatement { + /** The whole file: every line LF-terminated. */ + readonly source: string; + /** The offending statement's byte window (support.ts byteWindow). */ + readonly window: { readonly start: number; readonly end: number }; +} + /** - * Stage one arm over the shared `a`/`a.b` spec source and assert `build - * --json` reports exactly one finding of `condition`, located within the - * offending statement's byte window. The exact condition-count assertion is - * simultaneously the classification assertion (14.8 vs 14.18, SPEC 4.5). + * Lay out an arm's `src/app.ts` and locate its offending statement's byte + * window. The statement must appear exactly once among the staged lines — + * otherwise a harness defect (never a product failure). */ -async function assertArmFailsWith( - product: ProductBinding, +function stageOffendingStatement( testId: string, arm: OffendingStatementArm, - condition: string, -): Promise<void> { +): StagedOffendingStatement { const at = arm.lines.indexOf(arm.offending); if (at === -1 || arm.lines.lastIndexOf(arm.offending) !== at) { - // A harness defect (never a product failure): the offending statement - // must appear exactly once among the staged lines. throw new Error( `${testId} fixture broke: the offending statement must appear exactly ` + `once (${arm.name}) — fix the arm table in section-4.5.ts`, @@ -229,7 +244,22 @@ async function assertArmFailsWith( .slice(0, at) .map((line) => line + "\n") .join(""); - const window = byteWindow(prefix, arm.offending); + return { source, window: byteWindow(prefix, arm.offending) }; +} + +/** + * Stage one arm over the shared `a`/`a.b` spec source and assert `build + * --json` reports exactly one finding of `condition`, located within the + * offending statement's byte window. The exact condition-count assertion is + * simultaneously the classification assertion (14.8 vs 14.18, SPEC 4.5). + */ +async function assertArmFailsWith( + product: ProductBinding, + testId: string, + arm: OffendingStatementArm, + condition: string, +): Promise<void> { + const { source, window } = stageOffendingStatement(testId, arm); const context = `${testId} \`build --json\` over ${arm.name}`; await withWorkspace( SPEC_AND_CODE_CONFIG, @@ -792,10 +822,187 @@ const T4_5_4_APP_SOURCE = [ "", ].join("\n"); +// Callee side (SPEC 4.5: rooting is scope-aware and value-level for the +// `text` binding as for the node chain). Inside `shadowScope`, a local +// `function text` shadows the imported `text`, so `text(SPEC.a)` there has a +// non-spec callee and a node argument — the "passing to any other function" +// of 4.5, 14.18 (T4.5-5) — while the identical call at module scope (the +// control) is an ordinary `text` call recording its `embeds` edge (4.3), and +// the import binding, used by the control alone, stays a valid import (2.1, +// 4). The two targets differ (`a` shadowed, `a.b` control), so a record a +// by-name product would emit for the shadowed call is distinguishable from +// the control's by target, not only by range. +const T4_5_4_CALLEE_IMPORT = 'import SPEC, { text } from "../specs/A.xspec";'; +const T4_5_4_CALLEE_CONTROL_CALL = "text(SPEC.a.b)"; +const T4_5_4_CALLEE_SHADOWED_LINE = " text(SPEC.a);"; +const T4_5_4_CALLEE_ARM: OffendingStatementArm = { + name: "a shadowing local `text` as the callee (SPEC 4.5)", + lines: [ + T4_5_4_CALLEE_IMPORT, + "", + `${T4_5_4_CALLEE_CONTROL_CALL};`, + "", + "function shadowScope(): void {", + " function text(x: unknown): void { void x; }", + T4_5_4_CALLEE_SHADOWED_LINE, + "}", + "", + "shadowScope();", + ], + offending: T4_5_4_CALLEE_SHADOWED_LINE, +}; + +/** + * An occurrence record's every datum (SPEC 5.7) as one JSON-safe tuple — + * file, own range, kind, source (identity plus range, or the unavailability + * marker), target — so whole records compare key-order-free. + */ +function occurrenceTuple(record: OccurrenceRecord): readonly unknown[] { + const source = + "unavailable" in record.source + ? "(source unavailable)" + : [ + record.source.identity, + record.source.range.start, + record.source.range.end, + ]; + return [ + record.file, + record.range.start, + record.range.end, + record.kind, + source, + record.target, + ]; +} + +/** + * The T4.5-4 callee-side arm: `build` and `check` report exactly one + * condition-18 finding located at the shadowed use (exit 1), and + * `occurrences --file` over the code file — answering on the failing + * workspace (SPEC 11.2) — carries that finding and lists exactly the control + * call's `embeds` record, none for the shadowed call (5.7, 11.3). + */ +async function assertT454CalleeSide(product: ProductBinding): Promise<void> { + const { source, window } = stageOffendingStatement( + "T4.5-4", + T4_5_4_CALLEE_ARM, + ); + const shadowedUse = { file: "src/app.ts", window } as const; + // The control record: its own range spans the call expression, callee + // through closing parenthesis, the statement terminator excluded; its + // source is the whole-file location, no named unit enclosing it — + // identity the path alone, range the entire file (SPEC 5.7, 4.6, 1.7). + const controlStart = Buffer.byteLength(`${T4_5_4_CALLEE_IMPORT}\n\n`, "utf8"); + const controlEnd = + controlStart + Buffer.byteLength(T4_5_4_CALLEE_CONTROL_CALL, "utf8"); + const expectedRecords: readonly (readonly unknown[])[] = [ + [ + "src/app.ts", + controlStart, + controlEnd, + "embeds", + ["src/app.ts", 0, Buffer.byteLength(source, "utf8")], + "specs/A.mdx#a.b", + ], + ]; + await withWorkspace( + SPEC_AND_CODE_CONFIG, + { ...AB_SPEC_FILES, "src/app.ts": source }, + async (workspace) => { + // `build`: exactly one finding, condition 18, located at the shadowed + // use. The exact count pins the classification and, with it, the + // import's validity — nothing is reported beside the one 14.18 + // (SPEC 2.1, 4.5, 14.18). + const buildContext = + "T4.5-4 `build --json` with a shadowing local `text` as the callee"; + const buildFound = await buildFindings(product, workspace, buildContext); + assertConditionCounts(buildFound, { "14.18": 1 }, buildContext); + assertFindingLocated( + buildFound[0]!, + shadowedUse, + `${buildContext}: the 14.18 finding locates at the shadowed use ` + + `(SPEC 4.5, 14.18)`, + ); + + // `check`: the same validation, exit 1 (SPEC 12.2). Staleness of the + // never-built workspace's derived files is 14.10's own business + // (12.2, 14) — set aside, the findings are exactly the one 14.18, + // located at the shadowed use. + const checkContext = + "T4.5-4 `check --json` with a shadowing local `text` as the callee"; + const checkResult = await expectExit( + product, + workspace, + ["check", "--json"], + 1, + `${checkContext} — \`check\` performs all build validations and ` + + `exits 1 on the finding (SPEC 12.2, 4.5, 14.18)`, + ); + const checkFound = decodeFindingsReport( + parseJsonStdout(checkResult, checkContext), + checkContext, + ).findings.filter((finding) => finding.condition !== "14.10"); + assertConditionCounts(checkFound, { "14.18": 1 }, checkContext); + assertFindingLocated( + checkFound[0]!, + shadowedUse, + `${checkContext}: the 14.18 finding locates at the shadowed use ` + + `(SPEC 4.5, 14.18)`, + ); + + // `occurrences --file src/app.ts`, answering on the failing workspace + // (SPEC 11.2): the code file's finding accompanies (exit 1, the full + // answer still emitted), and the record set is exactly the control + // call's `embeds` record — the shadowed call records no edge and so + // no occurrence (5.7). A product resolving the callee by name would + // list a second record (target `specs/A.mdx#a`) and carry no finding. + const occContext = + "T4.5-4 `occurrences --file src/app.ts` on the failing workspace"; + const occResult = await expectExit( + product, + workspace, + ["occurrences", "--file", "src/app.ts"], + 1, + `${occContext} — the answer carries the domain's 14.18 finding, so ` + + `exit 1 with the full answer document (SPEC 11.2, 11.3)`, + ); + const report = decodeOccurrencesReport( + parseJsonStdout( + occResult, + `${occContext} — a single JSON document is the only output form ` + + `(SPEC 11)`, + ), + occContext, + ); + assertConditionCounts( + report.findings, + { "14.18": 1 }, + `${occContext}: the code file's one finding accompanies the answer ` + + `(SPEC 11.2, 11.3)`, + ); + assertFindingLocated( + report.findings[0]!, + shadowedUse, + `${occContext}: the accompanying 14.18 locates at the shadowed use ` + + `(SPEC 11.2, 14)`, + ); + assertSameJson( + report.occurrences.map(occurrenceTuple), + expectedRecords, + `${occContext}: exactly the control call's \`embeds\` record — file, ` + + `own range (callee through closing parenthesis), kind, whole-file ` + + `source, target — and none for the shadowed call, which records ` + + `no edge and no occurrence (SPEC 4.5, 5.7, 4.6, 1.7, 11.3)`, + ); + }, + ); +} + const T4_5_4 = defineProductTest({ id: "T4.5-4", title: - "a local declaration shadowing the import binding: chains rooted at the local are not spec references — no edge, no error, the program builds — while the identical statement rooted at the import records its marker edge (SPEC 4.5)", + "a local declaration shadowing the import binding: chains rooted at the local are not spec references — no edge, no error, the program builds — while the identical statement rooted at the import records its marker edge; callee side: an inner-scope `function text` shadowing the imported `text` makes `text(SPEC.a)` in that scope a call to another function — `build` and `check` report exactly one condition-18 finding located at that use, exit 1, and `occurrences --file` over the code file, answering on the failing workspace, carries the finding and lists no record for it while the identical call outside the scope lists its `embeds` occurrence (SPEC 4.5, 5.7, 11.2, 11.3, 14.18)", run: async (product) => { await withWorkspace( SPEC_AND_CODE_CONFIG, @@ -839,6 +1046,9 @@ const T4_5_4 = defineProductTest({ ); }, ); + + // Callee side: the shadowing local as the callee of `text(...)`. + await assertT454CalleeSide(product); }, }); diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 6717258..6eb03aa 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -162,7 +162,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T4.5-1": ["4.5"], "T4.5-2": ["4.5"], "T4.5-3": ["4.5", "14"], - "T4.5-4": ["4.5"], + "T4.5-4": ["4.5", "5.7", "14"], "T4.5-5": ["4.5", "14"], "T4.5-6": ["4.5"], "T4.5-7": ["4.5"], From ed51219648cc83c5baaad32acaf8155c3e2111ec Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 07:58:49 +0000 Subject: [PATCH 202/226] =?UTF-8?q?sdg(phase-9):=20T4.6-3=20value-side=20b?= =?UTF-8?q?oundary=20arm=20=E2=80=94=20`const=20s=20=3D=20text(SPEC.valfn)?= =?UTF-8?q?`=20inside=20`f`=20attributes=20its=20`embeds`=20edge=20to=20`s?= =?UTF-8?q?rc/app.ts#f`=20and=20the=20top-level=20`const=20t=20=3D=20text(?= =?UTF-8?q?SPEC.valtop)`=20to=20the=20file,=20pinned=20by=20the=20complete?= =?UTF-8?q?=20`embeds`=20set,=20with=20no=20endpoint=20of=20the=20unfilter?= =?UTF-8?q?ed=20`query=20edges`=20answer=20spelled=20after=20either=20cons?= =?UTF-8?q?tant=20(Task=2017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 18 ----- test/suite/registry/section-4.6.ts | 105 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 2 +- 3 files changed, 104 insertions(+), 21 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 3b89e6b..cbd178f 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,24 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 17 — T4.6-3: value-side boundary — `const s = text(SPEC.a)` attributes to `path#f` inside `f`, `path` at top level, never `path#s` - -Cites: TEST-SPEC T4.6-3 (attribution boundary: a `text(...)` call whose value -is bound by a `const` is attributed to the enclosing function's unit — `path#f` -inside `f`, the whole-file `path` at top level — never to the constant's own -name); SPEC 4.6. - -Now: `test/suite/registry/section-4.6.ts` has no value-side arm. - -Do: add the arm: one code file with `function f() { const s = text(SPEC.a); }` -and a top-level `const t = text(SPEC.b);`; after `build`, the graph/edge -enumeration (through the query adapter) attributes the first embedding to -`<path>#f` and the second to `<path>`, and no location `<path>#s` or `<path>#t` -exists anywhere in the answer. - -Verify: `npx vitest run … test/suite/section-4.6.test.ts`; `npm run test:self` -green. - ### Task 18 — T6.1-1: never-modifies sweep over every command surface Cites: TEST-SPEC T6.1-1 (the journal is appended only by `rename`/`move`; diff --git a/test/suite/registry/section-4.6.ts b/test/suite/registry/section-4.6.ts index 6ca444f..9d2ec08 100644 --- a/test/suite/registry/section-4.6.ts +++ b/test/suite/registry/section-4.6.ts @@ -23,6 +23,15 @@ // position, targeting the same section — so the two set-equality // assertions accept only a product attributing both forms to the table's // one unit per placement. +// - T4.6-3 "value-side boundary … never to a unit named `s` (asserted via +// `query edges`)": the two value-side `text(...)` calls target dedicated +// sections, so the workspace's complete `embeds` edge set (`--kinds +// embeds`) pins each call's attributed unit — `path#f` and `path` — +// exactly; additionally every endpoint of the unfiltered `query edges` +// answer is swept for a unit chain spelled after either constant (`#s`, +// `#t`, the nested `#f.s`, and their `@N`-suffixed forms), so a product +// that made a plain-identifier constant a named unit fails on the whole +// answer, not only on the `embeds` set. // - T4.6-4 "coverage boundary membership": SPEC 8 covers a target when a // permitted path exists from a boundary node to it, and 8.2 reports one // shortest covering path as a node-identity sequence (12.0) — from the @@ -116,6 +125,19 @@ async function queryEdgesFrom( ); } +/** Unfiltered workspace-wide `query edges` — every kind, decoded (SPEC 11.1). */ +async function queryAllEdges( + product: ProductBinding, + workspace: TestWorkspace, + context: string, +): Promise<readonly GraphEdge[]> { + const label = `${context} \`query edges\``; + return decodeEdgesReport( + await runJson(product, workspace, ["query", "edges"], label), + label, + ); +} + // --------------------------------------------------------------------------- // T4.6-1 — the attribution matrix over all named-unit forms // --------------------------------------------------------------------------- @@ -545,6 +567,15 @@ const T4_6_2 = defineProductTest({ // section, so the complete `references` edge set pins each arm's attribution // — in particular, an edge sourced at a `#`-named unit such as // `src/app.ts#Carrier.#priv` fails the equality. +// +// Value-side boundary (SPEC 4.6: a variable declaration is a named unit only +// when its initializer is a function expression, an arrow function, or a +// class expression): `const s = text(SPEC.valfn)` inside `f` and +// `const t = text(SPEC.valtop)` at top level bind the calls' returned +// strings — no node and no `text` binding is stored, so 4.5 is untouched and +// `build` succeeds — and each call's `embeds` edge (4.3, from the calling +// code location) attributes to the innermost enclosing named unit, +// `src/app.ts#f`, or to the file, never to a unit named after the constant. const T4_6_3_SPEC_SOURCE = [ '<S id="iife">', "Target for the top-level IIFE placement.", @@ -574,10 +605,18 @@ const T4_6_3_SPEC_SOURCE = [ "Target for the private-name class member.", "</S>", "", + '<S id="valfn">', + "Target for the value-side text call inside a named function.", + "</S>", + "", + '<S id="valtop">', + "Target for the value-side text call at file top level.", + "</S>", + "", ].join("\n"); const T4_6_3_APP_SOURCE = [ - 'import SPEC from "../specs/N.xspec";', + 'import SPEC, { text } from "../specs/N.xspec";', "", "(function () {", " SPEC.iife;", @@ -615,12 +654,36 @@ const T4_6_3_APP_SOURCE = [ " }", "}", "", + "function f(): void {", + " const s = text(SPEC.valfn);", + "}", + "", + "const t = text(SPEC.valtop);", + "", ].join("\n"); +/** The value-side arm's plain-identifier constants — never named units. */ +const T4_6_3_VALUE_CONSTANTS: readonly string[] = ["s", "t"]; + +/** + * Whether a graph-node identity names a code unit chain of `src/app.ts` with + * a segment spelled after a value-side constant — `src/app.ts#s`, + * `src/app.ts#t`, the nested `src/app.ts#f.s`, and their `@N`-suffixed + * forms — the misattribution the value-side arm forbids (SPEC 4.6). + */ +function namesValueConstantUnit(identity: string): boolean { + const prefix = "src/app.ts#"; + if (!identity.startsWith(prefix)) return false; + const chain = identity.slice(prefix.length).replace(/@\d+$/u, ""); + return chain + .split(".") + .some((segment) => T4_6_3_VALUE_CONSTANTS.includes(segment)); +} + const T4_6_3 = defineProductTest({ id: "T4.6-3", title: - "markers inside constructs that are not named units — an IIFE, a function stored via destructuring, and computed-name, string-literal-name, numeric-literal-name (`123() {}`), and private (`#priv() {}`) class members — attribute to the nearest enclosing named unit or the file; the private-member arm attributes to the bare class unit, never to a `#`-named unit (SPEC 4.6)", + "markers inside constructs that are not named units — an IIFE, a function stored via destructuring, and computed-name, string-literal-name, numeric-literal-name (`123() {}`), and private (`#priv() {}`) class members — attribute to the nearest enclosing named unit or the file; the private-member arm attributes to the bare class unit, never to a `#`-named unit; value-side boundary: `const s = text(SPEC.a)` attributes to `path#f` inside `f` and to `path` at top level, never to a unit named after the constant (SPEC 4.6)", run: async (product) => { await withWorkspace( SPEC_AND_CODE_CONFIG, @@ -684,6 +747,44 @@ const T4_6_3 = defineProductTest({ "its hosting function, and every oddly-named class member to " + "the bare class unit — never to a `#`-named unit (SPEC 4.6)", ); + // Value-side boundary: the complete `embeds` set pins each call's + // attributed unit — the constant's enclosing function, or the file. + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "embeds", "T4.6-3"), + [ + { + from: "src/app.ts#f", + to: "specs/N.mdx#valfn", + kind: "embeds", + }, + { + from: "src/app.ts", + to: "specs/N.mdx#valtop", + kind: "embeds", + }, + ], + "T4.6-3 value-side boundary: `const s = text(SPEC.valfn)` inside " + + "`f` attributes its `embeds` edge to `src/app.ts#f`, and the " + + "top-level `const t = text(SPEC.valtop)` to the file " + + "`src/app.ts` — never to a unit named after the constant " + + "(SPEC 4.6; the stored value is the returned string, 4.5)", + ); + // … and no location spelled after either constant exists anywhere + // in the unfiltered edge enumeration — as a source or a target. + const allEdges = await queryAllEdges(product, workspace, "T4.6-3"); + assertSameJson( + allEdges + .flatMap((edge) => [edge.from, edge.to]) + .filter(namesValueConstantUnit) + .sort(), + [], + "T4.6-3 value-side boundary: no endpoint of the unfiltered " + + "`query edges` answer names a code unit spelled after the " + + "plain-identifier constants `s` or `t` (`src/app.ts#s`, " + + "`src/app.ts#t`, `src/app.ts#f.s`, or an `@N`-suffixed form) " + + "— a variable declaration whose initializer is a call is no " + + "named unit (SPEC 4.6)", + ); }, ); }, diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 6eb03aa..ddc0096 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -168,7 +168,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T4.5-7": ["4.5"], "T4.6-1": ["4.6"], "T4.6-2": ["4.6"], - "T4.6-3": ["4.6"], + "T4.6-3": ["4.5", "4.6"], "T4.6-4": ["4.6"], "T5.2-1": ["5", "5.1", "5.2"], "T5.3-1": ["5.3", "14"], From 8b79fed5d7f404c050e2365aae322aec030c21da Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 08:12:05 +0000 Subject: [PATCH 203/226] =?UTF-8?q?sdg(phase-9):=20T6.1-1=20never-modifies?= =?UTF-8?q?=20sweep=20over=20every=20command=20surface=20=E2=80=94=20ids,?= =?UTF-8?q?=20show,=20coverage,=20a=20git-resolved=20`impact=20--base=20HE?= =?UTF-8?q?AD`,=20an=20audit=20session's=20create/list/status/next/split/r?= =?UTF-8?q?esolve/show/export,=20query,=20occurrences,=20view,=20at,=20inv?= =?UTF-8?q?entory,=20version,=20and=20the=20rename/move=20previews=20(file?= =?UTF-8?q?=20and=20section=20forms,=20whole-tree=20compared)=20each=20byt?= =?UTF-8?q?e-compared=20around=20the=20journal=20(Task=2018)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 2 +- specs/tmp/FIX_PLAN.md | 25 --- test/suite/registry/section-6.1.ts | 235 ++++++++++++++++++++++++----- 3 files changed, 195 insertions(+), 67 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb8a86a..f35621e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Typecheck both programs: `npm run typecheck` (`src/tsconfig.json`, then `test/tsconfig.json`; the harness is not typechecked by Vitest at run time). `test/fixtures/` is excluded from the harness typecheck: fixture projects are data compiled or executed at test run time and may contain deliberate type errors (e.g. the S-4 fixture). - Consumer fixture programs are compiled through the harness's TypeScript tooling driver (`test/helpers/tooling.ts`), which resolves `@types/node` from this repository's own `node_modules` — `npm ci` (dev dependencies included) must have run for consumer compilation to work. - Full test suite (TEST-SPEC sections 1–17, certification included; the Linux CI leg): `npm test`. Build the product first — tests invoke the built executable. -- Run a subset of a test project by appending file paths, e.g. `npx vitest run --config test/vitest.config.ts --project suite test/suite/section-1.1-1.2.test.ts` — the way to observe new product-facing tests failing-as-diagnosed against the stub product (Phase 9 red-green). +- Run a subset of a test project by appending file paths, e.g. `npx vitest run --config test/vitest.config.ts --project suite test/suite/section-1.1-1.2.test.ts` — the way to observe new product-facing tests failing-as-diagnosed against the stub product (Phase 9 red-green). Append `--reporter=verbose` to see each test's duration — the quick check that a many-invocation sweep (e.g. T6.1-1's ~34 CLI runs, ~9 s at steady state) actually drove every arm rather than short-circuiting. - Running tests also requires the system `git` executable on PATH: harness fixtures script local git repositories (`test/helpers/workspace.ts`). No git configuration is needed — the builder isolates all ambient git config and identity. - Harness self-tests and certification only (TEST-SPEC 17): `npm run test:self`. Its certification runner prints one `PASS`/`FAIL` line per (test, fixture) pair as it drives each fixture executable; a violator's `FAIL` lines are its expected outcomes, not failures — Vitest's own failures are the `×` lines and the final `Tests` summary, so when piping the output through `grep`, filter on those rather than on `FAIL`. - Certification fixture products (CERTIFICATIONS.md, e.g. `test/fixtures/conf-core/`) are plain Node ESM programs with no build step and no dependencies: the certification runner (and manual debugging) invokes `node test/fixtures/<fixture>/bin.mjs <command> …` with a staged workspace as the working directory. Violator executables sit beside the conformer's entry as `bin-<deviation>.mjs` (e.g. `bin-nolock.mjs`) and run the same way. Every fixture exits 70 — outside SPEC 12.0's exit partition — for a fixture-internal crash (the stack on stderr), and the CONF-DISC fixture also for an invocation outside its certified scope (`xspec: fixture scope error: …` on stderr, e.g. `query nodes` or `query edges --kinds`): such an exit is a fixture-side condition, never a product verdict, so a test hitting it needs a fixture (or staging) fix, not a product diagnosis. `test/fixtures/` is excluded from the harness typecheck; Prettier still formats it. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index cbd178f..b6b43b0 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,31 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 18 — T6.1-1: never-modifies sweep over every command surface - -Cites: TEST-SPEC T6.1-1 (the journal is appended only by `rename`/`move`; -every other command leaves the journal-bearing workspace byte-identical: -`build`, `check`, `coverage`, `impact --base HEAD`, `review list`, `query -nodes`, plus `ids`, `show`, `review create/resolve/split`, `occurrences`, -`view`, `at`, `inventory`, `version`, and `rename --preview` / `move ---preview`, each on a journal-bearing workspace); CERTIFICATIONS.md Exclusions -(T6.1-1 sweeps every surface — outside CONF-CORE); SPEC 6.1, 6.6. - -Now: `test/suite/registry/section-6.1.ts` ≈ lines 311–316 byte-compare only the -first six commands. - -Do: extend the sweep so each listed command runs on a workspace holding a -non-empty journal (and, for the review subcommands, an `audit` session and an -unblocked item; for `impact`, a resolvable commit) and the journal file is -byte-identical before/after each invocation (compare the journal — and, where -the existing sweep compares more, the same set); the `--preview` runs must -leave the journal and every source byte-identical. Keep each invocation's -own exit/answer decoded through the adapters (no assertion beyond a normal -completion is required here). - -Verify: `npx vitest run … test/suite/section-6.1.test.ts`; `npm run test:self` -green. - ### Task 19 — T6.4-2: keepable reference forms, whole-file byte contract, single-quoted `id` attributes Cites: TEST-SPEC T6.4-2 (revised: minimal in-place edits — a computed diff --git a/test/suite/registry/section-6.1.ts b/test/suite/registry/section-6.1.ts index 4f97838..10d7577 100644 --- a/test/suite/registry/section-6.1.ts +++ b/test/suite/registry/section-6.1.ts @@ -13,13 +13,18 @@ // workspace state; content is otherwise opaque — assertions here stick to the // stated observable contract (line-oriented, append-only form; H-4). // -// Staging constraint (CERTIFICATIONS.md §CONF-CORE — T6.1-1 and T6.1-2 are -// in-scope): their fixtures stay within CONF-CORE's scope — one configured -// spec group of `.mdx` sources without imports, embeddings, `d` props, or -// tags; no `code`, `markdown`, `coverage`, or `policy` keys; no git; the only -// mutating commands driven are `rename` and file-form `move`. In this -// git-less scope `impact --base` is the exit-2 unreadable-baseline case -// (SPEC 6.3, 12.0). +// Staging constraint (CERTIFICATIONS.md §CONF-CORE — T6.1-2 is in-scope): +// its fixture stays within CONF-CORE's scope — one configured spec group of +// `.mdx` sources without imports, embeddings, `d` props, or tags; no `code`, +// `markdown`, `coverage`, or `policy` keys; no git; the only mutating +// commands driven are `rename` and file-form `move`. T6.1-1 lies outside +// every certification scope (CERTIFICATIONS.md §Exclusions): its +// never-modifies sweep covers every command surface, so its workspace adds +// what the sweep needs on top of the same files — a git baseline commit (a +// resolvable `impact --base HEAD`, SPEC 6.3), an `audit` review session +// (`create`/`split`/`resolve` and the read subcommands, 10.7), the 11.2 +// surfaces (`occurrences`, `view`, `at`), `inventory`, `version`, and the +// 6.6 previews of both operations. // // Conservative operationalizations (noted per H-4): // - "One entry per line" + "the journal is written only by rename and move" @@ -40,7 +45,24 @@ import { Buffer } from "node:buffer"; import type { Finding } from "../../helpers/adapters/index.js"; -import { decodeFindingsReport } from "../../helpers/adapters/index.js"; +import { + decodeAtReport, + decodeCoverageReport, + decodeExportReport, + decodeFindingsReport, + decodeIdsReport, + decodeImpactReport, + decodeInventoryDocument, + decodeItemReport, + decodeNextReport, + decodeNodeRowsReport, + decodeOccurrencesReport, + decodePreviewReport, + decodeSessionListReport, + decodeSessionStatusReport, + decodeVersionDocument, + decodeViewReport, +} from "../../helpers/adapters/index.js"; import { assertBytesEqual, fail, @@ -48,7 +70,8 @@ import { } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; -import type { ProductBinding } from "../../helpers/subprocess.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; +import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import { buildOk, expectExit } from "./support.js"; @@ -95,6 +118,32 @@ async function withCoreWorkspace<T>( } } +/** + * Stage T6.1-1's sweep workspace: the same files plus a git baseline commit + * holding the configuration and sources exactly as staged, so `impact --base + * HEAD` resolves (SPEC 6.3). The commit precedes `build`, so the ref holds + * no derived file and no journal — a journal absent at the ref is the empty + * journal, a prefix of every journal, and the current entries replay onto + * the baseline identities (6.3). + */ +async function withSweepWorkspace<T>( + body: (workspace: TestWorkspace) => Promise<T>, +): Promise<T> { + const workspace = await TestWorkspace.create({ files: CORE_FILES }); + try { + await workspace.gitInit(); + await workspace.gitCommitAll("baseline"); + return await body(workspace); + } finally { + await workspace.dispose(); + } +} + +/** Snapshot exclusion pruning the top-level `.git` subtree. */ +function excludeGitDir(relPathBytes: Uint8Array): boolean { + return Buffer.from(relPathBytes).toString("latin1") === ".git"; +} + /** * Read the journal's exact bytes, failing diagnosed (H-8) when the path does * not hold a plain file (SPEC 6.1: the file comes into existence with the @@ -170,7 +219,8 @@ function assertJournalAppend( * Byte-compare the journal around one command (T6.1-1 "never modify it"): * read it, run the command asserting the exact exit code (H-5), read it * again, assert byte identity — a deleted or replaced journal fails via - * `readJournal`'s plain-file check. + * `readJournal`'s plain-file check. Returns the run so the caller can decode + * its answer. */ async function assertLeavesJournalUnchanged( product: ProductBinding, @@ -178,13 +228,13 @@ async function assertLeavesJournalUnchanged( argv: readonly string[], exitCode: number, context: string, -): Promise<void> { +): Promise<RunResult> { const command = argv.join(" "); const before = await readJournal( workspace, `${context}: before \`${command}\``, ); - await expectExit( + const result = await expectExit( product, workspace, argv, @@ -202,6 +252,7 @@ async function assertLeavesJournalUnchanged( `written only by \`rename\` and \`move\` (byte-compare around the ` + `command; SPEC 6.1, 13.4)`, ); + return result; } // --------------------------------------------------------------------------- @@ -211,9 +262,9 @@ async function assertLeavesJournalUnchanged( const T6_1_1 = defineProductTest({ id: "T6.1-1", title: - "no journal exists after `build` in a fresh workspace; the file appears at .xspec/journal with the first rename/move; each subsequent operation appends exactly one line-oriented entry and rewrites nothing above it (byte-prefix asserted); build, check, coverage, impact, review, query never modify it (byte-compare around each) (SPEC 6.1, 13.4)", + "no journal exists after `build` in a fresh workspace; the file appears at .xspec/journal with the first rename/move; each subsequent operation appends exactly one line-oriented entry and rewrites nothing above it (byte-prefix asserted); every other command surface — build, check, ids, show, coverage, impact, review (reads and create/split/resolve alike), query, occurrences, view, at, inventory, version, and rename/move --preview — never modifies it (byte-compare around each on the journal-bearing workspace; the previews leave the whole workspace byte-identical) (SPEC 6.1, 6.6, 13.4)", run: async (product) => { - await withCoreWorkspace(async (workspace) => { + await withSweepWorkspace(async (workspace) => { // Fresh workspace: `build` succeeds and creates no journal. await buildOk( product, @@ -292,37 +343,139 @@ const T6_1_1 = defineProductTest({ "T6.1-1", ); - // `build`, `check`, `coverage`, `impact`, `review`, `query` never - // modify the journal — byte-compare around each, with the journal - // present and non-empty so an append or truncation is visible. Exit - // codes (H-5): `build` 0 (the workspace is valid); `check` 0 (rename - // and move finish by regenerating derived files exactly as `build` - // does, SPEC 6.4/6.5, and the product-written journal is well-formed); - // `coverage` 0 reporting zero profiles (no `coverage` key, SPEC 7.4); - // `impact --base HEAD` 2 — the git-less workspace makes the baseline - // unreadable, a usage error (SPEC 6.3, 12.0), and even the refused - // invocation must leave the journal untouched; `review list` 0 (a read - // subcommand, informational with no sessions, SPEC 12.0); `query - // nodes` 0. - const readArms: readonly { - readonly argv: readonly string[]; - readonly exitCode: number; - }[] = [ - { argv: ["build"], exitCode: 0 }, - { argv: ["check"], exitCode: 0 }, - { argv: ["coverage"], exitCode: 0 }, - { argv: ["impact", "--base", "HEAD"], exitCode: 2 }, - { argv: ["review", "list"], exitCode: 0 }, - { argv: ["query", "nodes"], exitCode: 0 }, - ]; - for (const arm of readArms) { + // Every other command surface never modifies the journal (SPEC 6.1 + // "written only by rename and move"; 13.4): byte-compare it around + // each invocation, with the journal present and non-empty so an + // append or truncation is visible. Exit codes (H-5) are all 0: the + // workspace is valid; `check` finds nothing (rename and move finish by + // regenerating derived files exactly as `build` does, SPEC 6.4/6.5, + // and the product-written journal is well-formed); `coverage` reports + // zero profiles (no `coverage` key, 7.4); `impact --base HEAD` + // resolves against the staged baseline commit, the current journal + // replaying onto it (6.3); the `review` subcommands drive an `audit` + // session (10.6, 10.7); the 11 surfaces answer complete, finding-free + // documents; `version` is workspace-independent (12.6); and each + // preview succeeds exactly as its real operation would (6.6). Answers + // are decoded form-exactly through the adapters (H-3) — no value + // assertion is this test's business beyond a normal completion. + const sweepContext = "T6.1-1 never-modifies sweep"; + const sweep = async ( + argv: readonly string[], + exitCode: number, + ): Promise<RunResult> => await assertLeavesJournalUnchanged( product, workspace, - arm.argv, - arm.exitCode, - "T6.1-1 read sweep", + argv, + exitCode, + sweepContext, + ); + const sweepJson = async <T>( + argv: readonly string[], + decode: (doc: unknown, context: string) => T, + ): Promise<T> => { + const context = `${sweepContext}: \`${argv.join(" ")}\` answer`; + return decode(parseJsonStdout(await sweep(argv, 0), context), context); + }; + + await sweep(["build"], 0); + await sweep(["check"], 0); + await sweepJson(["ids", "--json"], decodeIdsReport); + await sweep(["show", "specs/A.mdx#a3"], 0); + await sweepJson(["coverage", "--json"], decodeCoverageReport); + await sweepJson( + ["impact", "--base", "HEAD", "--json"], + decodeImpactReport, + ); + + // `review`: `create` under the audit strategy — one subtree-coherence + // item per requirement node, leaves unblocked (10.6) — then the reads, + // a `split` of `a3`'s item (its scope root has the child `a3.k`), and a + // `resolve` of the first unblocked item `next` reports (10.7). + await sweep( + ["review", "create", "--strategy", "audit", "--name", "s"], + 0, + ); + await sweepJson(["review", "list", "--json"], decodeSessionListReport); + const status = await sweepJson( + ["review", "status", "s", "--json"], + decodeSessionStatusReport, + ); + const splitRow = status.items.find( + (row) => + row.kind === "subtree-coherence" && row.scope === "specs/A.mdx#a3", + ); + if (splitRow === undefined) { + fail( + `${sweepContext}: the audit session must hold a subtree-coherence ` + + `item scoped to specs/A.mdx#a3 — one item per requirement node ` + + `(SPEC 10.6); \`review status s --json\` listed ` + + `${String(status.items.length)} item(s)`, + ); + } + await sweepJson(["review", "next", "s", "--json"], decodeNextReport); + await sweep(["review", "split", "s", splitRow.id], 0); + const next = await sweepJson( + ["review", "next", "s", "--json"], + decodeNextReport, + ); + if (next.item === undefined) { + fail( + `${sweepContext}: \`review next s --json\` must report an ` + + `unblocked needing-review item — the leaf items of an audit ` + + `session are unblocked and unresolved (SPEC 10.6, 10.7); the ` + + `session reports itself fully resolved`, + ); + } + await sweep( + ["review", "resolve", "s", next.item.id, "--status", "updated"], + 0, + ); + await sweepJson( + ["review", "show", "s", next.item.id, "--json"], + decodeItemReport, + ); + await sweepJson(["review", "export", "s"], decodeExportReport); + + await sweepJson(["query", "nodes"], decodeNodeRowsReport); + await sweepJson(["occurrences"], decodeOccurrencesReport); + await sweepJson(["view"], (doc, context) => + decodeViewReport(doc, { text: false }, context), + ); + await sweepJson(["at", "specs/A.mdx", "0"], decodeAtReport); + await sweepJson(["inventory"], decodeInventoryDocument); + await sweepJson(["version"], decodeVersionDocument); + + // Previews modify nothing at all (SPEC 6.6: no sources, no journal, no + // derived files, no graph data): beyond the journal compare, the whole + // workspace tree (the git directory aside — a preview reads no git) + // is byte-compared around each. `rename`, the file form of `move`, + // and the section form — every operation with a preview. + const previewArms: readonly (readonly string[])[] = [ + ["rename", "specs/A.mdx", "a3", "a4", "--preview"], + ["rename", "specs/A.mdx", "a3", "a4", "--preview", "--json"], + ["move", "specs/Bmoved.mdx", "specs/B.mdx", "--preview", "--json"], + [ + "move", + "specs/A.mdx#a3.k", + "specs/Bmoved.mdx#b.k", + "--preview", + "--json", + ], + ]; + for (const argv of previewArms) { + const command = argv.join(" "); + const result = await assertLeavesUnchanged( + workspace.root, + () => sweep(argv, 0), + `T6.1-1 preview sweep: \`${command}\` must modify nothing — no ` + + `sources, no journal, no derived files, no graph data (SPEC 6.6)`, + { exclude: excludeGitDir }, ); + if (argv.includes("--json")) { + const context = `T6.1-1 preview sweep: \`${command}\` answer`; + decodePreviewReport(parseJsonStdout(result, context), context); + } } }); }, From 557fb86b2d827cff17f2315a0c28d00965442de3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 08:21:47 +0000 Subject: [PATCH 204/226] =?UTF-8?q?sdg(phase-9):=20T6.4-2=20keepable-form?= =?UTF-8?q?=20rename=20arms=20=E2=80=94=20the=20affected=20segment=20stage?= =?UTF-8?q?d=20computed=20in=20both=20quote=20kinds,=20dot,=20single-=20an?= =?UTF-8?q?d=20double-quoted=20local=20strings=20and=20`id`=20attributes;?= =?UTF-8?q?=20four=20arms=20(`login-v2`=20=E2=86=92=20`login2`=20/=20`logi?= =?UTF-8?q?n-v3`,=20`top.mid`=20=E2=86=92=20`top.neo`=20/=20`top.neo-2`)?= =?UTF-8?q?=20each=20byte-compare=20every=20`.mdx`=20and=20`.ts`=20file,?= =?UTF-8?q?=20markers=20and=20`text(...)`=20calls=20included,=20against=20?= =?UTF-8?q?an=20expectation=20composed=20from=20SPEC=206.4's=20rules,=20tw?= =?UTF-8?q?o=20untouched=20files=20byte-identical=20(Task=2019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 26 -- test/suite/registry/section-6.4.ts | 440 ++++++++++++++++------------ test/suite/registry/traceability.ts | 2 +- 3 files changed, 257 insertions(+), 211 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index b6b43b0..4a1dae8 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,32 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 19 — T6.4-2: keepable reference forms, whole-file byte contract, single-quoted `id` attributes - -Cites: TEST-SPEC T6.4-2 (revised: minimal in-place edits — a computed -double-quoted access `BASE["login-v2"]` → `BASE["login2"]` stays computed and -double-quoted; single-quoted `BASE['login-v2']` → `BASE['login2']` and → -`BASE['login-v3']` keep single quotes; dot access stays dot; `{text('login-v2')}` -and a `d` array entry `'login-v2'` keep single quotes; every rewritten `.mdx` -and `.ts` file — marker and `text` rewrites included — is asserted byte-equal to -an expected whole file; `id` attributes spelled single-quoted, on the renamed -section and on one descendant, stay single-quoted); SPEC 6.4, 2.7, 3. - -Now: `test/suite/registry/section-6.4.ts` ≈ lines 1017–1019 implement the -pre-revisit text (spot assertions on rewritten spellings only). - -Do: restage T6.4-2 with fixtures carrying each listed form (in `.mdx` `d` -props/arrays, `{text(...)}` embeddings, and a `.ts` file with markers and -`text` calls through both access forms), compose the expected post-rename -bytes for every touched file from SPEC 6.4's rules (only the ID segment's -characters change; quote kind and access form kept), and assert each rewritten -file byte-equal to its composed expectation (files untouched by the rename -byte-identical). Include the two single-quoted `id` attributes. Keep the -existing preview/journal assertions. - -Verify: `npx vitest run … test/suite/section-6.4.test.ts`; `npm run test:self` -green. - ### Task 20 — T6.4-3: two-bearer collision — one `refused-id-collision` locating both `b` and `b.c` Cites: TEST-SPEC T6.4-3 (rename `a`→`b` where `a.c` exists beside `b` and `b.c`: diff --git a/test/suite/registry/section-6.4.ts b/test/suite/registry/section-6.4.ts index b73b8cc..93e1588 100644 --- a/test/suite/registry/section-6.4.ts +++ b/test/suite/registry/section-6.4.ts @@ -50,12 +50,18 @@ // the renamed node and its descendant, which SPEC 6.4 pins as the complete // mapping (the renamed ID plus the prefix-replaced descendants, nothing // else). Pair order is unasserted (shape, not information). -// - T6.4-2 stages every *affected* reference part in dot access or -// double-quoted form, so each expected byte is pinned whichever way 6.4's -// preserve-then-default rule is read; single-quoted spellings appear only -// in untouched parts and untouched references, whose byte-wise preservation -// T6.4-2 pins explicitly. Whole rewritten source files are compared -// byte-exactly ("only the affected parts change" pins all other bytes). +// - T6.4-2 stages every keepable form on the *affected* segment itself — +// computed access in both quote kinds, dot access, local string literals +// and `id` attributes in both quote kinds — and composes each expected +// post-rename file from SPEC 6.4's rules: only the renamed segment's +// characters change, quote kind and access form are kept, and the +// double-quoted computed fallback applies to a dot segment whose new name +// is not a TS identifier alone. Whole files are compared byte-exactly, +// `.mdx` and `.ts` alike (markers and `text(...)` calls included), and +// two files holding only unaffected references must come through +// byte-identical ("only the affected parts change" pins all other +// bytes: untouched segments and references, prose and comments spelling +// the old name). // - T6.4-3/T6.4-6 "modifies nothing" is a whole-workspace-root byte snapshot // compare around the refused command, with the pre-refusal `build`'s // derived files present — a product that rewrites before validating, or @@ -807,184 +813,205 @@ const T6_4_1 = defineProductTest({ }); // --------------------------------------------------------------------------- -// T6.4-2 — minimal edits (byte-exact) +// T6.4-2 — minimal edits (byte-exact, every keepable form kept) // --------------------------------------------------------------------------- -// Arm A: the new segment `neo` is a valid TypeScript identifier, so every -// staged form is keepable and every rewrite is the minimal in-place edit — -// dot stays dot, double-quoted computed stays double-quoted computed, -// double-quoted string literals stay double-quoted. Untouched parts carry the -// contrasting spellings (single-quoted computed segments before and after the -// affected segment, a single-quoted local string, a whole untouched -// single-quoted-computed reference) and must be preserved byte-wise. -const M2A_CORE_BEFORE = [ - '<S id="top">', - "Top text.", - "", - '<S id="top.mid">', - "Mid text.", - "", - '<S id="top.mid.kid-x">', - "Kid text.", - "</S>", - "</S>", - "", - '<S id="top.aid" d={["top.mid", \'top.res\']}>', - "Embeds: {text(\"top.mid.kid-x\")} and {text('top.res')}", - "</S>", - "", - '<S id="top.res">', - "Res text.", - "</S>", - "</S>", - "", -].join("\n"); +// Each fixture below is a template over the renamed segment's spelling, so +// every expected post-rename file is composed from SPEC 6.4's rules — only +// the renamed segment's characters change; the quote kind of a computed +// access, a string literal, or an `id` attribute and the access form of a +// chain segment are kept wherever the new name admits them — while every +// other byte (untouched segments and references, prose and comments that +// spell the old name, whole files holding no affected reference) is the +// staged byte verbatim. +// +// Fixture L (arms 1 and 2): the renamed segment `login-v2` is not a TS +// identifier, so its chain references are computed — double-quoted +// (`["login-v2"]`) and single-quoted (`['login-v2']`) — and its local string +// references and `id` attributes come in both quote kinds; the renamed +// section's own `id` and one rewritten descendant's `id` are single-quoted +// (SPEC 2.7). Arm 1 renames it to the identifier-valid `login2`: the +// double-quoted computed segment stays computed and double-quoted (never +// `.login2`), the single-quoted one keeps its single quotes, and so do the +// single-quoted local strings and `id` values. Arm 2 renames it to +// `login-v3`: the same forms, all kept. + +/** Fixture L's `specs/Core.mdx`; `seg` spells the renamed segment. */ +function coreL(seg: string): string { + return [ + `<S id='${seg}'>`, + "Login text; the prose spelling login-v2 is no reference and stays.", + "", + `<S id='${seg}.kid'>`, + "Kid text.", + "</S>", + "", + `<S id="${seg}.aux" d={['${seg}.kid']}>`, + `Aux: {text('${seg}.kid')}`, + "</S>", + "</S>", + "", + `<S id="other" d={["${seg}", '${seg}.kid', 'other.leaf']}>`, + `Other: {text('${seg}')} and {text("${seg}.aux")} and {text('other.leaf')}`, + "", + '<S id="other.leaf">', + "Leaf text.", + "</S>", + "</S>", + "", + ].join("\n"); +} -const M2A_CORE_AFTER = [ - '<S id="top">', - "Top text.", - "", - '<S id="top.neo">', - "Mid text.", - "", - '<S id="top.neo.kid-x">', - "Kid text.", - "</S>", - "</S>", - "", - '<S id="top.aid" d={["top.neo", \'top.res\']}>', - "Embeds: {text(\"top.neo.kid-x\")} and {text('top.res')}", - "</S>", - "", - '<S id="top.res">', - "Res text.", - "</S>", - "</S>", - "", -].join("\n"); +/** Fixture L's `specs/Refs.mdx`: external chains through both quote kinds. */ +function refsL(seg: string): string { + return [ + 'import Core from "./Core.xspec"', + "", + `<S id="refs" d={[Core["${seg}"], Core['${seg}'].kid, Core["${seg}"]["aux"], Core['other'].leaf]}>`, + `Embeds: {text(Core['${seg}'])} and {text(Core["${seg}"].kid)} and {text(Core['other'])}`, + "</S>", + "", + ].join("\n"); +} -const M2A_REFS_BEFORE = [ - 'import Core from "./Core.xspec"', - "", - '<S id="refs" d={[Core.top.mid, Core.top["mid"], Core[\'top\'].mid]}>', - 'Embeds: {text(Core.top.mid["kid-x"])}', - "Also: {text(Core.top.mid['kid-x'])}", - "Watch: {text(Core.top['res'])}", - "</S>", - "", -].join("\n"); +/** Fixture L's `src/app.ts`: markers and `text(...)` calls, both quote kinds. */ +function appL(seg: string): string { + return [ + 'import CORE, { text } from "../specs/Core.xspec";', + "", + '// A comment is no reference: CORE["login-v2"] stays as written here.', + "export function login(): string {", + ` CORE["${seg}"];`, + ` CORE['${seg}'].kid;`, + ` return text(CORE['${seg}']) + text(CORE["${seg}"]["aux"]);`, + "}", + "", + "export function other(): string {", + " CORE.other.leaf;", + " return text(CORE['other']);", + "}", + "", + ].join("\n"); +} -const M2A_REFS_AFTER = [ +// Fixture L's untouched sources: unaffected identities only, referenced in +// single-quoted and computed spellings beside a single-quoted `id` — the +// rename must leave both files byte-identical. +const OTHER_MDX_L = [ 'import Core from "./Core.xspec"', "", - '<S id="refs" d={[Core.top.neo, Core.top["neo"], Core[\'top\'].neo]}>', - 'Embeds: {text(Core.top.neo["kid-x"])}', - "Also: {text(Core.top.neo['kid-x'])}", - "Watch: {text(Core.top['res'])}", + "<S id=\"unrelated\" d={[Core.other, Core['other'].leaf]}>", + "Unrelated: {text(Core[\"other\"].leaf)} and {text('unrelated.sub')}", + "", + "<S id='unrelated.sub'>", + "Sub text.", + "</S>", "</S>", "", ].join("\n"); -const M2A_APP_BEFORE = [ +const OTHER_TS_L = [ 'import CORE, { text } from "../specs/Core.xspec";', "", - "CORE.top.mid;", - 'CORE.top.mid["kid-x"];', - "CORE['top'].mid;", - 'text(CORE.top["mid"]);', + "CORE.other;", + "text(CORE['other'].leaf);", "", ].join("\n"); -const M2A_APP_AFTER = [ - 'import CORE, { text } from "../specs/Core.xspec";', - "", - "CORE.top.neo;", - 'CORE.top.neo["kid-x"];', - "CORE['top'].neo;", - 'text(CORE.top["neo"]);', - "", -].join("\n"); +// Fixture M (arms 3 and 4): the renamed segment `mid` is a TS identifier, +// referenced in dot access, in computed access of both quote kinds, and in +// local strings of both quote kinds. Arm 3 renames it to `neo`: dot stays dot +// and every computed segment keeps its quotes. Arm 4 renames it to `neo-2`: +// dot access cannot hold it and becomes double-quoted computed access (the +// 6.4 fallback), while the computed segments keep their quote kinds and the +// string literals hold any segment — untouched dot parts after the converted +// segment (`.kid-x`, `.mid` after `['top']`) stay as they are. + +/** Fixture M's `specs/Core.mdx`; `seg` spells the renamed segment. */ +function coreM(seg: string): string { + return [ + '<S id="top">', + "Top text.", + "", + `<S id="top.${seg}">`, + "Mid text.", + "", + `<S id="top.${seg}.kid-x">`, + "Kid text.", + "</S>", + "</S>", + "", + `<S id="top.aid" d={["top.${seg}", 'top.${seg}.kid-x', 'top.res']}>`, + `Embeds: {text("top.${seg}.kid-x")} and {text('top.${seg}')} and {text('top.res')}`, + "</S>", + "", + '<S id="top.res">', + "Res text.", + "</S>", + "</S>", + "", + ].join("\n"); +} -// Arm B: the new segment `neo-2` is not a TypeScript identifier. A dot-access -// affected part cannot keep its form and is written as double-quoted computed -// access; a double-quoted computed affected part keeps its form; untouched -// dot parts after the converted segment, and string-literal forms (which hold -// any segment), are preserved. -const M2B_CORE_BEFORE = [ - '<S id="top">', - "Top text.", - "", - '<S id="top.mid">', - "Mid text.", - "", - '<S id="top.mid.kid">', - "Kid text.", - "</S>", - "</S>", - "", - '<S id="top.aid" d={"top.mid"}>', - 'Embeds: {text("top.mid.kid")}', - "</S>", - "</S>", - "", -].join("\n"); +/** + * Fixture M's `specs/Refs.mdx`; `dot` spells an affected dot-access segment + * (`.mid`, `.neo`, or the `["neo-2"]` fallback), `seg` an affected computed one. + */ +function refsM(dot: string, seg: string): string { + return [ + 'import Core from "./Core.xspec"', + "", + `<S id="refs" d={[Core.top${dot}, Core.top["${seg}"], Core.top['${seg}'], Core['top']${dot}]}>`, + `Embeds: {text(Core.top${dot}["kid-x"])} and {text(Core.top['${seg}']['kid-x'])} and {text(Core.top['res'])}`, + "</S>", + "", + ].join("\n"); +} -const M2B_CORE_AFTER = [ - '<S id="top">', - "Top text.", - "", - '<S id="top.neo-2">', - "Mid text.", - "", - '<S id="top.neo-2.kid">', - "Kid text.", - "</S>", - "</S>", - "", - '<S id="top.aid" d={"top.neo-2"}>', - 'Embeds: {text("top.neo-2.kid")}', - "</S>", - "</S>", - "", -].join("\n"); +/** Fixture M's `src/app.ts`: markers and `text(...)` calls, every access form. */ +function appM(dot: string, seg: string): string { + return [ + 'import CORE, { text } from "../specs/Core.xspec";', + "", + `CORE.top${dot};`, + `CORE.top${dot}["kid-x"];`, + `CORE.top['${seg}'];`, + `CORE['top']${dot};`, + `text(CORE.top["${seg}"]);`, + `text(CORE.top['${seg}']["kid-x"]);`, + "", + ].join("\n"); +} -const M2B_REFS_BEFORE = [ +const OTHER_MDX_M = [ 'import Core from "./Core.xspec"', "", - '<S id="refs" d={[Core.top.mid, Core.top["mid"]]}>', - "Embeds: {text(Core.top.mid.kid)}", - "</S>", - "", -].join("\n"); - -const M2B_REFS_AFTER = [ - 'import Core from "./Core.xspec"', + "<S id=\"unrelated\" d={[Core.top.res, Core['top']['res']]}>", + "Unrelated: {text(Core.top[\"res\"])} and {text('unrelated.sub')}", "", - '<S id="refs" d={[Core.top["neo-2"], Core.top["neo-2"]]}>', - 'Embeds: {text(Core.top["neo-2"].kid)}', + "<S id='unrelated.sub'>", + "Sub text.", + "</S>", "</S>", "", ].join("\n"); -const M2B_APP_BEFORE = [ - 'import CORE, { text } from "../specs/Core.xspec";', - "", - "CORE.top.mid;", - "text(CORE.top.mid.kid);", - "", -].join("\n"); - -const M2B_APP_AFTER = [ +const OTHER_TS_M = [ 'import CORE, { text } from "../specs/Core.xspec";', "", - 'CORE.top["neo-2"];', - 'text(CORE.top["neo-2"].kid);', + "CORE.top.res;", + "text(CORE['top'].res);", "", ].join("\n"); -/** One T6.4-2 arm: stage, build, rename, byte-compare every rewritten file. */ +/** + * One T6.4-2 arm: stage, build, rename, then byte-compare every staged file + * against its composed expectation — the rewritten files against their + * post-rename composition, the untouched ones against their staged bytes. + */ async function runMinimalEditArm( product: ProductBinding, + oldId: string, newId: string, sources: Readonly<Record<string, string>>, expected: Readonly<Record<string, string>>, @@ -995,19 +1022,28 @@ async function runMinimalEditArm( await expectExit( product, workspace, - ["rename", "specs/Core.mdx", "top.mid", newId], + ["rename", "specs/Core.mdx", oldId, newId], 0, - `${context}: \`rename specs/Core.mdx top.mid ${newId}\``, + `${context}: \`rename specs/Core.mdx ${oldId} ${newId}\``, ); for (const [rel, bytes] of Object.entries(expected)) { + const touched = bytes !== sources[rel]; await assertFileBytes( workspace.path(rel), bytes, - `${context}: ${rel} after the rename — rewrites are minimal in-place ` + - `edits: quote style and access form of untouched reference parts ` + - `are preserved byte-wise and only the affected parts change; where ` + - `a form cannot be kept, a non-identifier segment is written as ` + - `double-quoted computed access (SPEC 6.4, 2.4; H-4)`, + `${context}: ${rel} after the rename — ` + + (touched + ? `the rewritten file must differ from its original in the ` + + `rewritten segments alone: rewrites are minimal in-place edits ` + + `keeping each reference's quote style and access form, and each ` + + `\`id\` attribute's quotes, wherever the new name admits them ` + + `(dot stays dot, computed stays computed in its own quote kind, ` + + `a string literal keeps its quotes); only a dot-access segment ` + + `whose new name is not a TS identifier falls back to ` + + `double-quoted computed access (SPEC 6.4, 2.4, 2.7; H-4)` + : `a file holding no reference to an affected identity must come ` + + `through byte-identical (SPEC 6.4: only the affected parts ` + + `change)`), ); } }); @@ -1016,44 +1052,80 @@ async function runMinimalEditArm( const T6_4_2 = defineProductTest({ id: "T6.4-2", title: - "minimal edits: quote style (single vs double) and access form (dot vs computed) of untouched reference parts are preserved byte-wise and only the affected parts change; where the form cannot be kept, a new segment that is not a TS identifier is written as double-quoted computed access (SPEC 6.4, 2.4)", + "minimal edits: quote style (single vs double) and access form (dot vs computed) of untouched reference parts are preserved byte-wise and only the affected parts change; the rewritten segment keeps every keepable form — a computed segment stays computed in its own quote kind whether or not the new name is a TS identifier, dot stays dot for an identifier-valid name, single-quoted local strings and single-quoted `id` attributes keep their quotes — and only a dot segment whose new name is not a TS identifier becomes double-quoted computed access; every rewritten `.mdx` and `.ts` file is byte-equal to its composed expectation and untouched files stay byte-identical (SPEC 6.4, 2.4, 2.7)", run: async (product) => { - // Arm A: keepable forms — every rewrite is the in-place minimal edit. + const stagedL = { + "specs/Core.mdx": coreL("login-v2"), + "specs/Refs.mdx": refsL("login-v2"), + "specs/Other.mdx": OTHER_MDX_L, + "src/app.ts": appL("login-v2"), + "src/other.ts": OTHER_TS_L, + }; + const expectedL = (seg: string) => ({ + "specs/Core.mdx": coreL(seg), + "specs/Refs.mdx": refsL(seg), + "specs/Other.mdx": OTHER_MDX_L, + "src/app.ts": appL(seg), + "src/other.ts": OTHER_TS_L, + }); + + // Arm 1: computed segment → identifier-valid name; forms kept. + await runMinimalEditArm( + product, + "login-v2", + "login2", + stagedL, + expectedL("login2"), + "T6.4-2 arm 1 (login-v2 → login2: computed and single-quoted forms kept)", + ); + + // Arm 2: computed segment → non-identifier name; forms kept. + await runMinimalEditArm( + product, + "login-v2", + "login-v3", + stagedL, + expectedL("login-v3"), + "T6.4-2 arm 2 (login-v2 → login-v3: computed and single-quoted forms kept)", + ); + + const stagedM = { + "specs/Core.mdx": coreM("mid"), + "specs/Refs.mdx": refsM(".mid", "mid"), + "specs/Other.mdx": OTHER_MDX_M, + "src/app.ts": appM(".mid", "mid"), + "src/other.ts": OTHER_TS_M, + }; + const expectedM = (dot: string, seg: string) => ({ + "specs/Core.mdx": coreM(seg), + "specs/Refs.mdx": refsM(dot, seg), + "specs/Other.mdx": OTHER_MDX_M, + "src/app.ts": appM(dot, seg), + "src/other.ts": OTHER_TS_M, + }); + + // Arm 3: dot segment → identifier-valid name; dot stays dot. await runMinimalEditArm( product, + "top.mid", "top.neo", - { - "specs/Core.mdx": M2A_CORE_BEFORE, - "specs/Refs.mdx": M2A_REFS_BEFORE, - "src/app.ts": M2A_APP_BEFORE, - }, - { - "specs/Core.mdx": M2A_CORE_AFTER, - "specs/Refs.mdx": M2A_REFS_AFTER, - "src/app.ts": M2A_APP_AFTER, - }, - "T6.4-2 identifier-segment arm (top.mid → top.neo)", + stagedM, + expectedM(".neo", "neo"), + "T6.4-2 arm 3 (top.mid → top.neo: dot stays dot, computed keeps quotes)", ); - // Arm B: the dot form cannot hold `neo-2` — double-quoted computed access. + // Arm 4: dot segment → non-identifier name; the double-quoted computed + // fallback for dot access alone, every computed segment keeping its quotes. await runMinimalEditArm( product, + "top.mid", "top.neo-2", - { - "specs/Core.mdx": M2B_CORE_BEFORE, - "specs/Refs.mdx": M2B_REFS_BEFORE, - "src/app.ts": M2B_APP_BEFORE, - }, - { - "specs/Core.mdx": M2B_CORE_AFTER, - "specs/Refs.mdx": M2B_REFS_AFTER, - "src/app.ts": M2B_APP_AFTER, - }, - "T6.4-2 non-identifier-segment arm (top.mid → top.neo-2)", + stagedM, + expectedM('["neo-2"]', "neo-2"), + "T6.4-2 arm 4 (top.mid → top.neo-2: dot falls back to double-quoted computed access, computed keeps quotes)", ); }, }); - // --------------------------------------------------------------------------- // T6.4-3 — validation refusals (exit 1, nothing modified) // --------------------------------------------------------------------------- diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index ddc0096..7bc7544 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -203,7 +203,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.3-3": ["6.3"], "T6.3-4": ["6.3", "12.0"], "T6.4-1": ["6.4"], - "T6.4-2": ["6.4"], + "T6.4-2": ["6.4", "2.4", "2.7"], "T6.4-3": ["6.4", "14"], "T6.4-4": ["6.4", "12.0"], "T6.4-5": ["6.4"], From f122975c3f34f104ceacb033c0acd97efef57aa9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 08:36:56 +0000 Subject: [PATCH 205/226] =?UTF-8?q?sdg(phase-9):=20T6.4-3=20two-bearer=20c?= =?UTF-8?q?ollision=20arm=20=E2=80=94=20a=20second=20staged=20file=20holdi?= =?UTF-8?q?ng=20`a`/`a.c`=20beside=20`b`/`b.c`,=20`rename=20a=20b`=20refus?= =?UTF-8?q?ed=20as=20one=20`refused-id-collision`=20finding=20locating=20e?= =?UTF-8?q?xactly=20both=20bearers=20`b`=20and=20`b.c`=20(index-wise=20in?= =?UTF-8?q?=2012.7=20order,=20the=20enclosing=20bearer's=20location=20star?= =?UTF-8?q?t-bounded=20before=20its=20child's=20construct,=20path=20null),?= =?UTF-8?q?=20modifying=20nothing;=20the=20case=20joins=20the=20exported?= =?UTF-8?q?=20refusal=20table=20so=20T6.6-3's=20preview=20equivalence=20an?= =?UTF-8?q?d=20T14-7's=20report=20contract=20stage=20it=20identically,=20t?= =?UTF-8?q?he=20fixture=20exported=20whole=20for=20Task=2046=20(Task=2020)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit support.ts gains `BearerLocationExpectation` (file, whole-construct window, optional `startBefore` bound) and `assertFindingLocatesExactly` — the every-participant strictness for nested bearers, where whole-construct windows alone cannot tell "`b` and `b.c`" from "`b.c` twice". section-6.4.ts's `RefusalExpectation` gains `locatedAtEach`, asserted by `expectRefusalModifiesNothing`; the arm also carries `locatedAt` on the second bearer so consumers asserting SOME-quantified only still fail a product locating the first alone. Verification: section-6.4 7/7, section-6.6 5/5, section-14 8/8 against the built product; `npm run test:self` 338/338 (T6.4-3 is in no certification manifest); a throwaway self-test confirmed the assertion rejects the child twice, the parent alone or twice, reversed order, and a non-null path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 21 ----- test/suite/registry/section-6.4.ts | 119 ++++++++++++++++++++++++++++- test/suite/registry/support.ts | 96 +++++++++++++++++++++++ 3 files changed, 212 insertions(+), 24 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 4a1dae8..ba3a46c 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,27 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 20 — T6.4-3: two-bearer collision — one `refused-id-collision` locating both `b` and `b.c` - -Cites: TEST-SPEC T6.4-3 (rename `a`→`b` where `a.c` exists beside `b` and `b.c`: -refused with one `refused-id-collision` finding locating every colliding -bearer — `b` and `b.c`; nothing modified); T14-7 (the finding locates every -colliding bearer); SPEC 6.4, 14. - -Now: `test/suite/registry/section-6.4.ts` ≈ lines 1060–1130 stage the single -bearer `a.sib` only. - -Do: add the arm (or restage the existing one) with sections `a`, `a.c`, `b`, -`b.c` in one or two files; `rename a b` → exit 1, exactly one -`refused-id-collision` finding whose locations are exactly the two bearers -`b` and `b.c` (tag ranges as the adapter decodes them), journal and sources -byte-identical. Export the fixture (source bytes and the two expected -locations) so Task 46 (T14-7) asserts the same collision without a second -staging. - -Verify: `npx vitest run … test/suite/section-6.4.test.ts`; `npm run test:self` -green. - ### Task 21 — T6.4-4: nonexistent `<file>` spelled as an `.mdx` present on disk but in no spec group (exit 2) Cites: TEST-SPEC T6.4-4 (a `<file>` operand naming no discovered spec source is diff --git a/test/suite/registry/section-6.4.ts b/test/suite/registry/section-6.4.ts index 93e1588..3c15a45 100644 --- a/test/suite/registry/section-6.4.ts +++ b/test/suite/registry/section-6.4.ts @@ -125,12 +125,17 @@ import { } from "../../helpers/snapshot.js"; import type { ProductBinding, RunResult } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; -import type { ConcernedIdentity, FindingSourceExpectation } from "./support.js"; +import type { + BearerLocationExpectation, + ConcernedIdentity, + FindingSourceExpectation, +} from "./support.js"; import { assertAppliedMapping, assertConditionCounts, assertEdgeSetEqual, assertFindingLocated, + assertFindingLocatesExactly, assertFindingMentionsLocation, assertFindingNamesIdentity, assertSameJson, @@ -362,6 +367,15 @@ export interface RefusalExpectation { readonly finding: string; /** At least one location names this file (and byte window when given). */ readonly locatedAt?: FindingSourceExpectation; + /** + * The finding's complete location set — exactly one location per listed + * bearer, none beside, index-wise in 12.7's within-finding order (SPEC 14: + * `refused-id-collision` locates every colliding bearer — the + * every-participant strictness of T6.4-3's two-bearer arm, T14-7). + * Declared beside `locatedAt`, whose SOME-quantified check consumers + * asserting it alone still apply. + */ + readonly locatedAtEach?: readonly BearerLocationExpectation[]; /** At least one identities entry names this concerned identity. */ readonly identity?: ConcernedIdentity; } @@ -416,6 +430,13 @@ async function expectRefusalModifiesNothing( `${context}: the refusal's concerned construct`, ); } + if (expected.locatedAtEach !== undefined) { + assertFindingLocatesExactly( + finding, + expected.locatedAtEach, + `${context}: the refusal's complete located-bearer set`, + ); + } if (expected.identity !== undefined) { assertFindingNamesIdentity( finding, @@ -1135,7 +1156,8 @@ const T6_4_2 = defineProductTest({ // only the differs-from-old check, `a.sib` only the collision check, `x.mid` // and `b.c` only the structural parent rules. The remaining 6.4 clause — all // rewritten references resolve — admits no discriminating fixture (TEST-SPEC -// T6.4-3) and is exercised as the always-passing side of T6.4-1. +// T6.4-3) and is exercised as the always-passing side of T6.4-1. The +// two-bearer collision arm stages its own file beside this one (below). const V3_FILE = "specs/A.mdx"; const V3_SOURCE = [ '<S id="a">', @@ -1167,6 +1189,95 @@ const V3_SIB_WINDOW = byteWindow( V3_SIB_CONSTRUCT, ); +// T6.4-3's two-bearer collision arm (TEST-SPEC T6.4-3, T14-7): a second +// file holding `a` with child `a.c` beside `b` with child `b.c`, so that +// `rename a b` makes the new ID `b` AND the prefix-replaced `b.c` each +// collide with an ID remaining in the file (SPEC 6.4: the new ID, and each +// ID the prefix replacement produces; IDs are unique within a source file, +// 1.3, so this file's `a` beside V3_SOURCE's `a` is valid, and rename's +// collision check reads the origin file alone). The refusal is ONE +// `refused-id-collision` finding locating BOTH bearers (SPEC 14: one +// finding per reason, locating every colliding bearer) — a product +// locating the first alone fails. `b.c`'s construct lies inside `b`'s, so +// the bearer expectations carry the start bound that attributes the first +// location to `b` alone (support.ts BearerLocationExpectation). Exported +// whole — file, source bytes, argv, and the two expected bearer locations — +// so T14-7 asserts the same collision over the shared staging. +export const TWO_BEARER_COLLISION_FILE = "specs/B.mdx"; +const TWO_BEARER_CHILD_CONSTRUCT = '<S id="b.c">\nBeta child text.\n</S>'; +const TWO_BEARER_PARENT_CONSTRUCT = [ + '<S id="b">', + "Beta text.", + "", + TWO_BEARER_CHILD_CONSTRUCT, + "</S>", +].join("\n"); +export const TWO_BEARER_COLLISION_SOURCE = [ + '<S id="a">', + "Alpha text.", + "", + '<S id="a.c">', + "Alpha child text.", + "</S>", + "</S>", + "", + TWO_BEARER_PARENT_CONSTRUCT, + "", +].join("\n"); +export const TWO_BEARER_COLLISION_ARGV: readonly string[] = [ + "rename", + TWO_BEARER_COLLISION_FILE, + "a", + "b", +]; +const TWO_BEARER_CHILD_WINDOW = byteWindow( + TWO_BEARER_COLLISION_SOURCE.slice( + 0, + TWO_BEARER_COLLISION_SOURCE.indexOf(TWO_BEARER_CHILD_CONSTRUCT), + ), + TWO_BEARER_CHILD_CONSTRUCT, +); +/** + * The two colliding bearers in 12.7's within-finding order: `b`, whose + * construct encloses `b.c`'s — its location starts before the child's + * construct at any precision — then `b.c`. + */ +export const TWO_BEARER_COLLISION_BEARERS: readonly BearerLocationExpectation[] = + [ + { + file: TWO_BEARER_COLLISION_FILE, + window: byteWindow( + TWO_BEARER_COLLISION_SOURCE.slice( + 0, + TWO_BEARER_COLLISION_SOURCE.indexOf(TWO_BEARER_PARENT_CONSTRUCT), + ), + TWO_BEARER_PARENT_CONSTRUCT, + ), + startBefore: TWO_BEARER_CHILD_WINDOW.start, + }, + { + file: TWO_BEARER_COLLISION_FILE, + window: TWO_BEARER_CHILD_WINDOW, + }, + ]; +/** The arm as one T6.4-3 refusal case (a member of RENAME_REFUSAL_CASES). */ +export const TWO_BEARER_COLLISION_CASE: RenameRefusalCase = { + argv: TWO_BEARER_COLLISION_ARGV, + expected: { + finding: "refused-id-collision", + // The SOME-quantified concern every consumer asserts: the SECOND bearer, + // so a product locating the first alone fails there too. + locatedAt: { + file: TWO_BEARER_COLLISION_FILE, + window: TWO_BEARER_CHILD_WINDOW, + }, + locatedAtEach: TWO_BEARER_COLLISION_BEARERS, + }, + reason: + "new ID `b` and the prefix-replaced `b.c` each colliding with an ID " + + "remaining in the file — one finding locating both bearers", +}; + /** * One T6.4-3 refusal case: the full rename argv (without `--json`), the one * refusal finding the staging isolates (SPEC 14), and its diagnosis context. @@ -1189,6 +1300,7 @@ export interface RenameRefusalCase { export const RENAME_REFUSAL_CONFIG = SPECS_ONLY_CONFIG; export const RENAME_REFUSAL_FILES: Readonly<Record<string, string>> = { [V3_FILE]: V3_SOURCE, + [TWO_BEARER_COLLISION_FILE]: TWO_BEARER_COLLISION_SOURCE, }; // Each arm's expected refusal finding (SPEC 14): the exact stable code, with @@ -1231,6 +1343,7 @@ export const RENAME_REFUSAL_CASES: readonly RenameRefusalCase[] = [ }, reason: "new ID colliding with an existing ID in the file", }, + TWO_BEARER_COLLISION_CASE, { argv: ["rename", V3_FILE, "a.mid", "x.mid"], expected: { @@ -1256,7 +1369,7 @@ export const RENAME_REFUSAL_CASES: readonly RenameRefusalCase[] = [ const T6_4_3 = defineProductTest({ id: "T6.4-3", title: - "validation refusals (exit 1): a new ID that is invalid (1.4), equal to the old ID, colliding with an existing ID, or violating structural parent rules each refuses the rename and modifies nothing (workspace byte-compare) — each refusal reported as the form-exact 12.7 findings-only report holding exactly one finding with its exact stable refusal code (refused-invalid-id, refused-identity-unchanged, refused-id-collision, refused-structural-parent) and the concerned identity or located colliding bearer (SPEC 6.4, 1.4, 1.3, 12.0, 12.7, 14)", + "validation refusals (exit 1): a new ID that is invalid (1.4), equal to the old ID, colliding with an existing ID, or violating structural parent rules each refuses the rename and modifies nothing (workspace byte-compare) — each refusal reported as the form-exact 12.7 findings-only report holding exactly one finding with its exact stable refusal code (refused-invalid-id, refused-identity-unchanged, refused-id-collision, refused-structural-parent) and the concerned identity or located colliding bearer — the collision staged with one bearer and with two (`rename a b` where `a.c` sits beside `b` and `b.c`: the new ID and the prefix-replaced `b.c` each collide, one refused-id-collision finding locating exactly both bearers `b` and `b.c`, a product locating the first alone failing) (SPEC 6.4, 1.4, 1.3, 12.0, 12.7, 14)", run: async (product) => { await withWorkspace( RENAME_REFUSAL_CONFIG, diff --git a/test/suite/registry/support.ts b/test/suite/registry/support.ts index 485170d..b224a2c 100644 --- a/test/suite/registry/support.ts +++ b/test/suite/registry/support.ts @@ -343,6 +343,102 @@ export function assertFindingMentionsLocation( ); } +/** + * One expected bearer of a jointly located concern whose bearers may nest — + * a colliding section and its colliding child (SPEC.md 14, 6.4): its + * containing file, its whole construct's byte window (the module-header + * window convention: any in-construct precision passes), and, for a bearer + * whose construct encloses another expected bearer's, `startBefore` — the + * byte offset where the first enclosed construct begins, an exclusive bound + * the location's start must fall before. An enclosed bearer's construct + * lies within its parent's window, so windows alone cannot tell "the parent + * and the child" from "the child twice"; the bound attributes each location + * to one bearer at whatever precision the product locates — the opening + * tag, the `id` attribute, and the whole construct all start inside the + * parent's own leading bytes, before any enclosed construct. + */ +export interface BearerLocationExpectation { + /** The workspace-relative, `/`-separated source file (SPEC.md 1.5, 14). */ + readonly file: string; + /** The bearer's whole construct as a byte window (`byteWindow`). */ + readonly window: { readonly start: number; readonly end: number }; + /** Exclusive bound on the location's start: where an enclosed bearer begins. */ + readonly startBefore?: number; +} + +/** + * Assert a finding locates EVERY expected bearer and nothing else (SPEC.md + * 14's location-cardinality rule — a condition several constructs jointly + * violate is one finding carrying a location for every participating + * construct, no representative chosen; the every-participant strictness, + * with none of `assertFindingMentionsLocation`'s SOME-quantified + * tolerance): exactly one location per bearer, index-wise in 12.7's + * within-finding order (file bytes, then start, then end — an enclosing + * bearer precedes the bearers it encloses at any precision), each in its + * bearer's file within the bearer's byte window and, where a `startBefore` + * bound is declared, starting before it; and, locating in source, the + * finding concerns no path (12.7: `path` null for located conditions). + */ +export function assertFindingLocatesExactly( + finding: Finding, + bearers: readonly BearerLocationExpectation[], + context: string, +): void { + const rendered = (): string => + finding.locations + .map( + (location) => + `${renderPathValue(location.file)} [${String(location.range.start)}, ` + + `${String(location.range.end)})`, + ) + .join("; "); + if (finding.locations.length !== bearers.length) { + fail( + `${context}: one finding carries a location for every participating ` + + `bearer and none beside — expected exactly ${String(bearers.length)} ` + + `location(s), got ${String(finding.locations.length)} ` + + `[${rendered()}] (SPEC.md 14, 12.7; message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + bearers.forEach((bearer, index) => { + const location = finding.locations[index]!; + const inFile = location.file === bearer.file; + const inWindow = + location.range.start >= bearer.window.start && + location.range.end <= bearer.window.end; + const attributable = + bearer.startBefore === undefined || + location.range.start < bearer.startBefore; + if (!inFile || !inWindow || !attributable) { + fail( + `${context}: location #${String(index + 1)} must locate bearer ` + + `#${String(index + 1)} — in ${JSON.stringify(bearer.file)} within ` + + `its byte window [${String(bearer.window.start)}, ` + + `${String(bearer.window.end)}]` + + (bearer.startBefore === undefined + ? "" + : `, starting before byte ${String(bearer.startBefore)} (the ` + + `bearer's own leading bytes: an enclosed bearer's location ` + + `is never this one's)`) + + ` — in 12.7's within-finding order (file bytes, then start, then ` + + `end); got ${renderPathValue(location.file)} ` + + `[${String(location.range.start)}, ${String(location.range.end)}) ` + + `among [${rendered()}] (SPEC.md 14, 12.7; message: ` + + `${JSON.stringify(finding.message)})`, + ); + } + }); + if (finding.path !== null) { + fail( + `${context}: a finding locating in source concerns no path — ` + + `\`path\` is null for located conditions (SPEC.md 12.7, 14); got ` + + `${renderPathValue(finding.path)} (message: ` + + `${JSON.stringify(finding.message)})`, + ); + } +} + /** A concerned identity, named by its containing file and its ID (SPEC.md 1.5). */ export interface ConcernedIdentity { /** The workspace-relative file whose `#`-form identity names the concern. */ From 506bc87ff86b29aa9a8869e13e41ab9508921bc8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 08:50:48 +0000 Subject: [PATCH 206/226] =?UTF-8?q?sdg(phase-9):=20T6.4-4=20second=20nonex?= =?UTF-8?q?istent-<file>=20spelling=20=E2=80=94=20a=20valid=20`docs/Stray.?= =?UTF-8?q?mdx`=20present=20on=20disk,=20holding=20a=20section=20spelling?= =?UTF-8?q?=20the=20old=20ID=20`a`,=20but=20outside=20every=20spec=20group?= =?UTF-8?q?;=20the=20base=20arm=20pins=20its=20absence=20from=20`ids=20--j?= =?UTF-8?q?son`'s=20discovered=20set,=20then=20`rename=20docs/Stray.mdx=20?= =?UTF-8?q?a=20a2`=20exits=202=20with=20the=2012.7=20error=20document=20be?= =?UTF-8?q?side=20the=20absent-on-disk,=20nonexistent-old-ID,=20and=20wron?= =?UTF-8?q?g-kind=20rows,=20the=20whole=20table=20run=20inside=20a=20whole?= =?UTF-8?q?-root=20modifies-nothing=20compare=20in=20the=20valid-workspace?= =?UTF-8?q?=20and=20ordering=20arms=20alike;=20the=20row=20and=20file=20jo?= =?UTF-8?q?in=20the=20exported=20usage=20table=20so=20T6.6-3's=20preview?= =?UTF-8?q?=20equivalence=20covers=20it=20(Task=2021)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 16 ---- test/suite/registry/section-6.4.ts | 124 +++++++++++++++++++++++------ 2 files changed, 100 insertions(+), 40 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index ba3a46c..a4cfaf7 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,22 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 21 — T6.4-4: nonexistent `<file>` spelled as an `.mdx` present on disk but in no spec group (exit 2) - -Cites: TEST-SPEC T6.4-4 (a `<file>` operand naming no discovered spec source is -a usage error, exit 2, in both spellings: absent on disk, and present on disk -but matched by no configured spec group); SPEC 6.4, 12.0. - -Now: `test/suite/registry/section-6.4.ts` ≈ lines 1352–1354 stage only the -absent-on-disk spelling. - -Do: add the second spelling: write a valid `.mdx` outside every spec-group -glob (e.g. `docs/stray.mdx` with globs `specs/**/*.mdx`), `rename docs/stray.mdx -<id> <new>` → exit 2 with the 12.7 error document (decoded through the -existing usage-error adapter), nothing modified. - -Verify: `npx vitest run … test/suite/section-6.4.test.ts`. - ### Task 22 — T6.4-5: `move` arm — a `typeof` reference to a section-moved node stays byte-unchanged Cites: TEST-SPEC T6.4-5 (revised: a code-file `typeof` reference to a node is diff --git a/test/suite/registry/section-6.4.ts b/test/suite/registry/section-6.4.ts index 3c15a45..3b9e56b 100644 --- a/test/suite/registry/section-6.4.ts +++ b/test/suite/registry/section-6.4.ts @@ -14,8 +14,11 @@ // a form cannot be kept, the rewritten part uses dot access for segments that // are valid TypeScript identifiers, double-quoted computed access for // segments that are not, and double-quoted string literals. Type-level -// references record no edges and are not rewritten. A nonexistent `<file>` or -// old ID is a usage error (12.0) checked before source validation, and so is +// references record no edges and are not rewritten. A nonexistent `<file>` — +// absent on disk, or an `.mdx` present on disk but matched by no spec group: +// a file named in an argument exists as a member of the discovered set +// (12.0) — or old ID is a usage error (12.0) checked before source +// validation, and so is // a `<file>` naming a discovered code source — a wrong-kind operand, judged // like existence before any content question (6.4); the old ID's existence is // parse-local, judged over spelled identities (11.2): a bearer whose node @@ -83,7 +86,10 @@ // document (12.0: with JSON output in effect, an exit-2 invocation emits // the error document as its entire stdout — no report, no validation // findings: the 12.0-ordering discriminator) and the usage error message -// on stderr (12.0), asserted for presence, not wording. The masking arm +// on stderr (12.0), asserted for presence, not wording — the whole table +// run inside one whole-root modifies-nothing compare (12.0), the base +// arm first pinning through `ids --json` that the stray `.mdx` present +// on disk is outside the discovered set (12.3). The masking arm // asserts exit 1 with a findings report of exactly one 14.20 naming the // unparseable file with a location (SPEC 14, H-3). The parse-local // existence arms (SPEC 6.4, 11.2) assert the invalid-workspace refusal @@ -111,6 +117,7 @@ import { decodeAppliedMappingReport, decodeEdgesReport, decodeFindingsReport, + decodeIdsReport, decodeNodeReport, decodeNodeRowsReport, } from "../../helpers/adapters/index.js"; @@ -1439,6 +1446,26 @@ const U4_BROKEN_SOURCE = [ const U4_CODE_FILE = "src/app.ts"; const U4_CODE_SOURCE = "export function noop(): void {}\n"; +// The second nonexistent-`<file>` spelling (TEST-SPEC T6.4-4): a valid `.mdx` +// present on disk, holding a section spelling the old ID, but outside every +// configured spec group (`specs/**/*.mdx`, SPEC 7) — a file named in an +// argument exists as a member of the discovered set (SPEC 12.0, 11.4's +// operand rule), so this operand is as nonexistent as an absent path. It +// spells the same old ID `a` that the discovered `specs/A.mdx` bears, so a +// product probing the filesystem for the operand finds the old ID whether it +// then reads it from the named file or looks it up over the discovered set +// — and proceeds instead of exiting 2. Its `a` beside `specs/A.mdx`'s is no +// duplicate-ID condition even when discovered (SPEC 14 condition 3 is a +// duplicate within a file), so the base arm pins the stray file's absence +// from the discovered set directly, through `ids --json` (SPEC 12.3). +const U4_STRAY_FILE = "docs/Stray.mdx"; +const U4_STRAY_SOURCE = [ + '<S id="a">', + "Stray text outside every spec group.", + "</S>", + "", +].join("\n"); + // Parse-local existence fixtures (SPEC 6.4, 11.2). Two sections both // spelling the same ID: every bearer's node identity is undefined (11.2, // duplicate spellings), yet each spells `dup`, so the old ID exists and the @@ -1496,7 +1523,16 @@ export const RENAME_USAGE_CASES: readonly (readonly [ readonly string[], string, ])[] = [ - [["rename", "specs/Missing.mdx", "a", "a2"], "nonexistent <file>"], + [ + ["rename", "specs/Missing.mdx", "a", "a2"], + "nonexistent <file>, absent on disk", + ], + [ + ["rename", U4_STRAY_FILE, "a", "a2"], + "nonexistent <file>, an .mdx present on disk (holding a section " + + "spelling the old ID) but matched by no spec group — a file named in " + + "an argument exists as a member of the discovered set (SPEC 12.0)", + ], [["rename", U4_FILE, "nope", "nope2"], "nonexistent old ID"], [ ["rename", U4_CODE_FILE, "a", "a2"], @@ -1507,13 +1543,15 @@ export const RENAME_USAGE_CASES: readonly (readonly [ ]; /** The ordering arm's staging (valid sources + a failing file + the code - * source), exported for T6.6-3: on it, exit 2 beside unrelated validation - * errors realizes "argument checks precede" — previewed or not. */ + * source + the undiscovered stray `.mdx`), exported for T6.6-3: on it, exit 2 + * beside unrelated validation errors realizes "argument checks precede" — + * previewed or not. */ export const RENAME_USAGE_CONFIG = SPEC_AND_CODE_CONFIG; export const RENAME_USAGE_ORDERING_FILES: Readonly<Record<string, string>> = { [U4_FILE]: U4_SOURCE, [U4_BAD_FILE]: U4_BAD_SOURCE, [U4_CODE_FILE]: U4_CODE_SOURCE, + [U4_STRAY_FILE]: U4_STRAY_SOURCE, }; /** @@ -1536,23 +1574,55 @@ export const RENAME_SOLO_ARGV: readonly string[] = [ const T6_4_4 = defineProductTest({ id: "T6.4-4", title: - "usage errors (exit 2): a nonexistent `<file>`, a nonexistent old ID, and a discovered code source as `<file>` — a wrong-kind operand, judged like existence before any content question — are usage errors checked before source validation, the same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering); an old ID inside an unparseable origin file is masked — the validation findings are reported and the command exits 1; and old-ID existence is parse-local over spelled identities: an ID two sections both spell, or one whose sole bearer spells it beneath an ancestor spelling no identity, exists — the duplicate-ID or ancestor finding refuses instead (exit 1, never exit 2) — while an old ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent, exit 2 even beside that file's findings (SPEC 6.4, 11.2, 12.0, 14, 14.20)", + "usage errors (exit 2): a nonexistent `<file>` — absent on disk, or an `.mdx` present on disk but matched by no spec group (a file named in an argument exists as a member of the discovered set) — a nonexistent old ID, and a discovered code source as `<file>` — a wrong-kind operand, judged like existence before any content question — are usage errors checked before source validation, the same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering); an old ID inside an unparseable origin file is masked — the validation findings are reported and the command exits 1; and old-ID existence is parse-local over spelled identities: an ID two sections both spell, or one whose sole bearer spells it beneath an ancestor spelling no identity, exists — the duplicate-ID or ancestor finding refuses instead (exit 1, never exit 2) — while an old ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent, exit 2 even beside that file's findings (SPEC 6.4, 11.2, 12.0, 14, 14.20)", run: async (product) => { // --- Base arm: a valid workspace --- await withWorkspace( SPEC_AND_CODE_CONFIG, - { [U4_FILE]: U4_SOURCE, [U4_CODE_FILE]: U4_CODE_SOURCE }, + { + [U4_FILE]: U4_SOURCE, + [U4_CODE_FILE]: U4_CODE_SOURCE, + [U4_STRAY_FILE]: U4_STRAY_SOURCE, + }, async (workspace) => { const context = "T6.4-4 valid-workspace arm"; await buildOk(product, workspace, `${context}: \`build\``); - for (const [argv, label] of RENAME_USAGE_CASES) { - await expectRenameUsageError( - product, - workspace, - argv, - `${context}, ${label}`, + // Staging premise: the stray file is outside the discovered set — + // `ids --json` lists the discovered spec sources (SPEC 12.3), and it + // lists `specs/A.mdx` but never `docs/Stray.mdx`. Pinning this makes + // the exit-2 assertion below demonstrably a discovered-set judgement + // over a file present on disk, not a filesystem miss. + const idsLabel = `${context}: \`ids --json\` premise`; + const listed = decodeIdsReport( + await runJson(product, workspace, ["ids", "--json"], idsLabel), + idsLabel, + ).files.map((entry) => entry.file); + if (!listed.includes(U4_FILE) || listed.includes(U4_STRAY_FILE)) { + fail( + `${context}: staging premise — the discovered spec sources must ` + + `include ${U4_FILE} and exclude the stray ${U4_STRAY_FILE} ` + + `(outside every spec group, SPEC 7; a file named in an ` + + `argument exists as a member of the discovered set, SPEC ` + + `12.0), but \`ids --json\` listed ${JSON.stringify(listed)}`, ); } + // Every usage error modifies nothing (SPEC 12.0): one whole-root + // byte compare around the table — derived files, sources, and the + // stray file alike. + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, label] of RENAME_USAGE_CASES) { + await expectRenameUsageError( + product, + workspace, + argv, + `${context}, ${label}`, + ); + } + }, + `${context}: the usage errors modify nothing (SPEC 12.0)`, + ); }, ); @@ -1578,16 +1648,22 @@ const T6_4_4 = defineProductTest({ `at least one validation finding (SPEC 14)`, ); } - for (const [argv, label] of RENAME_USAGE_CASES) { - await expectRenameUsageError( - product, - workspace, - argv, - `${context}, ${label}, with unrelated validation errors present ` + - `— the existence and wrong-kind checks precede source ` + - `validation (SPEC 6.4, 12.0)`, - ); - } + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, label] of RENAME_USAGE_CASES) { + await expectRenameUsageError( + product, + workspace, + argv, + `${context}, ${label}, with unrelated validation errors ` + + `present — the existence and wrong-kind checks precede ` + + `source validation (SPEC 6.4, 12.0)`, + ); + } + }, + `${context}: the usage errors modify nothing (SPEC 12.0)`, + ); }, ); From f99f809f1a25a6dfe82670427c6c2017382737fe Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 09:04:58 +0000 Subject: [PATCH 207/226] =?UTF-8?q?sdg(phase-9):=20T6.4-5=20move=20arm=20?= =?UTF-8?q?=E2=80=94=20a=20`typeof`=20reference=20to=20a=20node=20then=20s?= =?UTF-8?q?ection-moved=20into=20another=20file=20stays=20byte-unchanged,?= =?UTF-8?q?=20naming=20the=20vacated=20identity,=20the=20workspace=20valid?= =?UTF-8?q?,=20and=20the=20move's=20preview=20`mapping`/`files`,=20applied?= =?UTF-8?q?=20mapping,=20and=20journal=20entry=20the=20same=20as=20on=20a?= =?UTF-8?q?=20twin=20workspace=20without=20the=20reference=20(H-4/H-6=20pr?= =?UTF-8?q?oduct-to-itself=20compare)=20(Task=2022)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twin workspaces differing only by `src/app.ts` holding `typeof CORE.core.mid`: in each, `build`, then `move specs/Core.mdx#core.mid specs/Target.mdx#hub.mid --preview --json` (findings [], plan present, `mapping` exactly the one moved pair), the real move with `--json` (applied mapping the same pair), the journal's one appended entry, then `build` and `check` clean. The code file is asserted byte-identical to its staged bytes; the preview's `files` plan and the journal bytes are asserted identical across the twins. Verified: section-6.4 suite 7/7 against the built product (T6.4-5 4.4 s, both arms); a mutated expected mapping fails as a diagnosed HarnessAssertionError; `npm run test:self` 17 files / 338 tests green; typecheck and format clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 19 --- test/suite/registry/section-6.4.ts | 253 ++++++++++++++++++++++++++++- 2 files changed, 251 insertions(+), 21 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index a4cfaf7..90f740d 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,25 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 22 — T6.4-5: `move` arm — a `typeof` reference to a section-moved node stays byte-unchanged - -Cites: TEST-SPEC T6.4-5 (revised: a code-file `typeof` reference to a node is -not a reference the operation rewrites — after a section move of that node -the referencing `.ts` file is byte-unchanged, the workspace valid, and the -applied mapping and journal entry identical to the same move on a twin -workspace without the reference); SPEC 6.4, 6.5, 4.5. - -Now: `test/suite/registry/section-6.4.ts` ≈ lines 1575–1600 cover `rename` -only. - -Do: add the move arm mirroring the rename arm: twin workspaces differing only -by a `.ts` file holding a `typeof SPEC.a.b` reference; section-move `a.b` in -both; assert the `.ts` file byte-identical to its staged bytes, `check` clean, -and the journal's appended entry and the preview's mapping byte-identical -across the twins (H-4/H-6 product-to-itself compare). - -Verify: `npx vitest run … test/suite/section-6.4.test.ts`. - ### Task 23 — T6.5-1: specifier-rewrite byte contract for the file-form move Cites: TEST-SPEC T6.5-1 (revised: after a file move, every importing `.ts` diff --git a/test/suite/registry/section-6.4.ts b/test/suite/registry/section-6.4.ts index 3b9e56b..a958705 100644 --- a/test/suite/registry/section-6.4.ts +++ b/test/suite/registry/section-6.4.ts @@ -102,6 +102,15 @@ // 14.17 — a repeated `id` is condition 17, never 14.1, and spells no // identity, SPEC 14, 11.2) so its exit-2 assertion demonstrably runs // beside that file's findings. +// - T6.4-5's move arm compares the product to itself across twin workspaces +// (H-6; H-4's product-to-itself exception for the journal's opaque entry): +// the twins differ only by the code file holding the `typeof` reference, +// so the section move's preview plan — `mapping` and `files`, form-exact +// 12.7 members; a file bearing only a type-level reference is no file the +// operation would rewrite (SPEC 6.6, 6.4) — its applied mapping, and the +// journal's one appended entry must agree byte-for-byte between them, +// while the code file keeps its staged bytes and the workspace stays +// valid. // - T6.4-7 "byte-identical to a fresh build of the rewritten sources" is the // H-6 two-directory protocol: a second workspace is seeded with the // post-rename configuration, sources, and journal (derived files are @@ -112,7 +121,12 @@ import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; -import type { GraphEdge, NodeReport } from "../../helpers/adapters/index.js"; +import type { + AppliedMappingPair, + GraphEdge, + NodeReport, + PreviewFileEntry, +} from "../../helpers/adapters/index.js"; import { decodeAppliedMappingReport, decodeEdgesReport, @@ -120,8 +134,10 @@ import { decodeIdsReport, decodeNodeReport, decodeNodeRowsReport, + decodePreviewReport, } from "../../helpers/adapters/index.js"; import { + assertBytesEqual, assertFileBytes, fail, parseJsonStdout, @@ -1832,11 +1848,171 @@ const T5_APP_AFTER = [ "", ].join("\n"); +// Move arm (SPEC 6.4: a rename or move alike can leave type-level references +// naming vacated identities). Twin workspaces differing only by the code +// file: `core.mid` is moved by the section form into an existing target +// parent in another file; the `typeof` reference to it is left naming the +// vacated identity byte-for-byte, and the move's plan, applied mapping, and +// journal entry are the same as without the reference. The code file holds +// the type-level reference alone — nothing the operation rewrites — so its +// staged bytes are its expected bytes. +const T5_TARGET = "specs/Target.mdx"; +const T5_TARGET_SOURCE = ['<S id="hub">', "Hub text.", "</S>", ""].join("\n"); +const T5_MOVE_APP_SOURCE = [ + 'import CORE from "../specs/Core.xspec";', + "", + "type MidNode = typeof CORE.core.mid;", + "", +].join("\n"); +const T5_MOVE_ARGV: readonly string[] = [ + "move", + `${T5_CORE}#core.mid`, + `${T5_TARGET}#hub.mid`, +]; +// The complete mapping the section move journals: the moved node alone, +// re-identified by prefix replacement of `core.mid` with `hub.mid` (SPEC +// 6.5) — it has no descendants, and no identity outside the subtree moves. +const T5_MOVE_MAPPING: readonly AppliedMappingPair[] = [ + { from: `${T5_CORE}#core.mid`, to: `${T5_TARGET}#hub.mid` }, +]; + +interface T5MoveTwinOutcome { + /** The preview's `files` plan (form-exact 12.7). */ + readonly files: readonly PreviewFileEntry[]; + /** The journal's exact bytes after the move: its one appended entry. */ + readonly journal: Uint8Array; +} + +/** + * Drive the T6.4-5 section move on one twin: `build` (the valid-workspace + * precondition), the `--preview --json` plan, then the real move with + * `--json` — its report the applied mapping (SPEC 6.5: reported as rename + * does; T6.4-1's protocol) — and the journal's one appended entry (SPEC + * 6.1). Returns the preview's `files` and the journal bytes for the + * cross-twin compare. + */ +async function driveT5MoveTwin( + product: ProductBinding, + workspace: TestWorkspace, + label: string, +): Promise<T5MoveTwinOutcome> { + const context = `T6.4-5 move arm, ${label}`; + await buildOk( + product, + workspace, + `${context}: \`build\` over the staged workspace`, + ); + const journalBefore = await workspace.kind(JOURNAL_PATH); + if (journalBefore !== "absent") { + fail( + `${context}: staging premise — no journal file exists before the ` + + `first journaled operation (SPEC 6.1); found ${journalBefore} at ` + + `${JOURNAL_PATH}`, + ); + } + const previewArgv = [...T5_MOVE_ARGV, "--preview", "--json"]; + const preview = decodePreviewReport( + await runJson( + product, + workspace, + previewArgv, + `${context}: \`${previewArgv.join(" ")}\` — the preview succeeds ` + + `exactly when the real operation would proceed (SPEC 6.6)`, + ), + context, + ); + assertSameJson( + preview.findings, + [], + `${context}: a preview whose real operation would proceed reports ` + + `findings [] (SPEC 6.6, 12.7)`, + ); + if ( + preview.mapping === null || + preview.files === null || + preview.delta === null + ) { + fail( + `${context}: a successful preview reports its plan — \`mapping\`, ` + + `\`files\`, and \`delta\` are null exactly on refusal (SPEC 6.6, ` + + `12.7); got mapping ${preview.mapping === null ? "null" : "present"}, ` + + `files ${preview.files === null ? "null" : "present"}, delta ` + + `${preview.delta === null ? "null" : "present"}`, + ); + } + assertSameJson( + preview.mapping, + T5_MOVE_MAPPING, + `${context}: the preview's \`mapping\` is the complete identity mapping ` + + `the move would journal — the moved node alone, re-identified by ` + + `prefix replacement (SPEC 6.6, 6.5, 12.7)`, + ); + const moveArgv = [...T5_MOVE_ARGV, "--json"]; + assertAppliedMapping( + decodeAppliedMappingReport( + await runJson( + product, + workspace, + moveArgv, + `${context}: \`${moveArgv.join(" ")}\``, + ), + context, + ), + T5_MOVE_MAPPING, + `${context}: the successful section move's report is the applied ` + + `mapping — exactly the identity pair the operation journaled (SPEC ` + + `6.5, 6.4, 6.6, 12.0)`, + ); + const journal = await readJournal( + workspace, + `${context}: the journal after the move`, + ); + const lines = journalLineCount(journal); + if (lines !== 1) { + fail( + `${context}: the move must append its full mapping to the journal as ` + + `exactly one line-oriented entry — the journal came into existence ` + + `with this first journaled operation (SPEC 6.5, 6.1); found ` + + `${String(lines)} line(s) in ${String(journal.length)} bytes`, + ); + } + return { files: preview.files, journal }; +} + +/** + * The moved workspace stays xspec-valid: neither `build` nor `check` + * reports any finding — in particular none for a type-level reference to + * the vacated identity (SPEC 6.4: a consumer type error outside xspec's + * validations; 4.5: type-level references are unrestricted). + */ +async function assertT5ValidAfterMove( + product: ProductBinding, + workspace: TestWorkspace, + label: string, +): Promise<void> { + const context = `T6.4-5 move arm, ${label}`; + await buildOk( + product, + workspace, + `${context}: \`build\` after the move — no finding for the type-level ` + + `reference to the vacated identity (SPEC 6.4, 4.5)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context}: \`check\` after the move — no finding for the type-level ` + + `reference to the vacated identity (SPEC 6.4, 4.5, 12.2)`, + ); +} + const T6_4_5 = defineProductTest({ id: "T6.4-5", title: - "type-level references: a `typeof`-level reference to the old identity is not rewritten by rename, and the workspace stays xspec-valid — the consumer type error is outside xspec's validations, so `build` and `check` report no finding for it (SPEC 6.4, 4.5)", + "type-level references: a `typeof`-level reference to the old identity is not rewritten by rename, and the workspace stays xspec-valid — the consumer type error is outside xspec's validations, so `build` and `check` report no finding for it; move arm: the same `typeof` reference to a node then section-moved into another file is left byte-unchanged, naming the vacated identity, the workspace valid, and the move's preview plan, applied mapping, and journal entry the same as on a twin workspace without the reference (SPEC 6.4, 6.5, 4.5)", run: async (product) => { + // Rename arm. await withWorkspace( SPEC_AND_CODE_CONFIG, { [T5_CORE]: T5_CORE_SOURCE, [T5_APP]: T5_APP_BEFORE }, @@ -1881,6 +2057,79 @@ const T6_4_5 = defineProductTest({ ); }, ); + + // Move arm: twin workspaces differing only by the code file holding the + // `typeof` reference to the node the section move re-identifies. + const withReference = await withWorkspace( + SPEC_AND_CODE_CONFIG, + { + [T5_CORE]: T5_CORE_SOURCE, + [T5_TARGET]: T5_TARGET_SOURCE, + [T5_APP]: T5_MOVE_APP_SOURCE, + }, + async (workspace) => { + const outcome = await driveT5MoveTwin( + product, + workspace, + "with the `typeof` reference", + ); + await assertFileBytes( + workspace.path(T5_APP), + T5_MOVE_APP_SOURCE, + "T6.4-5 move arm: the code file after the section move — its " + + "`typeof`-level reference keeps naming the vacated identity " + + "byte-for-byte, and its import stays (its binding had no " + + "references, SPEC 6.5): type-level references record no edges " + + "and are not rewritten, by a rename or move alike (SPEC 6.4, 4.5)", + ); + await assertT5ValidAfterMove( + product, + workspace, + "with the `typeof` reference", + ); + return outcome; + }, + ); + const withoutReference = await withWorkspace( + SPEC_AND_CODE_CONFIG, + { [T5_CORE]: T5_CORE_SOURCE, [T5_TARGET]: T5_TARGET_SOURCE }, + async (workspace) => { + const outcome = await driveT5MoveTwin( + product, + workspace, + "twin without the reference", + ); + await assertT5ValidAfterMove( + product, + workspace, + "twin without the reference", + ); + return outcome; + }, + ); + // Product-to-itself compare across the twins (H-6; H-4's exception for + // the journal's opaque entry): the type-level reference changes nothing + // the move plans, reports, or journals. Both twins' preview `mapping` + // and applied mapping were asserted above against the same fixture + // pair, so they agree; the `files` plan and the journal entry are + // compared here directly. + assertSameJson( + withReference.files, + withoutReference.files, + "T6.4-5 move arm: the preview's `files` plan with the `typeof` " + + "reference vs the twin without it — every file the operation would " + + "rewrite, with every edit, must be the same: a code file bearing " + + "only a type-level reference is no file the move rewrites (SPEC 6.6, " + + "6.4, 12.7; H-6)", + ); + assertBytesEqual( + withReference.journal, + withoutReference.journal, + "T6.4-5 move arm: the journal's one appended entry with the `typeof` " + + "reference vs the twin without it — the move's journaled mapping is " + + "the same as without the reference (SPEC 6.4, 6.1; H-4/H-6 " + + "product-to-itself compare, normalizing nothing)", + ); }, }); From fa2175603c24ba2450bb3abc794d902e238b2d92 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 09:18:52 +0000 Subject: [PATCH 208/226] =?UTF-8?q?sdg(phase-9):=20T6.5-1=20specifier-rewr?= =?UTF-8?q?ite=20byte=20contract=20for=20the=20file-form=20move=20?= =?UTF-8?q?=E2=80=94=20the=20moved=20file,=20the=20importing=20`.mdx`,=20a?= =?UTF-8?q?nd=20the=20importing=20`.ts`=20each=20byte-identical=20to=20the?= =?UTF-8?q?ir=20pre-move=20bytes=20outside=20the=20`import-specifier-rewri?= =?UTF-8?q?te`=20ranges=20a=20`--preview`=20taken=20on=20a=20copy=20report?= =?UTF-8?q?s,=20each=20range=20holding=20one=202.1-form=20string=20literal?= =?UTF-8?q?=20resolving=20from=20the=20file's=20post-move=20directory=20to?= =?UTF-8?q?=20the=20moved=20(or,=20for=20the=20moved=20file's=20own=20impo?= =?UTF-8?q?rt,=20the=20unmoved)=20module,=20quote=20kind=20and=20relative?= =?UTF-8?q?=20spelling=20the=20product's;=20unlisted=20sources=20byte-unch?= =?UTF-8?q?anged=20(Task=2023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 21 -- test/suite/registry/section-6.5.ts | 436 ++++++++++++++++++++++++---- test/suite/registry/traceability.ts | 2 +- 3 files changed, 381 insertions(+), 78 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 90f740d..dc77743 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,27 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 23 — T6.5-1: specifier-rewrite byte contract for the file-form move - -Cites: TEST-SPEC T6.5-1 (revised: after a file move, every importing `.ts` -and the moved file are byte-identical outside the `import-specifier-rewrite` -ranges reported by a `--preview` taken on a copy, and each such range holds a -2.1-form specifier designating the moved module); SPEC 6.5, 6.6, 2.1. - -Now: `test/suite/registry/section-6.5.ts` ≈ lines 770–772 assert resolution -only. - -Do: on a copy of the fixture take `move --preview` and decode its -`import-specifier-rewrite` ranges (existing preview adapter); run the real move -on the original; for each importing `.ts` and for the moved file, assert the -bytes outside the reported ranges equal the pre-move bytes at the same -positions (splice check: pre-move bytes with the ranges replaced by the -post-move contents equals the post-move file) and that each range's new -content is a string-literal specifier that resolves (2.1 relative form) to -the moved module; nothing else changed. - -Verify: `npx vitest run … test/suite/section-6.5.test.ts`. - ### Task 24 — T6.5-3: third-file arm — a third spec source's `d`-chain reference rewritten to the target module Cites: TEST-SPEC T6.5-3 third-file arm ("a spec source that is neither origin diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index 44990c1..caf2f6a 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -28,12 +28,19 @@ // ID is a usage error (12.0). // // Conservative operationalizations (noted per H-4): -// - T6.5-1 "rewritten so everything resolves": SPEC pins resolution, not the -// rewritten specifier's spelling (several relative paths resolve to one -// file), so specifiers are asserted as: the stale quoted spelling gone, a -// spelling naming the moved module's file stem present (every resolving -// specifier ends in `Moved.xspec` / `Other.xspec`), and `check` exit 0 — -// which enforces that all imports and references actually resolve (12.2). +// - T6.5-1 "rewritten so everything resolves" is asserted as the specifier +// rewrite's byte contract (TEST-SPEC T6.5-1): the `import-specifier-rewrite` +// ranges are read by running the preview on a copy of the fixture (SPEC +// 6.6: a rewrite's range is the specifier literal's characters, in +// pre-operation coordinates), and after the real move each rewritten file +// — the moved file, the importing `.mdx`, the importing `.ts` — must be +// its pre-move bytes with exactly those ranges replaced by one string +// literal of 2.1's form designating the right module from the file's +// post-move directory (6.5: beyond its exact edits a move changes no +// bytes). SPEC pins the form, not a canonical spelling (several relative +// paths resolve to one file), so the literal's quote kind and relative +// spelling are the product's, resolved rather than compared; `check` +// exit 0 then enforces that everything actually resolves (12.2). // - "Mapping appended to the journal" uses the SUITE-21 operationalization: // the journal (absent before the first journaled operation, SPEC 6.1) is a // plain file holding exactly one line-oriented entry after the one move; @@ -163,12 +170,19 @@ // resolve" clause is unstageable for T6.4-3's reason. import { Buffer } from "node:buffer"; -import type { GraphEdge } from "../../helpers/adapters/index.js"; +import { posix as posixPath } from "node:path"; +import type { + GraphEdge, + PreviewFileEntry, + SourceRange, +} from "../../helpers/adapters/index.js"; import { decodeAppliedMappingReport, decodeEdgesReport, decodeFindingsReport, decodeNodeRowsReport, + decodePreviewReport, + renderPathValue, } from "../../helpers/adapters/index.js"; import { assertBytesEqual, @@ -444,6 +458,204 @@ function assertLacks( } } +/** UTF-8 byte length of `text` — SPEC 1.7 ranges are byte offsets. */ +function utf8Length(text: string): number { + return Buffer.byteLength(text, "utf8"); +} + +/** + * Byte span (SPEC 1.7) of exactly one occurrence of `fragment` in `source`. + * An absent or ambiguous fragment is a staging defect — a harness error, + * never a product failure: a precomputed span must name its bytes uniquely. + */ +function uniqueSpan( + source: string, + fragment: string, + where: string, +): SourceRange { + const first = source.indexOf(fragment); + if (first === -1) { + throw new Error( + `${where}: staging locator — fragment ${JSON.stringify(fragment)} ` + + `not found`, + ); + } + if (source.indexOf(fragment, first + 1) !== -1) { + throw new Error( + `${where}: staging locator — fragment ${JSON.stringify(fragment)} ` + + `is ambiguous`, + ); + } + const start = utf8Length(source.slice(0, first)); + return { start, end: start + utf8Length(fragment) }; +} + +/** + * The `import-specifier-rewrite` ranges a completed preview reports for the + * file at `rel` — its current, pre-operation path — in the report's own + * order (SPEC 6.6: every file the operation would rewrite, with every edit + * it would make there, each classed; 12.7: edits ordered by range start). + * A file the preview lists other than exactly once, or without a specifier + * rewrite, fails diagnosed: the caller's fixture stages in each such file + * one specifier the file-form move must rewrite. + */ +function specifierRewriteRanges( + files: readonly PreviewFileEntry[], + rel: string, + context: string, +): readonly SourceRange[] { + const entries = files.filter((entry) => entry.file === rel); + if (entries.length !== 1) { + fail( + `${context}: the preview must list ${rel} exactly once among the ` + + `files the move would rewrite (SPEC 6.6, 12.7); found it ` + + `${entries.length} time(s) in [${files + .map((entry) => renderPathValue(entry.file)) + .join(", ")}]`, + ); + } + const ranges = entries[0]!.edits + .filter((edit) => edit.class === "import-specifier-rewrite") + .map((edit) => edit.range); + if (ranges.length === 0) { + fail( + `${context}: the preview's entry for ${rel} reports no ` + + `\`import-specifier-rewrite\` edit, yet the relocation must rewrite ` + + `the specifier staged there (SPEC 6.5, 6.6)`, + ); + } + return ranges; +} + +/** The first offset at which two byte runs differ, or -1 when equal. */ +function firstDifference(a: Uint8Array, b: Uint8Array): number { + const shared = Math.min(a.length, b.length); + for (let i = 0; i < shared; i += 1) { + if (a[i] !== b[i]) return i; + } + return a.length === b.length ? -1 : shared; +} + +/** Up to 24 bytes of `bytes` from `offset`, rendered for a diagnosis. */ +function excerpt(bytes: Uint8Array, offset: number): string { + return JSON.stringify( + Buffer.from(bytes.subarray(offset, offset + 24)).toString("utf8"), + ); +} + +/** + * The file-form move's specifier-rewrite byte contract (TEST-SPEC T6.5-1). + * `post` must be `pre` with each previewed range — read on a copy, in + * pre-operation coordinates (SPEC 6.6, 1.7) — replaced by one string + * literal and nothing else (6.5: beyond its exact edits a move changes no + * bytes): the splice walks `pre` and `post` together, requiring every byte + * outside the ranges to recur at its spliced position, and reads each + * range's post-move content as one quoted literal — the quote kind the + * product's, no quote byte inside — whose text has 2.1's form (a relative + * path beginning with `./` or `../` and ending in `.xspec`) and, resolved + * against `importerDir`, the importing file's post-move directory, + * designates `expectedModule` (2.1: `DIR/NAME.xspec` designates + * `DIR/NAME.mdx`). The relative spelling is the product's — 2.1 pins the + * form, not a canonical spelling — so it is resolved, never compared. + */ +function assertSpecifierRewriteByteContract( + options: { + readonly rel: string; + readonly pre: Uint8Array; + readonly post: Uint8Array; + readonly ranges: readonly SourceRange[]; + readonly importerDir: string; + readonly expectedModule: string; + }, + context: string, +): void { + const { rel, pre, post, ranges, importerDir, expectedModule } = options; + const postBuf = Buffer.from(post.buffer, post.byteOffset, post.byteLength); + let preCursor = 0; + let postCursor = 0; + for (const range of ranges) { + if ( + range.start < preCursor || + range.end <= range.start || + range.end > pre.length + ) { + fail( + `${context}: ${rel} — the previewed import-specifier-rewrite range ` + + `[${range.start}, ${range.end}) must be non-empty, lie within the ` + + `${pre.length} pre-move bytes, and follow the preceding range ` + + `(SPEC 6.6, 1.7)`, + ); + } + // Bytes outside the ranges: the pre-move run before this range recurs + // verbatim at its spliced position. + const kept = pre.subarray(preCursor, range.start); + const spliced = post.subarray(postCursor, postCursor + kept.length); + const drift = firstDifference(kept, spliced); + if (drift !== -1) { + fail( + `${context}: ${rel} — bytes outside the previewed ` + + `import-specifier-rewrite ranges changed: from pre-move byte ` + + `${preCursor + drift} the file held ${excerpt(kept, drift)}… and ` + + `now holds ${excerpt(spliced, drift)}… there — a file-form move ` + + `rewrites nothing beyond the specifier literals (SPEC 6.5, 6.6)`, + ); + } + postCursor += kept.length; + // The range's post-move content: exactly one quoted string literal. + const quote = post[postCursor]; + if (quote !== 0x22 && quote !== 0x27) { + fail( + `${context}: ${rel} — at the previewed import-specifier-rewrite ` + + `range [${range.start}, ${range.end}) the file must hold one ` + + `quoted specifier literal; found ${excerpt(post, postCursor)}… ` + + `(SPEC 6.6, 2.1)`, + ); + } + const close = postBuf.indexOf(quote, postCursor + 1); + if (close === -1) { + fail( + `${context}: ${rel} — the specifier literal opened at post-move ` + + `byte ${postCursor} is never closed (SPEC 2.1)`, + ); + } + const specifier = postBuf.subarray(postCursor + 1, close).toString("utf8"); + const relative = specifier.startsWith("./") || specifier.startsWith("../"); + if (!relative || !specifier.endsWith(".xspec")) { + fail( + `${context}: ${rel} — the rewritten specifier ` + + `${JSON.stringify(specifier)} must be a relative path beginning ` + + `with \`./\` or \`../\` and ending in \`.xspec\` (SPEC 2.1)`, + ); + } + const resolved = posixPath.join(importerDir, specifier); + if (resolved !== expectedModule) { + fail( + `${context}: ${rel} — the rewritten specifier ` + + `${JSON.stringify(specifier)}, resolved against the file's ` + + `directory ${importerDir}/, designates ${resolved}; the ` + + `relocation must make it designate ${expectedModule} so the ` + + `import resolves (SPEC 6.5, 2.1)`, + ); + } + postCursor = close + 1; + preCursor = range.end; + } + // After the last range: the pre-move tail recurs and nothing follows it. + const preTail = pre.subarray(preCursor); + const postTail = post.subarray(postCursor); + const drift = firstDifference(preTail, postTail); + if (drift !== -1) { + fail( + `${context}: ${rel} — after the last previewed ` + + `import-specifier-rewrite range the file must end with its ` + + `pre-move bytes verbatim; they diverge at pre-move byte ` + + `${preCursor + drift} (pre ${excerpt(preTail, drift)}…, post ` + + `${excerpt(postTail, drift)}…) — a file-form move rewrites nothing ` + + `beyond the specifier literals (SPEC 6.5, 6.6)`, + ); + } +} + /** Human rendering of an argv that may carry raw-byte elements. */ function renderArgv(argv: readonly ArgvValue[]): string { return argv @@ -766,10 +978,46 @@ const F1_SEED_FILES = [ JOURNAL_PATH, ] as const; +/** + * The specifier rewrites the file-form move must make (SPEC 6.5), one per + * rewritten file: the file's pre-operation path — the preview lists it + * there (6.6) — and its post-move path, the staged source holding exactly + * one specifier literal, that literal, and the module the rewritten + * specifier must designate (2.1) from the file's post-move directory: the + * moved module for its `.mdx` and `.ts` importers; for the moved file's + * own import, the unmoved `Other` module, now reached from `specs/sub/`. + */ +const F1_SPECIFIER_REWRITES = [ + { + pre: F1_CORE, + post: F1_MOVED, + source: F1_CORE_SOURCE, + literal: '"./Other.xspec"', + module: "specs/Other.xspec", + }, + { + pre: F1_REFS, + post: F1_REFS, + source: F1_REFS_SOURCE, + literal: '"./Core.xspec"', + module: "specs/sub/Moved.xspec", + }, + { + pre: F1_APP, + post: F1_APP, + source: F1_APP_SOURCE, + literal: '"../specs/Core.xspec"', + module: "specs/sub/Moved.xspec", + }, +] as const; + +/** Staged sources the file-form move rewrites nothing in (SPEC 6.5). */ +const F1_UNTOUCHED = ["xspec.config.ts", F1_OTHER] as const; + const T6_5_1 = defineProductTest({ id: "T6.5-1", title: - "file form: `xspec move old.mdx new.mdx` keeps IDs unchanged and changes identities only in their file part; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves; the mapping is appended to the journal; finishing regeneration as T6.4-7 — byte-identical to a fresh `build`, `check` clean; and the command's own report is the applied mapping as T6.4-1 — every journaled identity pair, carried in JSON per 12.0 (SPEC 6.5, 6.4, 6.1, 12.0, 12.1, 14.10; H-3 adapter, report shape unpinned; 6.5: both forms report as rename does — the section form's report is T6.5-3's assertion)", + "file form: `xspec move old.mdx new.mdx` keeps IDs unchanged and changes identities only in their file part; the moved file's own import specifiers and other files' imports of its generated module are rewritten so everything resolves — under the specifier rewrite's byte contract: the moved file, the importing `.mdx`, and the importing `.ts` (T6.2-2's shape — a marker and a `text(...)` call through one `.xspec` import) are each byte-identical to their pre-move bytes outside the `import-specifier-rewrite` ranges a `--preview` taken on a copy reports, and within each such range hold one string literal of 2.1's form designating the moved module (the moved file's own import: the unmoved one) from the file's post-move directory, its quote kind and relative spelling the product's, while every other staged source is byte-unchanged; the mapping is appended to the journal; finishing regeneration as T6.4-7 — byte-identical to a fresh `build`, `check` clean; and the command's own report is the applied mapping as T6.4-1 — every journaled identity pair, carried in JSON per 12.0 (SPEC 6.5, 6.6, 2.1, 1.7, 6.4, 6.1, 12.0, 12.1, 12.7, 14.10; H-3 adapter, report shape unpinned; 6.5: both forms report as rename does — the section form's report is T6.5-3's assertion)", run: async (product) => { await withWorkspace( FULL_CONFIG, @@ -807,6 +1055,92 @@ const T6_5_1 = defineProductTest({ ); await assertF1Edges(product, workspace, F1_CORE, "T6.5-1 pre-move"); + // Specifier-rewrite byte contract, the preview side (TEST-SPEC + // T6.5-1): the `import-specifier-rewrite` ranges — in current, + // pre-operation coordinates (SPEC 6.6, 1.7) — are read by running + // the preview on a copy of the fixture, staged and built like the + // original, whose pre-move state no preview then touches. + // Premises: the preview completes with its full plan (6.6), and + // each rewritten file's ranges are exactly its one staged + // specifier literal's characters, quotes included (6.6: a + // specifier rewrite's range is the literal's characters). + const previewRanges = await withWorkspace( + FULL_CONFIG, + { + [F1_OTHER]: F1_OTHER_SOURCE, + [F1_CORE]: F1_CORE_SOURCE, + [F1_REFS]: F1_REFS_SOURCE, + [F1_APP]: F1_APP_SOURCE, + }, + async (copy) => { + await buildOk( + product, + copy, + "T6.5-1 `build` over the copy staged for the preview", + ); + const context = "T6.5-1 `move … --preview --json` on the copy"; + const report = decodePreviewReport( + await runJson( + product, + copy, + ["move", F1_CORE, F1_MOVED, "--preview", "--json"], + context, + ), + context, + ); + assertSameJson( + report.findings, + [], + `${context}: the preview of the valid file-form move ` + + `completes with findings [] (SPEC 6.6)`, + ); + if (report.files === null) { + fail( + `${context}: the completed preview reports its plan — ` + + `\`files\` non-null (SPEC 6.6, 12.7)`, + ); + } + const files = report.files; + return new Map( + F1_SPECIFIER_REWRITES.map((rewrite) => { + const ranges = specifierRewriteRanges( + files, + rewrite.pre, + context, + ); + assertSameJson( + ranges, + [uniqueSpan(rewrite.source, rewrite.literal, "T6.5-1")], + `${context}: ${rewrite.pre} — the previewed ` + + `import-specifier-rewrite ranges are exactly the one ` + + `staged specifier literal's characters, quotes ` + + `included (SPEC 6.6, 1.7)`, + ); + return [rewrite.pre, ranges] as const; + }), + ); + }, + ); + + // The pre-move bytes of every source the contract reads, taken from + // the original just before the move (staging premise: as staged — + // `build` rewrites no source, T6.1-1). + const preMoveBytes = new Map<string, Uint8Array>(); + for (const rel of [ + ...F1_SPECIFIER_REWRITES.map((rewrite) => rewrite.pre), + ...F1_UNTOUCHED, + ]) { + preMoveBytes.set(rel, await workspace.readBytes(rel)); + } + for (const rewrite of F1_SPECIFIER_REWRITES) { + assertBytesEqual( + preMoveBytes.get(rewrite.pre)!, + rewrite.source, + `T6.5-1: staging premise — ${rewrite.pre} holds its staged ` + + `source before the move`, + ); + } + // The command's own report is the applied mapping — every identity // pair the operation journaled, the information of the preview's // `mapping` (SPEC 6.5: both forms report as rename does; 6.4, 6.6) — @@ -852,56 +1186,46 @@ const T6_5_1 = defineProductTest({ ); } - // Specifier rewrites (module header, H-4): the stale quoted spelling - // is gone and a spelling naming the resolving module remains — the - // moved file's own import, a spec file's import, a code file's - // import (SPEC 6.5). - const movedText = await readSourceText( - workspace, - F1_MOVED, - "T6.5-1 rewrite check", - ); - assertLacks( - movedText, - F1_MOVED, - '"./Other.xspec"', - "the moved file's own import specifiers are rewritten for its new " + - "directory (SPEC 6.5); from specs/sub/ the old spelling no " + - "longer resolves", - "T6.5-1 rewrite check", - ); - assertContains( - movedText, - F1_MOVED, - "Other.xspec", - "every resolving specifier for specs/Other.mdx ends in " + - "`Other.xspec` (SPEC 2.1)", - "T6.5-1 rewrite check", - ); - for (const [rel, stale] of [ - [F1_REFS, '"./Core.xspec"'], - [F1_APP, '"../specs/Core.xspec"'], - ] as const) { - const text = await readSourceText( - workspace, - rel, - "T6.5-1 rewrite check", - ); - assertLacks( - text, - rel, - stale, - "imports of the moved file's generated module are rewritten so " + - "all references continue to resolve (SPEC 6.5)", + // Specifier-rewrite byte contract, the operation side (TEST-SPEC + // T6.5-1; SPEC 6.5: relocation rewrites the moved file's own import + // specifiers and the paths by which other files import its + // generated module, and beyond its exact edits a move changes no + // bytes): each rewritten file — the moved file under its new path, + // the importing `.mdx`, the importing `.ts` — is its pre-move + // bytes with exactly the previewed ranges replaced by one string + // literal of 2.1's form designating the right module from the + // file's post-move directory, the quote kind and relative spelling + // the product's (2.1 pins the form; T6.1-2/H-6 pin the spelling + // product-to-itself). + for (const rewrite of F1_SPECIFIER_REWRITES) { + const kind = await workspace.kind(rewrite.post); + if (kind !== "file") { + fail( + `T6.5-1 rewrite check: expected a plain file at ` + + `${rewrite.post} after the move (SPEC 6.5, 13.4); found ` + + `${kind}`, + ); + } + assertSpecifierRewriteByteContract( + { + rel: rewrite.post, + pre: preMoveBytes.get(rewrite.pre)!, + post: await workspace.readBytes(rewrite.post), + ranges: previewRanges.get(rewrite.pre)!, + importerDir: posixPath.dirname(rewrite.post), + expectedModule: rewrite.module, + }, "T6.5-1 rewrite check", ); - assertContains( - text, - rel, - "Moved.xspec", - "every resolving specifier for the moved module ends in " + - "`Moved.xspec` (SPEC 2.1, 4)", - "T6.5-1 rewrite check", + } + // Nothing else changed: a source the move rewrites nothing in is + // byte-identical to its pre-move bytes (SPEC 6.5). + for (const rel of F1_UNTOUCHED) { + await assertFileBytes( + workspace.path(rel), + preMoveBytes.get(rel)!, + `T6.5-1: ${rel} — a source the file-form move rewrites nothing ` + + `in must be byte-identical to its pre-move bytes (SPEC 6.5)`, ); } diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 7bc7544..20c3290 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -209,7 +209,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.4-5": ["6.4"], "T6.4-6": ["6.4"], "T6.4-7": ["6.4"], - "T6.5-1": ["6.5"], + "T6.5-1": ["6.5", "6.6", "2.1"], "T6.5-2": ["6.5"], "T6.5-3": ["6.5"], "T6.5-4": ["6.5", "14"], From 5f9752bf5c34d726c11be742d3eaf75f707b03d6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 09:32:57 +0000 Subject: [PATCH 209/226] =?UTF-8?q?sdg(phase-9):=20T6.5-3=20third-file=20a?= =?UTF-8?q?rms=20=E2=80=94=20a=20spec=20source=20neither=20origin=20nor=20?= =?UTF-8?q?target,=20importing=20the=20origin=20module=20and=20referencing?= =?UTF-8?q?=20the=20moved=20node=20through=20a=20`d`=20chain,=20has=20that?= =?UTF-8?q?=20reference=20rewritten=20to=20the=20target=20module=20under?= =?UTF-8?q?=20an=20import=20added=20there,=20its=20bytes=20asserted=20with?= =?UTF-8?q?=20T6.5-8's=20discipline=20through=20the=20new=20shared=20helpe?= =?UTF-8?q?r=20`test/helpers/import-insertion.ts`=20(the=20single=20insert?= =?UTF-8?q?ed=20run=20isolated=20by=20diff=20against=20bytes=20composed=20?= =?UTF-8?q?from=206.4/6.5=20and=203,=20identifier=20read=20off=20the=20rew?= =?UTF-8?q?ritten=20reference,=20offset=20the=20product's,=20`\n`=20rules?= =?UTF-8?q?=20per=20line-start-ness),=20the=20origin=20import=20removed=20?= =?UTF-8?q?with=206.5's=20exact=20extent=20when=20the=20moved=20reference?= =?UTF-8?q?=20was=20its=20binding's=20last=20(arm=20a)=20and=20kept=20byte?= =?UTF-8?q?-for-byte=20when=20`th2`=20still=20references=20through=20it=20?= =?UTF-8?q?(arm=20b);=20`query=20edges`=20lists=20the=20third=20file's=20`?= =?UTF-8?q?depends`=20edge=20under=20`Target.mdx#tm`,=20`check`=20clean,?= =?UTF-8?q?=20the=20applied=20mapping=20and=20journal=20entry=20as=20the?= =?UTF-8?q?=20existing=20arm=20asserts;=20the=20helper's=20self-test=20cov?= =?UTF-8?q?ers=20both=20accepted=20offset=20kinds=20and=20every=20violatio?= =?UTF-8?q?n=20T6.5-8=20names=20(Task=2024)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 28 --- test/helpers/import-insertion.ts | 249 ++++++++++++++++++++++++++ test/self/import-insertion.test.ts | 167 +++++++++++++++++ test/suite/registry/section-6.5.ts | 276 ++++++++++++++++++++++++++--- 4 files changed, 672 insertions(+), 48 deletions(-) create mode 100644 test/helpers/import-insertion.ts create mode 100644 test/self/import-insertion.test.ts diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index dc77743..128337a 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,34 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 24 — T6.5-3: third-file arm — a third spec source's `d`-chain reference rewritten to the target module - -Cites: TEST-SPEC T6.5-3 third-file arm ("a spec source that is neither origin -nor target, importing the origin module and referencing a moved node through -it (a `d` chain), has that reference rewritten to the target module under an -import of it added there (bytes per T6.5-8's discipline), the origin import -removed when the moved reference was its binding's last and kept when another -reference through it remains (one arm each); `query edges` reports the third -file's edge under the moved node's new identity and `check` is clean"); SPEC -6.5, 2.1. - -Now: `test/suite/registry/section-6.5.ts` ≈ lines 1321–1323 have no third -file. - -Do: two arms over three spec sources (origin, target, third): (a) the third -file's only reference through the origin binding is to the moved node → after -the section move the reference is rewritten to the target module under an -added import (added-import bytes asserted with T6.5-8's discipline: isolate the -single added run by diff, value-unpinned identifier, `\n` rules), the origin -import removed with 6.5's exact extent; (b) the third file also references an -unmoved origin node → origin import kept byte-for-byte, only the moved -reference rewritten. In both: `query edges` lists the third file's `depends` -edge under the moved node's new identity, `check` clean, journal mapping as the -existing arm asserts. Share the diff-isolation helper with Task 28 (T6.5-8) if -it already landed; otherwise write it in `test/helpers/` for both. - -Verify: `npx vitest run … test/suite/section-6.5.test.ts`. - ### Task 25 — T6.5-4: destination occupied by a directory; symbolic-link path components → `refused-invalid-destination` Cites: TEST-SPEC T6.5-4 (revised: (a) file-form destination occupied by a diff --git a/test/helpers/import-insertion.ts b/test/helpers/import-insertion.ts new file mode 100644 index 0000000..9a780da --- /dev/null +++ b/test/helpers/import-insertion.ts @@ -0,0 +1,249 @@ +// SPEC 6.5's added-import insertion discipline, asserted value-blind +// (TEST-SPEC T6.5-8; shared by T6.5-3's third-file arms and T6.5-8's arms). +// Harness machinery only: no product imports. +// +// An added import "is inserted as a line of its own — the declaration's +// characters followed by a U+000A line terminator, preceded by one when the +// insertion point is not at the start of a line — at an offset where the +// file's grammar permits an import declaration; the identifier choice and +// the insertion offset are implementation latitude" (SPEC 6.5). A test +// therefore composes the receiving file's expected post-operation bytes from +// the rules of 6.4/6.5 and 3 WITHOUT the added import (`base`, with the +// fresh identifier read off the rewritten references), and this module +// isolates, by diff against the product's bytes (`actual`), the single byte +// run whose insertion turns `base` into `actual`, then asserts that run is +// exactly the declaration plus its terminators for the offset it lies at. +// +// Offsets are a range, not a point: a run whose end bytes repeat the bytes +// beside it admits several insertion offsets describing the same bytes +// (`A\n` + `import X\n` + `B` equals `A` + `\nimport X` + `\nB`). Bytes are +// the only observable here, so the discipline holds when SOME admissible +// offset reads as a disciplined insertion — a product is never failed for +// output byte-identical to a conforming one — and is violated when none +// does: a declaration joined to a neighbour with `;`, a mid-line offset +// without its preceding terminator, a spurious blank line, or any terminator +// but U+000A leaves no admissible reading (T6.5-8). + +import { Buffer } from "node:buffer"; +import { posix as posixPath } from "node:path"; +import { fail } from "./assertions.js"; + +const LF = 0x0a; + +/** The single run whose insertion into `base` yields `actual`. */ +export interface SingleInsertion { + /** Byte length of the inserted run. */ + readonly length: number; + /** Lowest admissible insertion offset into `base`. */ + readonly lowestOffset: number; + /** Highest admissible insertion offset into `base`. */ + readonly highestOffset: number; +} + +/** One disciplined reading of an added import (SPEC 6.5, 2.1). */ +export interface AddedImportReading { + /** Insertion offset into `base` (pre-insertion coordinates). */ + readonly offset: number; + /** Whether that offset lies at the start of a line of `base`. */ + readonly atLineStart: boolean; + /** The declaration's characters (no terminator). */ + readonly declaration: string; + /** The identifier the declaration binds. */ + readonly identifier: string; + /** The specifier literal's text, as the product spelled it. */ + readonly specifier: string; +} + +export interface AddedImportOptions { + /** Workspace-relative path of the receiving file, for diagnoses. */ + readonly rel: string; + /** Expected post-operation bytes composed WITHOUT the added import. */ + readonly base: Uint8Array; + /** The product's post-operation bytes. */ + readonly actual: Uint8Array; + /** Workspace-relative POSIX directory of the receiving file. */ + readonly importerDir: string; + /** Workspace-relative module path the specifier must designate. */ + readonly expectedModule: string; + /** The identifier the rewritten references are rooted at. */ + readonly identifier: string; +} + +function firstDifference(a: Uint8Array, b: Uint8Array): number { + const shared = Math.min(a.length, b.length); + for (let i = 0; i < shared; i += 1) { + if (a[i] !== b[i]) return i; + } + return a.length === b.length ? -1 : shared; +} + +function excerpt(bytes: Uint8Array, offset: number, width = 32): string { + return JSON.stringify( + Buffer.from(bytes.subarray(offset, offset + width)).toString("utf8"), + ); +} + +/** + * Isolate the single contiguous byte run whose insertion into `base` yields + * `actual`, failing diagnosed (H-8) when `actual` is not `base` with exactly + * one run inserted — bytes changed elsewhere, nothing inserted, or two + * separate runs. + */ +export function isolateSingleInsertion( + base: Uint8Array, + actual: Uint8Array, + context: string, +): SingleInsertion { + const length = actual.length - base.length; + if (length <= 0) { + const drift = firstDifference(base, actual); + fail( + drift === -1 + ? `${context}: no bytes were inserted — the file is byte-identical ` + + `to its expected bytes without the added import` + : `${context}: the file is ${String(actual.length)} bytes against ` + + `${String(base.length)} expected without the added import, so ` + + `no single run was inserted; the bytes diverge at offset ` + + `${String(drift)} (expected ${excerpt(base, drift)}…, actual ` + + `${excerpt(actual, drift)}…)`, + ); + } + let prefix = 0; + while (prefix < base.length && base[prefix] === actual[prefix]) prefix += 1; + let suffix = 0; + while ( + suffix < base.length && + base[base.length - 1 - suffix] === actual[actual.length - 1 - suffix] + ) { + suffix += 1; + } + const lowestOffset = Math.max(0, base.length - suffix); + const highestOffset = Math.min(prefix, base.length); + if (lowestOffset > highestOffset) { + fail( + `${context}: the file is not its expected bytes with one run ` + + `inserted — the bytes diverge from the expected bytes at offset ` + + `${String(prefix)} (expected ${excerpt(base, prefix)}…, actual ` + + `${excerpt(actual, prefix)}…) and again, counted from the end, ` + + `${String(suffix)} bytes before it: more than one edit, or an edit ` + + `outside the added import`, + ); + } + return { length, lowestOffset, highestOffset }; +} + +const DECLARATION = + /^import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+(["'])([^"'\n\r]*)\2[ \t]*;?$/; + +/** + * Read one admissible offset as a disciplined added import; `null` with a + * reason when it does not read as one. + */ +function readInsertion( + options: AddedImportOptions, + offset: number, + length: number, +): { reading: AddedImportReading } | { reason: string } { + const { base, actual, importerDir, expectedModule, identifier } = options; + const run = actual.subarray(offset, offset + length); + const atLineStart = offset === 0 || base[offset - 1] === LF; + let body = run; + if (!atLineStart) { + if (run[0] !== LF) { + return { + reason: + "the offset is not at the start of a line, so the run must begin " + + "with the preceding U+000A terminator", + }; + } + body = run.subarray(1); + } + if (body.length === 0 || body[body.length - 1] !== LF) { + return { reason: "the run must end with a U+000A line terminator" }; + } + const declaration = Buffer.from(body.subarray(0, body.length - 1)).toString( + "utf8", + ); + const match = DECLARATION.exec(declaration); + if (match === null) { + return { + reason: + `between its terminators the run must hold exactly one default ` + + `import declaration of 2.1's form on one line; it holds ` + + `${JSON.stringify(declaration)}`, + }; + } + const [, bound, , specifier] = match as unknown as [ + string, + string, + string, + string, + ]; + if (bound !== identifier) { + return { + reason: + `the declaration binds ${JSON.stringify(bound)} but the rewritten ` + + `references are rooted at ${JSON.stringify(identifier)}`, + }; + } + const relative = specifier.startsWith("./") || specifier.startsWith("../"); + if (!relative || !specifier.endsWith(".xspec")) { + return { + reason: + `the specifier ${JSON.stringify(specifier)} must be a relative path ` + + `beginning with \`./\` or \`../\` and ending in \`.xspec\` (SPEC 2.1)`, + }; + } + const resolved = posixPath.join(importerDir, specifier); + if (resolved !== expectedModule) { + return { + reason: + `the specifier ${JSON.stringify(specifier)}, resolved against ` + + `${importerDir}/, designates ${resolved} rather than ${expectedModule}`, + }; + } + return { + reading: { offset, atLineStart, declaration, identifier: bound, specifier }, + }; +} + +/** + * Assert that `actual` is `base` with exactly one import declaration + * inserted under SPEC 6.5's line discipline — the declaration's characters + * followed by U+000A at an offset lying at the start of a line, and U+000A, + * the declaration, then U+000A at one that does not — binding `identifier` + * to a 2.1-form specifier designating `expectedModule` from `importerDir`, + * no other byte inserted. The offset and the specifier's relative spelling + * are the product's; the accepted reading is returned. + */ +export function assertAddedImportInsertion( + options: AddedImportOptions, + context: string, +): AddedImportReading { + const label = `${context}: ${options.rel}`; + const insertion = isolateSingleInsertion(options.base, options.actual, label); + const reasons: string[] = []; + for ( + let offset = insertion.highestOffset; + offset >= insertion.lowestOffset; + offset -= 1 + ) { + const read = readInsertion(options, offset, insertion.length); + if ("reading" in read) return read.reading; + reasons.push(`at offset ${String(offset)}: ${read.reason}`); + } + const shown = options.actual.subarray( + insertion.highestOffset, + insertion.highestOffset + insertion.length, + ); + fail( + `${label} — the single inserted run ` + + `${JSON.stringify(Buffer.from(shown).toString("utf8"))} is not an ` + + `added import under 6.5's line discipline (the declaration's ` + + `characters followed by U+000A, preceded by one when the insertion ` + + `point is not at the start of a line; one import of ` + + `${options.expectedModule} binding ${JSON.stringify(options.identifier)}, ` + + `no other byte inserted — SPEC 6.5, 2.1) under any admissible reading: ` + + reasons.join("; "), + ); +} diff --git a/test/self/import-insertion.test.ts b/test/self/import-insertion.test.ts new file mode 100644 index 0000000..8c3236b --- /dev/null +++ b/test/self/import-insertion.test.ts @@ -0,0 +1,167 @@ +// Self-checks for the added-import insertion discipline helper +// (`test/helpers/import-insertion.ts`; TEST-SPEC 17 preamble — harness +// machinery certification does not exercise; SPEC 6.5's line discipline, +// asserted value-blind as T6.5-8 reads it). Conforming insertions are +// accepted at a line-start offset and at a mid-line offset — including the +// ambiguous-offset case where the run's own terminators repeat the bytes +// beside it, so several offsets describe one byte string — and every +// violation T6.5-8 names fails as a diagnosed HarnessAssertionError: a +// declaration joined to a neighbour with `;`, a mid-line offset without +// its preceding terminator, a spurious blank line, a CRLF terminator, a +// declaration binding an identifier the rewritten references do not use, +// a specifier designating the wrong module, nothing inserted, and edits +// beyond one inserted run. + +import { Buffer } from "node:buffer"; +import { expect, test } from "vitest"; +import { HarnessAssertionError } from "../helpers/assertions.js"; +import { + assertAddedImportInsertion, + isolateSingleInsertion, +} from "../helpers/import-insertion.js"; + +const bytes = (text: string): Uint8Array => Buffer.from(text, "utf8"); + +const BASE = [ + 'import Org from "./Origin.xspec"', + "", + '<S id="th" d={T.tm}>', + "Third text.", + "</S>", + "", +].join("\n"); + +function check(actual: string, identifier = "T") { + return assertAddedImportInsertion( + { + rel: "specs/Third.mdx", + base: bytes(BASE), + actual: bytes(actual), + importerDir: "specs", + expectedModule: "specs/Target.xspec", + identifier, + }, + "self", + ); +} + +function expectDiagnosed(run: () => unknown, ...patterns: string[]): void { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(HarnessAssertionError); + const message = (error as Error).message; + for (const pattern of patterns) expect(message).toContain(pattern); + return; + } + throw new Error( + "expected a diagnosed assertion failure, but the helper passed", + ); +} + +test("isolateSingleInsertion reports every admissible offset of one run", () => { + // `A\n` + `import X\n` + `B` equals `A` + `\nimport X` + `\nB`: offsets 1..2. + const found = isolateSingleInsertion( + bytes("A\nB"), + bytes("A\nimport X\nB"), + "self", + ); + expect(found).toEqual({ length: 9, lowestOffset: 1, highestOffset: 2 }); + expectDiagnosed( + () => isolateSingleInsertion(bytes("A\nB"), bytes("A\nB"), "self"), + "no bytes were inserted", + ); + expectDiagnosed( + () => + isolateSingleInsertion(bytes("A\nB"), bytes("A\nimport X\nC"), "self"), + "not its expected bytes with one run inserted", + ); + expectDiagnosed( + () => + isolateSingleInsertion(bytes("A\nB\nC"), bytes("A\nX\nB\nY\nC"), "self"), + "more than one edit", + ); +}); + +test("a disciplined added import is accepted at a line-start offset", () => { + const atTop = check('import T from "./Target.xspec"\n' + BASE); + expect(atTop).toMatchObject({ + offset: 0, + atLineStart: true, + identifier: "T", + specifier: "./Target.xspec", + }); + // After the first line — the ambiguous case: offset 32 (mid-line, before + // the terminator) reads as `\n` + declaration without a trailing + // terminator, offset 33 (line start) as declaration + `\n`; the bytes are + // one string, and the line-start reading is the accepted one. + const second = check( + 'import Org from "./Origin.xspec"\nimport T from "./Target.xspec"\n\n<S id="th" d={T.tm}>\nThird text.\n</S>\n', + ); + expect(second).toMatchObject({ offset: 33, atLineStart: true }); + // Spelling latitude: single quotes, a `../` relative form, a `;`. + expect( + check("import T from '../specs/Target.xspec';\n" + BASE).specifier, + ).toBe("../specs/Target.xspec"); +}); + +test("a disciplined added import is accepted at a mid-line offset", () => { + // Inserted after the first declaration's closing quote, before its + // terminator: `\n` + declaration + `\n`, leaving the neighbour's own + // terminator to start the blank line. + const midLine = + 'import Org from "./Origin.xspec"' + + '\nimport T from "./Target.xspec"\n' + + '\n\n<S id="th" d={T.tm}>\nThird text.\n</S>\n'; + const reading = check(midLine); + expect(reading.atLineStart).toBe(false); + expect(reading.declaration).toBe('import T from "./Target.xspec"'); +}); + +test("every violation of the line discipline fails diagnosed", () => { + const head = 'import Org from "./Origin.xspec"'; + const tail = '\n\n<S id="th" d={T.tm}>\nThird text.\n</S>\n'; + const notImport = "not an added import under 6.5's line discipline"; + // Joined to the neighbour with `;` — still parsing, still resolving. + expectDiagnosed( + () => check(head + '; import T from "./Target.xspec"' + tail), + notImport, + ); + // Mid-line offset without the preceding terminator. + expectDiagnosed( + () => check(head + 'import T from "./Target.xspec"\n' + tail), + notImport, + "must begin with the preceding U+000A", + ); + // A spurious blank line after the declaration. + expectDiagnosed( + () => check('import T from "./Target.xspec"\n\n' + BASE), + notImport, + "exactly one default import declaration", + ); + // CRLF terminator. + expectDiagnosed( + () => check('import T from "./Target.xspec"\r\n' + BASE), + notImport, + ); + // Binds an identifier the rewritten references are not rooted at. + expectDiagnosed( + () => check('import Tgt from "./Target.xspec"\n' + BASE), + notImport, + 'binds "Tgt"', + ); + // Designates the wrong module. + expectDiagnosed( + () => check('import T from "./Origin.xspec"\n' + BASE), + notImport, + "designates specs/Origin.xspec rather than specs/Target.xspec", + ); + // Not 2.1's form. + expectDiagnosed( + () => check('import T from "Target.xspec"\n' + BASE), + notImport, + "relative path", + ); + // Nothing added at all. + expectDiagnosed(() => check(BASE), "no bytes were inserted"); +}); diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index caf2f6a..903e7ed 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -192,6 +192,7 @@ import { parseJsonStdout, } from "../../helpers/assertions.js"; import { assertAcrossDirectoriesDeterministic } from "../../helpers/determinism.js"; +import { assertAddedImportInsertion } from "../../helpers/import-insertion.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; import { @@ -1641,10 +1642,239 @@ const R3_SEED_FILES = [ JOURNAL_PATH, ] as const; +// Third-file arms (TEST-SPEC T6.5-3; SPEC 6.5: "all references across the +// workspace are rewritten"): a spec source that is neither origin nor target +// imports the origin module and references the moved node through it (a `d` +// chain). After the move that reference is rewritten to the target module +// under an import added to the third file. The added declaration's +// identifier and insertion offset are 6.5's latitude, so the file's bytes +// are asserted with T6.5-8's discipline (`assertAddedImportInsertion`): its +// expected post-move bytes are composed from the rules of 6.4/6.5 and 3 +// WITHOUT the added import, the fresh identifier read off the rewritten +// reference, and the single inserted run isolated by diff must be exactly +// the declaration under 6.5's line rules (followed by U+000A; preceded by +// one at a mid-line offset). The origin import's fate splits the arms (6.5: +// removed exactly when its binding had references and the rewrite leaves it +// with none): +// - (a) `Org`'s only reference was to the moved node → the own-line +// declaration's characters are deleted and its emptied line dropped with +// its terminator (6.5, 3); the blank line after it was blank before the +// deletion, so it stays — the composed file begins with that U+000A. +// - (b) `th2` keeps `d={Org.org}` through the binding → the declaration +// survives byte-for-byte; only the moved reference is rewritten. +// The rewritten reference keeps its access form — `Org.org.mv` becomes +// `<fresh>.tm`, dot access for the identifier-valid segment (6.4) — and the +// fresh binding may not collide with `Org` where it stays, nor be `S`, +// `Spec`, or `text` (2.1); `check` would fail either, but the arms name the +// collision first. +const R3_THIRD = "specs/Third.mdx"; +const R3_TARGET_MODULE = "specs/Target.xspec"; + +const R3_THIRD_A_SOURCE = [ + 'import Org from "./Origin.xspec"', + "", + '<S id="th" d={Org.org.mv}>', + "Third text.", + "</S>", + "", +].join("\n"); + +/** Arm (a)'s expected post-move bytes without the added import (6.5, 3). */ +const R3_THIRD_A_BASE = (root: string): string => + ["", `<S id="th" d={${root}.tm}>`, "Third text.", "</S>", ""].join("\n"); + +const R3_THIRD_B_SOURCE = [ + 'import Org from "./Origin.xspec"', + "", + '<S id="th" d={Org.org.mv}>', + "Third text.", + "</S>", + "", + '<S id="th2" d={Org.org}>', + "Third keeps the origin.", + "</S>", + "", +].join("\n"); + +/** Arm (b)'s expected post-move bytes without the added import (6.5). */ +const R3_THIRD_B_BASE = (root: string): string => + [ + 'import Org from "./Origin.xspec"', + "", + `<S id="th" d={${root}.tm}>`, + "Third text.", + "</S>", + "", + '<S id="th2" d={Org.org}>', + "Third keeps the origin.", + "</S>", + "", + ].join("\n"); + +/** The section form's journaled mapping: the moved subtree, nothing else. */ +const R3_MAPPING = [ + { from: `${R3_ORIGIN}#org.mv`, to: `${R3_TARGET}#tm` }, + { from: `${R3_ORIGIN}#org.mv.k1`, to: `${R3_TARGET}#tm.k1` }, + { from: `${R3_ORIGIN}#org.mv.k2`, to: `${R3_TARGET}#tm.k2` }, +] as const; + +/** The complete post-move `depends` edge set of the four-file fixture. */ +const R3_DEPENDS_EDGES: readonly GraphEdge[] = [ + { from: `${R3_ORIGIN}#org.usemv`, to: `${R3_TARGET}#tm`, kind: "depends" }, + { from: `${R3_TARGET}#tgt`, to: `${R3_TARGET}#tm`, kind: "depends" }, + { from: `${R3_TARGET}#tm`, to: `${R3_KEEP}#keep`, kind: "depends" }, + { from: `${R3_TARGET}#tm.k2`, to: `${R3_TARGET}#tm.k1`, kind: "depends" }, +]; + +/** `<S id="th" d={<root>.tm}>` — the third file's rewritten reference. */ +const R3_THIRD_REWRITTEN = /<S id="th" d=\{([A-Za-z_$][A-Za-z0-9_$]*)\.tm\}>/g; + +/** + * The identifier the third file's rewritten reference is rooted at — the + * value-unpinned fresh binding (SPEC 6.5), read off the one place 6.4's + * pinned spelling makes it observable. + */ +function thirdFileReferenceRoot(text: string, context: string): string { + const matches = [...text.matchAll(R3_THIRD_REWRITTEN)]; + const root = matches.length === 1 ? matches[0]?.[1] : undefined; + if (root === undefined) { + fail( + `${context}: ${R3_THIRD} must hold exactly one ` + + `\`<S id="th" d={<binding>.tm}>\` — the third file's reference to ` + + `the moved node rewritten to the target module under the new ` + + `identity, its access form kept (dot access for the ` + + `identifier-valid segment; SPEC 6.5, 6.4); found ` + + `${String(matches.length)} in ${JSON.stringify(text)}`, + ); + } + return root; +} + +/** + * One third-file arm: stage the four-file fixture plus `Third.mdx`, run the + * identical section-form move, and assert the third file's rewrite — the + * moved reference re-rooted at a fresh binding of the target module, added + * under 6.5's line discipline; the origin import removed or kept as + * `originImportKept` says — beside the applied-mapping report, the journal + * entry, the complete `depends` edge set, and a clean `check`. + */ +async function runThirdFileArm( + product: ProductBinding, + created: TestWorkspace[], + arm: { + readonly label: string; + readonly source: string; + readonly base: (root: string) => string; + readonly originImportKept: boolean; + readonly thirdEdges: readonly GraphEdge[]; + }, +): Promise<void> { + const context = `T6.5-3 third-file arm ${arm.label}`; + const workspace = await TestWorkspace.create({ + files: { ...R3_FILES, [R3_THIRD]: arm.source }, + }); + created.push(workspace); + const result = await runProduct(product, { + cwd: workspace.root, + argv: [...R3_MOVE_ARGV], + }); + assertExitCode( + result, + 0, + `${context} \`move specs/Origin.mdx#org.mv specs/Target.mdx#tm --json\``, + ); + assertAppliedMapping( + decodeAppliedMappingReport( + parseJsonStdout(result, `${context} report (SPEC 12.0)`), + context, + ), + [...R3_MAPPING], + `${context}: the applied mapping is exactly the moved subtree's ` + + `prefix-replaced pairs — the third file's rewrite maps no identity ` + + `(SPEC 6.5, 6.4)`, + ); + + const text = await readSourceText(workspace, R3_THIRD, context); + const root = thirdFileReferenceRoot(text, context); + if (arm.originImportKept) { + assertContains( + text, + R3_THIRD, + 'import Org from "./Origin.xspec"\n', + "the `Org` binding keeps a reference (`th2`'s `d={Org.org}`) after " + + "the rewrite, so its import stays byte-for-byte (SPEC 6.5, 2.1)", + context, + ); + if (root === "Org") { + fail( + `${context}: the added import binds \`Org\`, an identifier the ` + + `file's retained origin import already binds — an added import ` + + `binds fresh identifiers colliding with no binding already in ` + + `the file (SPEC 6.5, 2.1, 14.15)`, + ); + } + } else { + assertLacks( + text, + R3_THIRD, + "Origin.xspec", + "the `Org` binding's only reference was to the moved node, so the " + + "rewrite leaves it with none and the import is removed (SPEC 6.5, " + + "2.1)", + context, + ); + } + for (const reserved of ["S", "Spec", "text"]) { + if (root === reserved) { + fail( + `${context}: the added import binds \`${reserved}\`, a ` + + `compiler-provided name no import may bind (SPEC 2.1, 14.15)`, + ); + } + } + // Composed from the rules of 6.4/6.5 and 3 up to the two unknowns — the + // fresh identifier (now known) and the insertion offset (isolated below). + assertAddedImportInsertion( + { + rel: R3_THIRD, + base: Buffer.from(arm.base(root), "utf8"), + actual: await workspace.readBytes(R3_THIRD), + importerDir: posixPath.dirname(R3_THIRD), + expectedModule: R3_TARGET_MODULE, + identifier: root, + }, + `${context}: the third file's rewrite is its composed post-move bytes ` + + `with exactly one import of the target module added under 6.5's ` + + `line discipline (SPEC 6.5, 2.1, 6.4, 3; T6.5-8)`, + ); + + await assertJournalHoldsOneEntry(workspace, `${context} after the move`); + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "depends", context), + [...R3_DEPENDS_EDGES, ...arm.thirdEdges], + `${context}: the complete \`depends\` edge set — the third file's ` + + `edge is reported under the moved node's new identity` + + (arm.originImportKept + ? ", its other edge through the retained origin binding unchanged" + : "") + + ` (SPEC 6.5, 5.2)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context} \`check\` immediately after the move — the third file's ` + + `rewritten reference and added import resolve, the fresh binding ` + + `collides with nothing (14.15), and no staleness remains (SPEC 6.5, ` + + `12.2, 14.10)`, + ); +} + const T6_5_3 = defineProductTest({ id: "T6.5-3", title: - "re-identification and reference conversion: the moved subtree is re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed exactly (an import unreferenced before the move stays); rewritten content is byte-deterministic across two identical fixtures; the full mapping is appended to the journal and reported as the command's own applied-mapping report — the section form reports as rename does, T6.4-1's protocol (SPEC 6.5, 2.1, 6.1, 6.4, 12.0, 12.1, 14.10; H-3 adapter, report shape unpinned)", + "re-identification and reference conversion: the moved subtree is re-identified by prefix replacement; references convert between local and imported forms; needed spec imports are added binding fresh, non-colliding identifiers and unneeded ones removed exactly (an import unreferenced before the move stays); rewritten content is byte-deterministic across two identical fixtures; the full mapping is appended to the journal and reported as the command's own applied-mapping report — the section form reports as rename does, T6.4-1's protocol (SPEC 6.5, 2.1, 6.1, 6.4, 12.0, 12.1, 14.10; H-3 adapter, report shape unpinned); third-file arms — a spec source neither origin nor target, importing the origin module and referencing the moved node through a `d` chain, has that reference rewritten to the target module under an import added there (bytes per T6.5-8's discipline: the single inserted run isolated by diff against bytes composed from 6.4/6.5 and 3, its identifier and offset the product's), the origin import removed exactly when the moved reference was its binding's last and kept byte-for-byte when another reference through it remains, `query edges` listing the third file's `depends` edge under the new identity and `check` clean (SPEC 6.5, 2.1, 6.4, 3)", run: async (product) => { const created: TestWorkspace[] = []; try { @@ -1694,11 +1924,7 @@ const T6_5_3 = defineProductTest({ ), "T6.5-3", ), - [ - { from: `${R3_ORIGIN}#org.mv`, to: `${R3_TARGET}#tm` }, - { from: `${R3_ORIGIN}#org.mv.k1`, to: `${R3_TARGET}#tm.k1` }, - { from: `${R3_ORIGIN}#org.mv.k2`, to: `${R3_TARGET}#tm.k2` }, - ], + [...R3_MAPPING], "T6.5-3: the successful section-form move's report is the applied " + "mapping — exactly the identity pairs the operation journaled: " + "the moved subtree's prefix-replaced identities, nothing else " + @@ -1827,20 +2053,7 @@ const T6_5_3 = defineProductTest({ "depends", "T6.5-3 post-move", ), - [ - { - from: `${R3_ORIGIN}#org.usemv`, - to: `${R3_TARGET}#tm`, - kind: "depends", - }, - { from: `${R3_TARGET}#tgt`, to: `${R3_TARGET}#tm`, kind: "depends" }, - { from: `${R3_TARGET}#tm`, to: `${R3_KEEP}#keep`, kind: "depends" }, - { - from: `${R3_TARGET}#tm.k2`, - to: `${R3_TARGET}#tm.k1`, - kind: "depends", - }, - ], + R3_DEPENDS_EDGES, "T6.5-3: the complete `depends` edge set — every converted, added, " + "and re-identified reference resolves to the new identities " + "(SPEC 6.5, 5.2)", @@ -1907,6 +2120,29 @@ const T6_5_3 = defineProductTest({ "must be byte-identical (SPEC 6.5, 6.4, 12.0; H-4/H-6, " + "normalizing nothing)", ); + + // Third-file arms (SPEC 6.5: all references across the workspace): + // (a) the moved reference was the origin binding's last — import + // removed; (b) another reference through it remains — import kept. + await runThirdFileArm(product, created, { + label: "(a) origin import removed", + source: R3_THIRD_A_SOURCE, + base: R3_THIRD_A_BASE, + originImportKept: false, + thirdEdges: [ + { from: `${R3_THIRD}#th`, to: `${R3_TARGET}#tm`, kind: "depends" }, + ], + }); + await runThirdFileArm(product, created, { + label: "(b) origin import kept", + source: R3_THIRD_B_SOURCE, + base: R3_THIRD_B_BASE, + originImportKept: true, + thirdEdges: [ + { from: `${R3_THIRD}#th`, to: `${R3_TARGET}#tm`, kind: "depends" }, + { from: `${R3_THIRD}#th2`, to: `${R3_ORIGIN}#org`, kind: "depends" }, + ], + }); } finally { for (const workspace of created) { await workspace.dispose(); From a75fbc28bd57b85a8c9b759eed6f0b2789d4a700 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 09:49:41 +0000 Subject: [PATCH 210/226] =?UTF-8?q?sdg(phase-9):=20T6.5-4=20directory-occu?= =?UTF-8?q?pant=20and=20symbolic-link-component=20arms=20=E2=80=94=20a=20f?= =?UTF-8?q?ile-form=20destination=20occupied=20by=20a=20directory=20refuse?= =?UTF-8?q?d=20`refused-destination-exists`=20beside=20the=20plain-file/sy?= =?UTF-8?q?mlink/broken-symlink=20occupants=20(discriminating=20a=20produc?= =?UTF-8?q?t=20probing=20for=20a=20file=20alone);=20`specs/sub`=20staged?= =?UTF-8?q?=20as=20a=20symbolic=20link=20to=20a=20real,=20empty=20director?= =?UTF-8?q?y,=20the=20file-form=20move=20to=20`specs/sub/b.mdx`=20and=20th?= =?UTF-8?q?e=20section-form=20move=20creating=20`specs/sub/new.mdx`=20each?= =?UTF-8?q?=20refused=20as=20exactly=20one=20`refused-invalid-destination`?= =?UTF-8?q?=20finding=20concerning=20the=20destination=20path,=20never=201?= =?UTF-8?q?4.22,=20exit=201,=20modifying=20nothing=20=E2=80=94=20staged=20?= =?UTF-8?q?once=20with=20the=20link=20targeting=20`linked/`=20inside=20the?= =?UTF-8?q?=20root=20(on=20the=20shared=20refusal=20workspace,=20the=20who?= =?UTF-8?q?le-root=20compare=20seeing=20any=20write=20through=20the=20link?= =?UTF-8?q?)=20and=20once,=20on=20its=20own=20exported=20workspace,=20targ?= =?UTF-8?q?eting=20a=20directory=20beside=20the=20root=20in=20the=20tempor?= =?UTF-8?q?ary=20directory,=20that=20target=20compared=20byte-identical=20?= =?UTF-8?q?around=20each=20refusal=20as=20well;=20the=20derived-path=20arm?= =?UTF-8?q?'s=20sibling=20stages=20`mdout/new`=20as=20such=20a=20link=20in?= =?UTF-8?q?stead=20of=20a=20plain=20file,=20refused=20identically;=20T6.6-?= =?UTF-8?q?3=20stages=20the=20two=20new=20workspaces=20identically=20for?= =?UTF-8?q?=20its=20preview-refusal=20equivalence,=20the=20shared=20table'?= =?UTF-8?q?s=20new=20arms=20flowing=20through=20it=20unchanged=20(Task=202?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 24 --- test/suite/registry/section-6.5.ts | 227 ++++++++++++++++++++++++++++- test/suite/registry/section-6.6.ts | 63 ++++++++ 3 files changed, 286 insertions(+), 28 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 128337a..9522f9e 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,30 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 25 — T6.5-4: destination occupied by a directory; symbolic-link path components → `refused-invalid-destination` - -Cites: TEST-SPEC T6.5-4 (revised: (a) file-form destination occupied by a -directory is refused like the plain-file/symlink/broken-symlink occupants; -(b) a destination path with a symbolic-link component — file-form move to -`specs/sub/b.mdx` and section-form move creating `specs/sub/new.mdx`, with -`specs/sub` a symlink to a real empty directory, staged once inside and once -outside the workspace root — is `refused-invalid-destination`, never 14.22, -exit 1, nothing modified, the link and its target byte-identical; and -`<outDir>/new` staged as such a link beside the derived-path arm); SPEC 6.5, -14. - -Now: `test/suite/registry/section-6.5.ts` ≈ lines 1664–1666 (title 2003) -stage plain file, symlink, broken symlink only; no link-component arms. - -Do: add the directory-occupant arm and the four link-component arms (file -form × {inside, outside root}, section form × {inside, outside root}) using the -workspace builder's symlink support (S-2 covers it), plus the `<outDir>/new` -link beside the derived-path arm. Each: exit 1, exactly one -`refused-invalid-destination` finding (never 14.22), sources/journal/link/ -target byte-identical after the run, the link still a link. - -Verify: `npx vitest run … test/suite/section-6.5.test.ts`. - ### Task 26 — T6.5-5: origin `<file>` present on disk but undiscovered (both forms) Cites: TEST-SPEC T6.5-5 (an origin `<file>` naming no discovered spec source is diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index 903e7ed..cf39838 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -170,7 +170,8 @@ // resolve" clause is unstageable for T6.4-3's reason. import { Buffer } from "node:buffer"; -import { posix as posixPath } from "node:path"; +import * as fsp from "node:fs/promises"; +import { join as joinPath, posix as posixPath } from "node:path"; import type { GraphEdge, PreviewFileEntry, @@ -2167,7 +2168,8 @@ const T6_5_3 = defineProductTest({ // - The destination-path arms use the file form of the reference-free A.mdx, // each violating exactly one destination rule under REFUSAL_CONFIG. // - Destination occupants (SPEC 6.5): the file form refuses on ANY occupant -// — a plain file (B.mdx), a symbolic link, a broken symbolic link (target +// — a plain file (B.mdx), a directory (a product probing for a file alone +// sees none and proceeds), a symbolic link, a broken symbolic link (target // absent; a product probing existence through link-following stat sees // that path absent and proceeds to relocate) — and the section form on // any occupant that is not a discovered spec source: a directory, a @@ -2223,6 +2225,7 @@ const V4_B_SOURCE = [ // `build`, plus the out-of-group plain `.mdx` file (in the files map). const V4_SYM_DEST = "specs/SymDest.mdx"; // file form: symlink → B.mdx const V4_GONE_DEST = "specs/GoneDest.mdx"; // file form: broken symlink +const V4_DIR_DEST = "specs/DirDest.mdx"; // file form: directory const V4_DIR_TARGET = "specs/DirTarget.mdx"; // section form: directory const V4_LINK_TARGET = "specs/LinkTarget.mdx"; // section form: symlink → B.mdx const V4_OCC = "docs/Occ.mdx"; // section form: out-of-group `.mdx` file @@ -2288,6 +2291,39 @@ const V4_SOLO_SOURCE = ['<S id="solo">', "Solo text.", "</S>", ""].join("\n"); const V4_MDOUT_OCCUPANT = "mdout/new"; const V4_MDOUT_OCCUPANT_CONTENT = "not a directory\n"; +// The symbolic-link arms of the same clause (SPEC 6.5: a workspace-relative +// directory component of the destination path, or of a derived path the +// destination would generate, occupied by a symbolic link — whatever it +// targets: discovery never traverses one, 7, and writes never traverse or +// replace one, 13.4, 14.22). `specs/sub` is a symbolic link to a real, empty +// directory, so the file-form destination `specs/sub/b.mdx` and the section +// form's created target file `specs/sub/new.mdx` each have that link as a +// directory component. Staged twice: with the link targeting the empty +// directory `linked/` inside the workspace root (on the main refusal +// workspace, beside the occupant arms), and, on its own workspace, a real +// directory outside the root — beside the workspace in the test-owned +// temporary directory, disposed with it. These discriminate a product +// vetting components through link-following stat: it sees a directory at +// the link, proceeds, and writes the moved file — and its regenerated +// derived files — through the link, possibly outside the workspace; the +// outside-root arms therefore also compare the link's target directory +// around each refusal, since the whole-root compare cannot see a write +// landing there. The link lies under no current source's write path and is +// never a source (SPEC 7), so the premise `build` passes; through the link +// the destination path itself is absent (the target directory is empty), so +// no occupant refusal applies beside the component one — one finding, +// refused-invalid-destination concerning the destination path (14, T14-7), +// never 14.22. The derived-path arm's sibling stages `mdout/new` — the emit +// destination's directory component — as such a link (to `linked/`) instead +// of a plain file, refused identically. +const V4_LINK_COMPONENT = "specs/sub"; +const V4_LINKED_DIR = "linked"; // the real, empty inside-root target +const V4_LINKED_TARGET = "../linked"; // spelled from one level below the root +const V4_OUTSIDE_DIR = "outside"; // beside the root in the temporary directory +const V4_OUTSIDE_TARGET = "../../outside"; // work/specs/sub → tempRoot/outside +const V4_LINK_FILE_DEST = "specs/sub/b.mdx"; +const V4_LINK_SECTION_DEST = "specs/sub/new.mdx"; + /** * One T6.5-4 refusal case: the full move argv (without `--json`), the * expected refusal finding — or one expectation per applicable reason where @@ -2299,6 +2335,47 @@ export interface MoveRefusalCase { readonly reason: string; } +/** + * The two link-component arms (V4_LINK_COMPONENT's note) for one staging of + * `specs/sub` — the file-form destination and the section form's created + * target file — each refused refused-invalid-destination concerning the + * destination path (SPEC 6.5, 14, T14-7), never 14.22; `target` names what + * the staged link resolves to, for diagnosis. + */ +function linkComponentCases(target: string): readonly MoveRefusalCase[] { + return [ + { + argv: ["move", "specs/A.mdx", V4_LINK_FILE_DEST], + expected: { + finding: "refused-invalid-destination", + path: V4_LINK_FILE_DEST, + }, + reason: + "file form whose destination path has its directory component " + + `specs/sub occupied by a symbolic link to ${target} — a component ` + + "occupied by anything other than a directory, a symbolic link " + + "whatever it targets, is the move's own refusal, never 14.22; a " + + "product vetting components through link-following stat sees a " + + "directory there, proceeds, and writes the moved file through the " + + "link (SPEC 6.5, 7, 13.4, 14)", + }, + { + argv: ["move", "specs/A.mdx#x", `${V4_LINK_SECTION_DEST}#tnew`], + expected: { + finding: "refused-invalid-destination", + path: V4_LINK_SECTION_DEST, + }, + reason: + "section form creating the target file specs/sub/new.mdx, whose " + + `directory component specs/sub is a symbolic link to ${target} — ` + + "the created target file's path is vetted like the file form's " + + "destination, refused never 14.22; through the link the path is " + + "absent, so no occupant reason applies beside it (SPEC 6.5, 7, " + + "13.4, 14)", + }, + ]; +} + /** * T6.5-4's main-workspace staging and complete refusal-case table, exported * so T6.6-3 can stage each refusal identically and assert the `--preview` @@ -2321,15 +2398,20 @@ export const MOVE_REFUSAL_FILES: Readonly<Record<string, string>> = { * in-group `.mdx` paths discovery ignores, staged before the pre-refusal * `build` — a directory is no source file and discovery never yields a * symbolic link (SPEC 7), so the build stays valid and each occupant arm - * refuses on exactly its staged ground. + * refuses on exactly its staged ground — plus the inside-root staging of + * the link-component arms (V4_LINK_COMPONENT's note): `specs/sub` a + * symbolic link to the real, empty directory `linked/` at the root. */ export async function stageMoveRefusalOccupants( workspace: TestWorkspace, ): Promise<void> { await workspace.dir(V4_DIR_TARGET); + await workspace.dir(V4_DIR_DEST); await workspace.symlink(V4_SYM_DEST, "B.mdx"); await workspace.symlink(V4_LINK_TARGET, "B.mdx"); await workspace.symlink(V4_GONE_DEST, "missing-target.mdx"); + await workspace.dir(V4_LINKED_DIR); + await workspace.symlink(V4_LINK_COMPONENT, V4_LINKED_TARGET, "dir"); } // Each case's expected refusal finding (SPEC 14): the exact stable code with @@ -2383,6 +2465,14 @@ export const MOVE_REFUSAL_CASES: readonly MoveRefusalCase[] = [ "link, target absent — a product probing existence through " + "link-following stat sees the path absent and proceeds (SPEC 6.5)", }, + { + argv: ["move", "specs/A.mdx", V4_DIR_DEST], + expected: { finding: "refused-destination-exists", path: V4_DIR_DEST }, + reason: + "file form whose destination path is occupied by a directory — " + + "whatever kind of filesystem object occupies it; a product probing " + + "for a file alone sees none there and proceeds (SPEC 6.5)", + }, { argv: ["move", "specs/A.mdx#x", `${V4_DIR_TARGET}#tdir`], expected: { finding: "refused-destination-exists", path: V4_DIR_TARGET }, @@ -2504,6 +2594,12 @@ export const MOVE_REFUSAL_CASES: readonly MoveRefusalCase[] = [ "`specs/plain/**` spec glob, isolating 14.19's extension rule " + "(SPEC 6.5, 7.1, 14.19)", }, + // The inside-root staging of the link-component arms (V4_LINK_COMPONENT's + // note): `specs/sub` → `linked/`, staged by stageMoveRefusalOccupants; the + // whole-root compare sees any write landing through the link. + ...linkComponentCases( + "the empty directory linked/ inside the workspace root", + ), ]; /** @@ -2531,6 +2627,64 @@ export const MOVE_DERIVED_PATH_CASE: MoveRefusalCase = { "components sees new/ absent and proceeds (SPEC 6.5, 7.3, 13.1, 13.2, 14)", }; +/** + * T6.5-4's outside-root staging of the link-component arms + * (V4_LINK_COMPONENT's note), exported for T6.6-3: MOVE_LINK_OUTSIDE_FILES + * under MOVE_REFUSAL_CONFIG, `specs/sub` a symbolic link to a real, empty + * directory created beside the workspace root in the test-owned temporary + * directory (disposed with the workspace). Returns that directory's + * absolute path: the caller compares its byte state around each refusal + * (`assertLeavesUnchanged`), since the whole-root compare cannot see a + * write landing through the link outside the root. + */ +export const MOVE_LINK_OUTSIDE_FILES: Readonly<Record<string, string>> = { + [V4_A]: V4_A_SOURCE, + [V4_B]: V4_B_SOURCE, +}; +export async function stageMoveLinkOutsideComponent( + workspace: TestWorkspace, +): Promise<string> { + const outside = joinPath(workspace.tempRoot, V4_OUTSIDE_DIR); + await fsp.mkdir(outside); + await workspace.symlink(V4_LINK_COMPONENT, V4_OUTSIDE_TARGET, "dir"); + return outside; +} +export const MOVE_LINK_OUTSIDE_CASES: readonly MoveRefusalCase[] = + linkComponentCases("an empty directory outside the workspace root"); + +/** + * The derived-path arm's symbolic-link sibling (V4_LINK_COMPONENT's note), + * exported for T6.6-3: MOVE_DERIVED_LINK_FILES under + * MOVE_DERIVED_PATH_CONFIG, with `mdout/new` — the emit destination's + * directory component — staged as a symbolic link to the real, empty + * directory `linked/` instead of a plain file; the link lies under no + * current source's write path (specs/Solo.mdx emits mdout/specs/Solo.md), + * so the premise `build` passes, and the move is refused identically — + * refused-invalid-destination concerning the destination path, never + * 14.22, the link and its target byte-identical afterward. + */ +export const MOVE_DERIVED_LINK_FILES: Readonly<Record<string, string>> = { + [V4_SOLO]: V4_SOLO_SOURCE, +}; +export async function stageMoveDerivedLinkComponent( + workspace: TestWorkspace, +): Promise<void> { + await workspace.dir(V4_LINKED_DIR); + await workspace.symlink(V4_MDOUT_OCCUPANT, V4_LINKED_TARGET, "dir"); +} +export const MOVE_DERIVED_LINK_CASE: MoveRefusalCase = { + argv: MOVE_DERIVED_PATH_CASE.argv, + expected: MOVE_DERIVED_PATH_CASE.expected, + reason: + "derived-path arm's symbolic-link sibling — the emit destination's " + + "directory component mdout/new occupied by a symbolic link to the " + + "empty directory linked/ instead of a plain file (a component occupied " + + "by a symbolic link, whatever it targets; writes never traverse one): " + + "refused refused-invalid-destination concerning the destination path, " + + "never 14.22, the link and its target byte-identical (SPEC 6.5, 7.3, " + + "13.1, 13.2, 13.4, 14)", +}; + /** * T6.5-4's valid-workspace precondition arm (as T6.4-6), exported for * T6.6-3: stage MOVE_PRECONDITION_FILES under MOVE_REFUSAL_CONFIG, `build` @@ -2560,7 +2714,7 @@ export const MOVE_PRECONDITION_CASE: MoveRefusalCase = { const T6_5_4 = defineProductTest({ id: "T6.5-4", title: - "refusals (exit 1, nothing modified): a move creating a spec import cycle or a dependency cycle (refused-cycle, the dependency arm locating the participating `d` spelling); file form whose destination exists — occupied by a plain file, by a symbolic link, and by a broken symbolic link with its target absent, one arm each, the broken-link arm discriminating a product probing existence through link-following stat (refused-destination-exists, concerning that path); section form whose target path is occupied by anything other than a discovered spec source — a directory; a symbolic link resolving to a discovered spec source (discovery never yields a symlink); and an existing `.mdx` file outside every configured spec group, the latter refusing under refused-destination-exists and refused-invalid-destination together, one finding per applicable reason; section form with a 1.4-invalid `<new-id>` (forbidden name `then`; whitespace-bearing segment; the empty `<new-id>` of destination operand `specs/B.mdx#`, a well-formed 12.0 split with zero id segments, never the exit-2 generalization of 11.3's `--to` spelling rule — refused-invalid-id, concerning that identity); the ordinary cross-file `<new-id>` collision (refused-id-collision, locating the remaining bearer); a missing target parent and a target parent within the moved subtree (refused-missing-target-parent, concerning the target-parent identity); destination paths in no configured spec group, in a code group as well, or lacking `.mdx`, and the derived-path arm — emission enabled under `markdown.outDir`, the otherwise-valid destination's emit-destination directory component `mdout/new` occupied by a plain file lying under no current source's write path, refused never 14.22 (refused-invalid-destination, concerning the destination path) — each refusal the form-exact 12.7 findings-only report holding exactly one finding per applicable reason with its exact stable code; the `#`-containing and non-UTF-8 destination clauses admit no refusal staging (the dead-letter note): every such operand spelling is an exit-2 usage error first, staged in T6.5-5; plus the valid-workspace precondition as T6.4-6, reporting the workspace's numbered findings alone (SPEC 6.5, 7, 7.3, 5.3, 2.1, 1.4, 1.3, 13.1, 13.2, 13.4, 14.14, 14.19, 14.22, 12.0, 12.7, 14)", + "refusals (exit 1, nothing modified): a move creating a spec import cycle or a dependency cycle (refused-cycle, the dependency arm locating the participating `d` spelling); file form whose destination exists — occupied by a plain file, by a directory, by a symbolic link, and by a broken symbolic link with its target absent, one arm each, the directory arm discriminating a product probing for a file alone and the broken-link arm discriminating a product probing existence through link-following stat (refused-destination-exists, concerning that path); section form whose target path is occupied by anything other than a discovered spec source — a directory; a symbolic link resolving to a discovered spec source (discovery never yields a symlink); and an existing `.mdx` file outside every configured spec group, the latter refusing under refused-destination-exists and refused-invalid-destination together, one finding per applicable reason; section form with a 1.4-invalid `<new-id>` (forbidden name `then`; whitespace-bearing segment; the empty `<new-id>` of destination operand `specs/B.mdx#`, a well-formed 12.0 split with zero id segments, never the exit-2 generalization of 11.3's `--to` spelling rule — refused-invalid-id, concerning that identity); the ordinary cross-file `<new-id>` collision (refused-id-collision, locating the remaining bearer); a missing target parent and a target parent within the moved subtree (refused-missing-target-parent, concerning the target-parent identity); destination paths in no configured spec group, in a code group as well, or lacking `.mdx`, and the derived-path arm — emission enabled under `markdown.outDir`, the otherwise-valid destination's emit-destination directory component `mdout/new` occupied by a plain file lying under no current source's write path, refused never 14.22 (refused-invalid-destination, concerning the destination path); the symbolic-link arms of the same clause — a file-form move to `specs/sub/b.mdx` and a section-form move creating the target file `specs/sub/new.mdx`, `specs/sub` a symbolic link to a real, empty directory, staged with the link targeting a directory inside the workspace root and, on its own workspace, one outside it — each refused-invalid-destination concerning the destination path, never 14.22, nothing written through the link inside or outside the workspace (the link and its target directory byte-identical afterward), and the derived-path arm's sibling staging `mdout/new` as such a link instead of a plain file, refused identically — each refusal the form-exact 12.7 findings-only report holding exactly one finding per applicable reason with its exact stable code; the `#`-containing and non-UTF-8 destination clauses admit no refusal staging (the dead-letter note): every such operand spelling is an exit-2 usage error first, staged in T6.5-5; plus the valid-workspace precondition as T6.4-6, reporting the workspace's numbered findings alone (SPEC 6.5, 7, 7.3, 5.3, 2.1, 1.4, 1.3, 13.1, 13.2, 13.4, 14.14, 14.19, 14.22, 12.0, 12.7, 14)", run: async (product) => { await withWorkspace( MOVE_REFUSAL_CONFIG, @@ -2595,6 +2749,44 @@ const T6_5_4 = defineProductTest({ }, ); + // The outside-root staging of the link-component arms + // (V4_LINK_COMPONENT's note): `specs/sub` a symbolic link to a real, + // empty directory beside the workspace root. Each refusal is compared + // over the link's target directory as well — a product writing the + // moved file, or its regenerated derived files, through the link lands + // them outside the root, where the whole-root compare cannot see them. + await withWorkspace( + MOVE_REFUSAL_CONFIG, + MOVE_LINK_OUTSIDE_FILES, + async (workspace) => { + const outside = await stageMoveLinkOutsideComponent(workspace); + await buildOk( + product, + workspace, + "T6.5-4 outside-root link staging `build` — the link specs/sub " + + "is never discovered nor traversed and lies under no current " + + "source's write path (SPEC 7, 13.4), so the workspace passes " + + "`build`'s validations and each refusal below is the move's own", + ); + for (const { argv, expected, reason } of MOVE_LINK_OUTSIDE_CASES) { + await assertLeavesUnchanged( + outside, + () => + expectRefusalModifiesNothing( + product, + workspace, + argv, + expected, + `T6.5-4 (${reason})`, + ), + `T6.5-4 (${reason}): the link's target directory outside the ` + + `workspace root stays byte-identical — nothing is written ` + + `through the link (SPEC 6.5, 13.4)`, + ); + } + }, + ); + // The derived-path arm of refused-invalid-destination, on its own // workspace (V4_OUTDIR_CONFIG's note): the destination `new/b.mdx` is // otherwise valid and its own directory components unobstructed (`new/` @@ -2624,6 +2816,33 @@ const T6_5_4 = defineProductTest({ }, ); + // The derived-path arm's symbolic-link sibling (V4_LINK_COMPONENT's + // note): `mdout/new` a symbolic link to the empty directory `linked/` + // instead of a plain file — refused identically, the link and its + // target byte-identical (both inside the root: the whole-root compare). + await withWorkspace( + MOVE_DERIVED_PATH_CONFIG, + MOVE_DERIVED_LINK_FILES, + async (workspace) => { + await stageMoveDerivedLinkComponent(workspace); + await buildOk( + product, + workspace, + "T6.5-4 derived-path link sibling `build` — the link mdout/new " + + "lies under no current source's write path (SPEC 13.4), so the " + + "workspace passes `build`'s validations and the refusal below " + + "is the move's own", + ); + await expectRefusalModifiesNothing( + product, + workspace, + MOVE_DERIVED_LINK_CASE.argv, + MOVE_DERIVED_LINK_CASE.expected, + `T6.5-4 (${MOVE_DERIVED_LINK_CASE.reason})`, + ); + }, + ); + // Valid-workspace precondition, as T6.4-6: with a pre-existing // validation error elsewhere, the move's own arguments being valid, the // move refuses (exit 1) before modifying anything. The invalid-workspace diff --git a/test/suite/registry/section-6.6.ts b/test/suite/registry/section-6.6.ts index 40b5f39..90a6d6e 100644 --- a/test/suite/registry/section-6.6.ts +++ b/test/suite/registry/section-6.6.ts @@ -237,9 +237,13 @@ import { } from "./section-6.4.js"; import type { RefusalExpectation } from "./section-6.5.js"; import { + MOVE_DERIVED_LINK_CASE, + MOVE_DERIVED_LINK_FILES, MOVE_DERIVED_PATH_CASE, MOVE_DERIVED_PATH_CONFIG, MOVE_DERIVED_PATH_FILES, + MOVE_LINK_OUTSIDE_CASES, + MOVE_LINK_OUTSIDE_FILES, MOVE_MIXED_SYNOPSIS_CASES, MOVE_NON_UTF8_ARGV, MOVE_PRECONDITION_BREAK_FILE, @@ -256,6 +260,8 @@ import { MOVE_USAGE_CONFIG, MOVE_USAGE_ORDERING_FILES, MOVE_WRONG_KIND_CASES, + stageMoveDerivedLinkComponent, + stageMoveLinkOutsideComponent, stageMoveRefusalOccupants, } from "./section-6.5.js"; import { @@ -844,6 +850,39 @@ const T6_6_3 = defineProductTest({ }, ); + // T6.5-4's outside-root link-component arms, staged identically on + // their own workspace: the real operation and the preview alike leave + // the link's target directory outside the root byte-identical. + await withWorkspace( + MOVE_REFUSAL_CONFIG, + MOVE_LINK_OUTSIDE_FILES, + async (workspace) => { + const outside = await stageMoveLinkOutsideComponent(workspace); + await buildOk( + product, + workspace, + "T6.6-3 outside-root link staging `build` (the link staged before " + + "it lies under no current source's write path; T6.5-4's protocol)", + ); + for (const { argv, expected, reason } of MOVE_LINK_OUTSIDE_CASES) { + await assertLeavesUnchanged( + outside, + () => + expectRefusedPreviewEquivalence( + product, + workspace, + argv, + expected, + `T6.6-3 move refusal (${reason})`, + ), + `T6.6-3 move refusal (${reason}): the link's target directory ` + + `outside the workspace root stays byte-identical across the ` + + `real operation and its preview (SPEC 6.5, 6.6, 13.4)`, + ); + } + }, + ); + // T6.5-4's derived-path arm, staged identically on its own workspace. await withWorkspace( MOVE_DERIVED_PATH_CONFIG, @@ -866,6 +905,30 @@ const T6_6_3 = defineProductTest({ }, ); + // T6.5-4's derived-path link sibling, staged identically on its own + // workspace. + await withWorkspace( + MOVE_DERIVED_PATH_CONFIG, + MOVE_DERIVED_LINK_FILES, + async (workspace) => { + await stageMoveDerivedLinkComponent(workspace); + await buildOk( + product, + workspace, + "T6.6-3 derived-path link-sibling staging `build` — the link " + + "mdout/new lies under no current source's write path (T6.5-4's " + + "sibling arm), so the refusal previewed below is the move's own", + ); + await expectRefusedPreviewEquivalence( + product, + workspace, + MOVE_DERIVED_LINK_CASE.argv, + MOVE_DERIVED_LINK_CASE.expected, + `T6.6-3 move refusal (${MOVE_DERIVED_LINK_CASE.reason})`, + ); + }, + ); + // T6.5-4's valid-workspace precondition arm, staged identically: the // invalid-workspace refusal previews as it refuses — the workspace's // numbered findings alone (SPEC 6.6, 6.4, 6.5, 14). From 19f88b515c6a35469f9de8941b03ed509cc7b8ae Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 09:58:56 +0000 Subject: [PATCH 211/226] =?UTF-8?q?sdg(phase-9):=20T6.5-5=20present-but-un?= =?UTF-8?q?discovered=20origin=20`<file>`=20spelling=20in=20both=20forms?= =?UTF-8?q?=20=E2=80=94=20a=20valid=20`docs/Stray.mdx`=20on=20disk,=20hold?= =?UTF-8?q?ing=20a=20section=20spelling=20the=20origin=20ID=20`a`,=20but?= =?UTF-8?q?=20outside=20every=20spec=20group;=20the=20base=20arm=20pins=20?= =?UTF-8?q?its=20absence=20from=20`ids=20--json`'s=20discovered=20set,=20t?= =?UTF-8?q?hen=20`move=20docs/Stray.mdx=20specs/New.mdx`=20and=20`move=20d?= =?UTF-8?q?ocs/Stray.mdx#a=20specs/B.mdx#z`=20exit=202=20with=20the=2012.7?= =?UTF-8?q?=20error=20document=20beside=20the=20absent-on-disk=20rows,=20t?= =?UTF-8?q?he=20whole=20existence=20table=20run=20inside=20a=20whole-root?= =?UTF-8?q?=20modifies-nothing=20compare=20in=20the=20valid-workspace=20an?= =?UTF-8?q?d=20ordering=20arms=20alike=20(a=20product=20probing=20the=20fi?= =?UTF-8?q?lesystem=20for=20the=20operand=20would=20move=20the=20stray=20f?= =?UTF-8?q?ile=20or=20insert=20its=20subtree=20into=20B.mdx);=20the=20rows?= =?UTF-8?q?=20and=20file=20join=20the=20exported=20usage=20tables=20so=20T?= =?UTF-8?q?6.6-3's=20preview=20equivalence=20covers=20them=20unchanged=20(?= =?UTF-8?q?Task=2026)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- AGENTS.md | 2 +- specs/tmp/FIX_PLAN.md | 16 --- test/suite/registry/section-6.5.ts | 151 +++++++++++++++++++++++------ 3 files changed, 120 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f35621e..da98fbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Build, test, and run instructions for this repository (nothing else belongs in t - Typecheck both programs: `npm run typecheck` (`src/tsconfig.json`, then `test/tsconfig.json`; the harness is not typechecked by Vitest at run time). `test/fixtures/` is excluded from the harness typecheck: fixture projects are data compiled or executed at test run time and may contain deliberate type errors (e.g. the S-4 fixture). - Consumer fixture programs are compiled through the harness's TypeScript tooling driver (`test/helpers/tooling.ts`), which resolves `@types/node` from this repository's own `node_modules` — `npm ci` (dev dependencies included) must have run for consumer compilation to work. - Full test suite (TEST-SPEC sections 1–17, certification included; the Linux CI leg): `npm test`. Build the product first — tests invoke the built executable. -- Run a subset of a test project by appending file paths, e.g. `npx vitest run --config test/vitest.config.ts --project suite test/suite/section-1.1-1.2.test.ts` — the way to observe new product-facing tests failing-as-diagnosed against the stub product (Phase 9 red-green). Append `--reporter=verbose` to see each test's duration — the quick check that a many-invocation sweep (e.g. T6.1-1's ~34 CLI runs, ~9 s at steady state) actually drove every arm rather than short-circuiting. +- Run a subset of a test project by appending file paths, e.g. `npx vitest run --config test/vitest.config.ts --project suite test/suite/section-1.1-1.2.test.ts` — the way to observe new product-facing tests failing-as-diagnosed against the stub product (Phase 9 red-green). Append `--reporter=verbose` to see each test's duration — the quick check that a many-invocation sweep (e.g. T6.1-1's ~34 CLI runs, ~9 s at steady state) actually drove every arm rather than short-circuiting. Vitest's `-t <pattern>` name filter narrows further but matches substrings of the whole title, so filtering on a test ID (`-t "T6.5-5"`) also runs every test whose title cites that ID (T6.5-4's title mentions T6.5-5, adding its ~9 s) — read the verbose list to see what actually ran. - Running tests also requires the system `git` executable on PATH: harness fixtures script local git repositories (`test/helpers/workspace.ts`). No git configuration is needed — the builder isolates all ambient git config and identity. - Harness self-tests and certification only (TEST-SPEC 17): `npm run test:self`. Its certification runner prints one `PASS`/`FAIL` line per (test, fixture) pair as it drives each fixture executable; a violator's `FAIL` lines are its expected outcomes, not failures — Vitest's own failures are the `×` lines and the final `Tests` summary, so when piping the output through `grep`, filter on those rather than on `FAIL`. - Certification fixture products (CERTIFICATIONS.md, e.g. `test/fixtures/conf-core/`) are plain Node ESM programs with no build step and no dependencies: the certification runner (and manual debugging) invokes `node test/fixtures/<fixture>/bin.mjs <command> …` with a staged workspace as the working directory. Violator executables sit beside the conformer's entry as `bin-<deviation>.mjs` (e.g. `bin-nolock.mjs`) and run the same way. Every fixture exits 70 — outside SPEC 12.0's exit partition — for a fixture-internal crash (the stack on stderr), and the CONF-DISC fixture also for an invocation outside its certified scope (`xspec: fixture scope error: …` on stderr, e.g. `query nodes` or `query edges --kinds`): such an exit is a fixture-side condition, never a product verdict, so a test hitting it needs a fixture (or staging) fix, not a product diagnosis. `test/fixtures/` is excluded from the harness typecheck; Prettier still formats it. diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 9522f9e..318ac04 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,22 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 26 — T6.5-5: origin `<file>` present on disk but undiscovered (both forms) - -Cites: TEST-SPEC T6.5-5 (an origin `<file>` naming no discovered spec source is -a usage error, exit 2, in both spellings — absent, and present but matched by -no spec group — for the file form and the section form); SPEC 6.5, 12.0. - -Now: `test/suite/registry/section-6.5.ts` ≈ lines 2322–2324 stage only the -absent spelling. - -Do: add the present-but-undiscovered spelling for each form (as Task 21 does -for `rename`): a valid `.mdx` outside every glob; `move docs/stray.mdx …` and -`move docs/stray.mdx#a …` → exit 2 with the 12.7 error document, nothing -modified. - -Verify: `npx vitest run … test/suite/section-6.5.test.ts`. - ### Task 27 — T6.5-7: single-quoted descendant `id`; code-source counterpart (import removal in `.ts`, byte-composed) Cites: TEST-SPEC T6.5-7 (revised: the moved subtree "spells that descendant's diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index cf39838..07803cd 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -119,13 +119,21 @@ // on stderr (presence, not wording). The existence and kind checks ride // both a valid workspace and the ordering arm's failing one (12.0: // checked before source validation, as T6.4-4): a nonexistent origin -// file or origin ID, and a discovered code source as the origin in each +// file — in each form, both of T6.4-4's spellings: absent on disk, and a +// valid `.mdx` present on disk (holding a section spelling the origin +// ID) but matched by no spec group, its absence from the discovered set +// pinned through `ids --json` on the valid workspace (a file named in an +// argument exists as a member of the discovered set, SPEC 12.0) — or +// origin ID, and a discovered code source as the origin in each // form — both forms' origin operands name discovered spec sources // (SPEC 6.5), so a code-source origin is a wrong-kind operand, judged // like existence before any content question — the wrong-kind arms on // the valid workspace inside whole-root modifies-nothing snapshot // compares (a product accepting a code origin would relocate the file or -// act on its named unit). The masking arm asserts exit 1 with exactly +// act on its named unit), and the existence table inside one such +// compare in the valid-workspace and ordering arms alike (a product +// probing the filesystem for the stray origin would move it). The +// masking arm asserts exit 1 with exactly // one 14.20 finding naming the unparseable origin file, and origin-ID // existence is parse-local over spelled identities, as T6.4-4 // (SPEC 6.5, 6.4, 11.2): an origin ID two sections both spell, or one @@ -181,6 +189,7 @@ import { decodeAppliedMappingReport, decodeEdgesReport, decodeFindingsReport, + decodeIdsReport, decodeNodeRowsReport, decodePreviewReport, renderPathValue, @@ -2920,6 +2929,30 @@ const U5_BROKEN_SOURCE = [ const U5_CODE = "src/app.ts"; const U5_CODE_SOURCE = "export function noop(): void {}\n"; +// The second nonexistent-`<file>` spelling (TEST-SPEC T6.5-5: "both of +// T6.4-4's spellings"): a valid `.mdx` present on disk, holding a section +// spelling the origin ID `a`, but outside every configured spec group +// (`specs/**/*.mdx`, SPEC 7) — both forms' origin operands name discovered +// spec sources (SPEC 6.5), and a file named in an argument exists as a +// member of the discovered set (SPEC 12.0), so this operand is as +// nonexistent as an absent path in either form. A product probing the +// filesystem for the operand finds the file (file form) and the origin ID +// it spells (section form) and proceeds — moving the stray file to +// `specs/New.mdx`, or inserting its `a` subtree into `specs/B.mdx` as `z` — +// instead of exiting 2, so the existence table runs inside whole-root +// modifies-nothing compares. Its `a` beside `specs/A.mdx`'s is no +// duplicate-ID condition even when discovered (SPEC 14 condition 3 is a +// duplicate within a file), so the base arm pins the stray file's absence +// from the discovered set directly, through `ids --json` (SPEC 12.3), as +// T6.4-4 does. +const U5_STRAY = "docs/Stray.mdx"; +const U5_STRAY_SOURCE = [ + '<S id="a">', + "Stray text outside every spec group.", + "</S>", + "", +].join("\n"); + // Parse-local existence fixtures, mirroring T6.4-4 (SPEC 6.5, 6.4, 11.2). // Two sections both spelling the same ID: every bearer's node identity is // undefined (11.2, duplicate spellings), yet each spells `dup`, so the @@ -2989,11 +3022,24 @@ export const MOVE_USAGE_CASES: readonly (readonly [ ])[] = [ [ ["move", "specs/Missing.mdx", "specs/New.mdx"], - "file form, nonexistent origin file", + "file form, nonexistent origin file, absent on disk", + ], + [ + ["move", U5_STRAY, "specs/New.mdx"], + "file form, nonexistent origin file — an .mdx present on disk but " + + "matched by no spec group (a file named in an argument exists as a " + + "member of the discovered set, SPEC 12.0; both forms' origin " + + "operands name discovered spec sources, SPEC 6.5)", ], [ ["move", "specs/Missing.mdx#a", "specs/B.mdx#z"], - "section form, nonexistent origin file", + "section form, nonexistent origin file, absent on disk", + ], + [ + ["move", `${U5_STRAY}#a`, "specs/B.mdx#z"], + "section form, nonexistent origin file — an .mdx present on disk " + + "(holding a section spelling the origin ID) but matched by no spec " + + "group (SPEC 6.5, 12.0)", ], [ ["move", "specs/A.mdx#nope", "specs/B.mdx#z"], @@ -3071,14 +3117,16 @@ export const MOVE_NON_UTF8_ARGV: readonly ArgvValue[] = [ ]; /** The base/ordering staging shared by the usage cases (T6.4-4's mirror): - * valid sources, the discovered code source, and — in the ordering variant — - * the failing Bad.mdx, exported for T6.6-3's preview sweep. */ + * valid sources, the discovered code source, the undiscovered stray `.mdx`, + * and — in the ordering variant — the failing Bad.mdx, exported for + * T6.6-3's preview sweep. */ export const MOVE_USAGE_CONFIG = SPEC_AND_CODE_CONFIG; export const MOVE_USAGE_ORDERING_FILES: Readonly<Record<string, string>> = { [U5_A]: U5_A_SOURCE, [U5_B]: U5_B_SOURCE, [U5_BAD]: U5_BAD_SOURCE, [U5_CODE]: U5_CODE_SOURCE, + [U5_STRAY]: U5_STRAY_SOURCE, }; /** @@ -3100,7 +3148,7 @@ export const MOVE_SOLO_ARGV: readonly string[] = [ const T6_5_5 = defineProductTest({ id: "T6.5-5", title: - "usage errors (exit 2): a nonexistent origin file (either form), a nonexistent origin ID, and a discovered code source as the origin in each form — both forms' origin operands name discovered spec sources, so a code-source origin is a wrong-kind operand, judged like existence before any content question — are usage errors checked before source validation, the same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering, as T6.4-4); an origin ID inside an unparseable origin file is masked: the validation findings are reported and the command exits 1; origin-ID existence is parse-local over spelled identities: an ID two sections both spell, or one whose sole bearer spells it beneath an ancestor spelling no identity, exists — the duplicate-ID or ancestor finding refuses instead (exit 1, never exit 2, nothing modified) — while an ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent, exit 2 even beside that file's findings; operand classification is by spelling alone: the three mixed-synopsis invocations — bare-file origin with pair destination, pair origin with bare-file destination, and a `#`-containing file-form destination classified as a pair — match neither synopsis (exit 2), and a non-UTF-8 destination operand (raw argv bytes, Linux leg) is a usage-error argument value (exit 2) — the latter two the stagings T6.5-4's dead-letter note sets aside — the wrong-kind, mixed-synopsis, dead-letter, and refusal arms each proving nothing modified (SPEC 6.5, 6.4, 11.2, 12.0, 14, 14.20)", + "usage errors (exit 2): a nonexistent origin file in either form — absent on disk, or an `.mdx` present on disk but matched by no spec group (a file named in an argument exists as a member of the discovered set), its absence from the discovered set pinned through `ids --json` — a nonexistent origin ID, and a discovered code source as the origin in each form — both forms' origin operands name discovered spec sources, so a code-source origin is a wrong-kind operand, judged like existence before any content question — are usage errors checked before source validation, the same exit 2 even when the workspace also has unrelated validation errors (12.0 ordering, as T6.4-4); an origin ID inside an unparseable origin file is masked: the validation findings are reported and the command exits 1; origin-ID existence is parse-local over spelled identities: an ID two sections both spell, or one whose sole bearer spells it beneath an ancestor spelling no identity, exists — the duplicate-ID or ancestor finding refuses instead (exit 1, never exit 2, nothing modified) — while an ID whose only would-be bearer spells no identity (its `id` attribute repeated on the tag) is nonexistent, exit 2 even beside that file's findings; operand classification is by spelling alone: the three mixed-synopsis invocations — bare-file origin with pair destination, pair origin with bare-file destination, and a `#`-containing file-form destination classified as a pair — match neither synopsis (exit 2), and a non-UTF-8 destination operand (raw argv bytes, Linux leg) is a usage-error argument value (exit 2) — the latter two the stagings T6.5-4's dead-letter note sets aside — the existence, wrong-kind, mixed-synopsis, dead-letter, and refusal arms each proving nothing modified (SPEC 6.5, 6.4, 11.2, 12.0, 14, 14.20)", run: async (product) => { // --- Base arm: a valid workspace --- await withWorkspace( @@ -3109,18 +3157,49 @@ const T6_5_5 = defineProductTest({ [U5_A]: U5_A_SOURCE, [U5_B]: U5_B_SOURCE, [U5_CODE]: U5_CODE_SOURCE, + [U5_STRAY]: U5_STRAY_SOURCE, }, async (workspace) => { const context = "T6.5-5 valid-workspace arm"; await buildOk(product, workspace, `${context}: \`build\``); - for (const [argv, label] of MOVE_USAGE_CASES) { - await expectMoveUsageError( - product, - workspace, - argv, - `${context}, ${label}`, + // Staging premise: the stray file is outside the discovered set — + // `ids --json` lists the discovered spec sources (SPEC 12.3), and it + // lists `specs/A.mdx` but never `docs/Stray.mdx`. Pinning this makes + // the exit-2 assertions on the stray origin demonstrably a + // discovered-set judgement over a file present on disk, not a + // filesystem miss (as T6.4-4). + const idsLabel = `${context}: \`ids --json\` premise`; + const listed = decodeIdsReport( + await runJson(product, workspace, ["ids", "--json"], idsLabel), + idsLabel, + ).files.map((entry) => entry.file); + if (!listed.includes(U5_A) || listed.includes(U5_STRAY)) { + fail( + `${context}: staging premise — the discovered spec sources must ` + + `include ${U5_A} and exclude the stray ${U5_STRAY} (outside ` + + `every spec group, SPEC 7; a file named in an argument exists ` + + `as a member of the discovered set, SPEC 12.0), but \`ids ` + + `--json\` listed ${JSON.stringify(listed)}`, ); } + // Every usage error modifies nothing (SPEC 12.0): one whole-root + // byte compare around the existence table — derived files, sources, + // and the stray file alike (a product probing the filesystem for the + // stray origin would move it, or insert its subtree into B.mdx). + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, label] of MOVE_USAGE_CASES) { + await expectMoveUsageError( + product, + workspace, + argv, + `${context}, ${label}`, + ); + } + }, + `${context}: the usage errors modify nothing (SPEC 6.5, 12.0)`, + ); // Wrong-kind origins (SPEC 6.5: both forms' origin operands name // discovered spec sources), each inside a whole-root @@ -3215,25 +3294,33 @@ const T6_5_5 = defineProductTest({ `at least one validation finding (SPEC 14)`, ); } - for (const [argv, label] of MOVE_USAGE_CASES) { - await expectMoveUsageError( - product, - workspace, - argv, - `${context}, ${label}, with unrelated validation errors present ` + - `— the existence checks precede source validation (SPEC 12.0)`, - ); - } - for (const [argv, label] of MOVE_WRONG_KIND_CASES) { - await expectMoveUsageError( - product, - workspace, - argv, - `${context}, ${label}, with unrelated validation errors present ` + - `— the wrong-kind operand is judged like existence, before ` + - `source validation (SPEC 6.5, 6.4, 12.0)`, - ); - } + await assertLeavesUnchanged( + workspace.root, + async () => { + for (const [argv, label] of MOVE_USAGE_CASES) { + await expectMoveUsageError( + product, + workspace, + argv, + `${context}, ${label}, with unrelated validation errors ` + + `present — the existence checks precede source ` + + `validation (SPEC 12.0)`, + ); + } + for (const [argv, label] of MOVE_WRONG_KIND_CASES) { + await expectMoveUsageError( + product, + workspace, + argv, + `${context}, ${label}, with unrelated validation errors ` + + `present — the wrong-kind operand is judged like ` + + `existence, before source validation (SPEC 6.5, 6.4, ` + + `12.0)`, + ); + } + }, + `${context}: the usage errors modify nothing (SPEC 6.5, 12.0)`, + ); }, ); From b1e5d5143785623fb78c3b61f7710f469ead2a43 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 10:09:39 +0000 Subject: [PATCH 212/226] =?UTF-8?q?sdg(phase-9):=20T6.5-7=20single-quoted?= =?UTF-8?q?=20descendant=20`id`=20and=20the=20code-source=20counterpart=20?= =?UTF-8?q?=E2=80=94=20the=20moved=20subtree=20spells=20`org.mv.leaf`'s=20?= =?UTF-8?q?`id`=20attribute=20single-quoted,=20re-identified=20to=20`id=3D?= =?UTF-8?q?'mv.leaf'`=20with=20its=20quotes=20kept=20in=20the=20composed?= =?UTF-8?q?=20target=20bytes;=20two=20`.ts`=20files=20of=20a=20code=20grou?= =?UTF-8?q?p,=20each=20importing=20the=20origin,=20target,=20and=20third?= =?UTF-8?q?=20modules=20with=20the=20origin=20binding=20referenced=20only?= =?UTF-8?q?=20by=20markers=20on=20moved=20nodes,=20beside=20`TGT.hub`=20an?= =?UTF-8?q?d=20`Keep.keep`=20markers=20=E2=80=94=20the=20origin=20declarat?= =?UTF-8?q?ion=20alone=20on=20its=20line=20(between=20the=20other=20two)?= =?UTF-8?q?=20in=20`src/own-line.ts`=20and=20following=20the=20third=20mod?= =?UTF-8?q?ule's=20declaration=20on=20a=20shared=20line=20in=20`src/shared?= =?UTF-8?q?-line.ts`,=20`;`-less=20as=20in=20the=20MDX=20fixture=20so=20it?= =?UTF-8?q?s=20extent=20reaches=20no=20arguable=20terminator=20=E2=80=94?= =?UTF-8?q?=20asserted=20byte-equal=20after=20the=20move=20to=20expectatio?= =?UTF-8?q?ns=20composed=20from=206.4/6.5=20and=203:=20the=20own-line=20de?= =?UTF-8?q?claration's=20line=20dropped=20with=20its=20terminator,=20the?= =?UTF-8?q?=20shared-line=20declaration's=20own=20characters=20alone=20del?= =?UTF-8?q?eted=20(the=20retained=20import,=20its=20`;`,=20and=20the=20sep?= =?UTF-8?q?arating=20U+0020=20kept),=20the=20moved=20markers=20re-rooted?= =?UTF-8?q?=20at=20the=20existing=20target=20binding=20(`TGT.mv`,=20`TGT.m?= =?UTF-8?q?v.leaf`,=20dot=20access=20kept,=20no=20import=20added),=20every?= =?UTF-8?q?=20other=20byte=20untouched;=20premise=20`build`=20and=20post-m?= =?UTF-8?q?ove=20`check`=20cover=20the=20code=20sources;=20traceability=20?= =?UTF-8?q?adds=206.4,=202.7,=204.5.=20Against=20the=20built=20product=20t?= =?UTF-8?q?he=20MDX=20arm=20passes=20byte-for-byte=20and=20the=20markers?= =?UTF-8?q?=20are=20rewritten=20as=20composed,=20but=20the=20unreferenced?= =?UTF-8?q?=20origin-module=20import=20is=20left=20in=20both=20`.ts`=20var?= =?UTF-8?q?iants=20=E2=80=94=20a=20diagnosed=20product=20failure=20of=206.?= =?UTF-8?q?5's=20import-removal=20rule=20for=20code=20sources,=20`check`?= =?UTF-8?q?=20clean=20(Task=2027)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 33 ------ test/suite/registry/section-6.5.ts | 178 +++++++++++++++++++++++++--- test/suite/registry/traceability.ts | 2 +- 3 files changed, 162 insertions(+), 51 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 318ac04..dc5bed1 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,39 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 27 — T6.5-7: single-quoted descendant `id`; code-source counterpart (import removal in `.ts`, byte-composed) - -Cites: TEST-SPEC T6.5-7 (revised: the moved subtree "spells that descendant's -`id` attribute single-quoted (2.7)" and after the move "the local reference and -the single-quoted `id` attribute are each re-identified by prefix replacement -with their single-quote spellings preserved"; and "The code-source counterpart, -fully composed (no import added, so no latitude): a `.ts` file importing the -origin module, the target module, and a retained third module — the third -module's declaration and the origin's sharing one line in a second variant, -the origin's following it — whose only references through the origin binding -are markers on nodes of the moved subtree, beside a marker through the target -binding and one through the third; after the section move, the origin-module -import … is removed with 6.5's exact extent (own-line: the line dropped with -its terminator; shared-line: the declaration's own characters alone deleted, -the retained declaration kept byte-for-byte), the moved markers are rewritten -through the existing target binding, and the file is asserted byte-equal to -expected bytes composed from the rules of 6.4/6.5 and 3"); SPEC 6.4, 6.5, 2.7, -4.5. - -Now: `test/suite/registry/section-6.5.ts` ≈ lines 2723–2910 (T6.5-7) stage the -MDX origin/target with double-quoted `id`s and no code file. - -Do: (a) spell the moved descendant's `id` attribute single-quoted in the -fixture and compose the expected target bytes with the re-identified value -still single-quoted; (b) add the code-source arm in two variants (own-line -origin import; origin import following the third-module import on a shared -line) with markers through all three bindings; after the move assert the -`.ts` file byte-equal to the composed expectation (origin import removed with -exact extent, moved markers rewritten through the existing target binding, -everything else byte-identical); `check` clean. - -Verify: `npx vitest run … test/suite/section-6.5.test.ts`. - ### Task 28 — T6.5-8 Added-import insertion discipline (new test: TS, MDX-origin, MDX-target arms) Cites: TEST-SPEC T6.5-8 (full text in §6.5): an added import is a line of its diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index 07803cd..265745f 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -165,9 +165,19 @@ // imported → local, the one rewrite direction free of implementation // latitude (SPEC 6.5: identifier choice and insertion offset attach to // added imports alone), so the two files' post-move bytes are the rules' -// unique composition. A premise `build` pins the staging valid (the -// shared-line two-declaration import block parses, SPEC 2.1) and a -// post-move `check` guards the composition's soundness: if the product's +// unique composition; the moved subtree spells a descendant's `id` +// attribute single-quoted (SPEC 2.7), re-identified with its quotes kept +// (SPEC 6.4: minimal in-place edits bind the `id`-attribute rewrite as +// they bind references, T6.4-2). The code-source counterpart, two `.ts` +// files in the same workspace, each importing the origin, target, and +// third modules with the origin binding referenced only by markers on +// moved nodes (SPEC 4.5): the origin declaration alone on its line in one +// file and following the third module's on a shared line in the other, +// removed with 6.5's exact extent as in MDX, the moved markers re-rooted +// at the existing target binding (no import added), each file byte-equal +// to its composed expectation. A premise `build` pins the staging valid +// (the shared-line two-declaration import blocks parse, SPEC 2.1, 4) and +// a post-move `check` guards the composition's soundness: if the product's // bytes equal the expected bytes yet something failed to resolve, the // staging itself was defective and must fail loud. // - T6.5-6's unstageable clauses are documented at the test, per TEST-SPEC: @@ -3597,10 +3607,12 @@ const T6_5_6 = defineProductTest({ // line. Every reference through the two bindings — the `d` chain // `d={TWO.hub}` and the embedding `{text(TB.aux)}` — lies inside the moved // subtree `org.mv`, which also holds the single-quoted local string -// reference `d={'org.mv.leaf'}` to a moved descendant; no reference to a -// moved node lies outside the subtree, and no moved reference targets a -// node remaining in the origin — so the rewrite adds no import anywhere, -// the one direction free of implementation latitude (SPEC 6.5). +// reference `d={'org.mv.leaf'}` to a moved descendant and spells that +// descendant's `id` attribute single-quoted (`id='org.mv.leaf'`, SPEC 2.7: +// single- or double-quoted alike); no reference to a moved node lies +// outside the subtree, and no moved reference targets a node remaining in +// the origin — so the rewrite adds no import anywhere, the one direction +// free of implementation latitude (SPEC 6.5). const B7_ORIGIN = "specs/Origin.mdx"; const B7_TARGET = "specs/Target.mdx"; const B7_KEEP = "specs/Keep.mdx"; @@ -3617,7 +3629,7 @@ const B7_ORIGIN_BEFORE = [ "", "{text(TB.aux)}", "", - '<S id="org.mv.leaf">', + "<S id='org.mv.leaf'>", "Moved leaf text.", "</S>", "", @@ -3694,7 +3706,12 @@ const B7_ORIGIN_AFTER = [ // reference single-quoted fails here. // - The local reference stays local, re-identified by prefix replacement // with its single-quote spelling preserved: `d={'mv.leaf'}` (SPEC 6.4: -// minimal in-place edits preserve quote style). +// minimal in-place edits preserve quote style); the descendant's +// single-quoted `id` attribute is re-identified the same way, its quotes +// kept: `id='mv.leaf'` (SPEC 6.4 binds the `id`-attribute rewrite as it +// binds references — the double-quoted fallback applies only where a form +// cannot be kept, and `mv.leaf` holds no quote character). A product +// re-emitting a rewritten `id` attribute double-quoted fails here. const B7_TARGET_AFTER = [ '<S id="hub">', "Hub text.", @@ -3708,7 +3725,7 @@ const B7_TARGET_AFTER = [ "", '{text("aux")}', "", - '<S id="mv.leaf">', + "<S id='mv.leaf'>", "Moved leaf text.", "</S>", "", @@ -3719,6 +3736,99 @@ const B7_TARGET_AFTER = [ "", ].join("\n"); +// The code-source counterpart (TEST-SPEC T6.5-7), fully composed — no +// import is added, so no latitude: two `.ts` files of the configured code +// group, each importing the origin module, the target module, and the +// retained third module (SPEC 4: default bindings, `.xspec` specifiers +// resolved as 2.1), whose only references through the origin binding +// (`ORG`) are markers (SPEC 4.5) on nodes of the moved subtree — the moved +// root `org.mv` and its descendant `org.mv.leaf` — beside a marker through +// the target binding (`TGT.hub`) and one through the third (`Keep.keep`). +// The variants differ in the origin declaration's line alone: alone on its +// line (between the other two) in `src/own-line.ts`; following the third +// module's declaration on a shared line in `src/shared-line.ts`. As in the +// MDX fixture, the removed declaration carries no `;` — the declaration +// spans `import ORG from "../specs/Origin.xspec"` exactly, so its extent +// reaches no terminator character whose membership could be argued — while +// the retained declarations keep theirs (TypeScript's grammar admits both, +// a line break ending the `;`-less declaration). +const B7_TS_OWN = "src/own-line.ts"; +const B7_TS_SHARED = "src/shared-line.ts"; + +const B7_TS_OWN_BEFORE = [ + 'import TGT from "../specs/Target.xspec";', + 'import ORG from "../specs/Origin.xspec"', + 'import Keep from "../specs/Keep.xspec";', + "", + "export function ownLine(): void {", + " ORG.org.mv;", + " ORG.org.mv.leaf; // moved descendant", + " TGT.hub;", + " Keep.keep;", + "}", + "", +].join("\n"); + +const B7_TS_SHARED_BEFORE = [ + 'import Keep from "../specs/Keep.xspec"; import ORG from "../specs/Origin.xspec"', + 'import TGT from "../specs/Target.xspec";', + "", + "export function sharedLine(): void {", + " ORG.org.mv;", + " ORG.org.mv.leaf; // moved descendant", + " TGT.hub;", + " Keep.keep;", + "}", + "", +].join("\n"); + +// Expected code bytes, composed from the rules of SPEC 6.4/6.5 and 3 — not +// from any product output: +// - The moved markers are rewritten through the EXISTING target binding +// (SPEC 6.5: an import is added only where the file lacks the binding): +// re-rooted at `TGT` with the prefix `org.mv` replaced by `mv`, dot access +// kept (SPEC 6.4: minimal in-place edits preserve access form; every +// segment is an identifier), so `ORG.org.mv` → `TGT.mv` and +// `ORG.org.mv.leaf` → `TGT.mv.leaf`, each marker's `;`, indentation, and +// trailing comment untouched. +// - The origin binding is left without references, so its declaration is +// removed with 6.5's exact extent: in the own-line variant, the line left +// empty purely by the deletion is dropped with its terminator (SPEC 6.5, +// 3); in the shared-line variant, the declaration's own characters ALONE +// are deleted, so the retained `Keep` import, its `;`, AND the separating +// U+0020 survive byte-for-byte — the kept line ends +// `"../specs/Keep.xspec"; ` with a trailing space before its terminator +// (an explicit concatenation below, so the byte is loud). +// - The `TGT.hub` and `Keep.keep` markers, the retained imports, and every +// other byte are unchanged: a product reprinting the code file on removal +// — re-indenting, dropping the comment, or normalizing `;` or whitespace +// — fails here while passing every resolution-only assertion. +const B7_TS_OWN_AFTER = [ + 'import TGT from "../specs/Target.xspec";', + 'import Keep from "../specs/Keep.xspec";', + "", + "export function ownLine(): void {", + " TGT.mv;", + " TGT.mv.leaf; // moved descendant", + " TGT.hub;", + " Keep.keep;", + "}", + "", +].join("\n"); + +const B7_TS_SHARED_AFTER = [ + 'import Keep from "../specs/Keep.xspec";' + " ", + 'import TGT from "../specs/Target.xspec";', + "", + "export function sharedLine(): void {", + " TGT.mv;", + " TGT.mv.leaf; // moved descendant", + " TGT.hub;", + " Keep.keep;", + "}", + "", +].join("\n"); + const B7_MOVE_ARGV = [ "move", "specs/Origin.mdx#org.mv", @@ -3728,19 +3838,22 @@ const B7_MOVE_ARGV = [ const T6_5_7 = defineProductTest({ id: "T6.5-7", title: - "operation-side rewrite bytes for the real move: import-edit extents and reference-conversion spellings byte-asserted against independently composed expected files, staged so no import is added (the one rewrite direction free of implementation latitude) — the own-line target-module import's line dropped with its terminator, the shared-line declaration's own characters alone deleted with the retained third-module import kept byte-for-byte on its kept line, the moved references converted to local form as double-quoted string literals, and the single-quoted local reference re-identified by prefix replacement with its quote spelling preserved (SPEC 6.5, 6.4, 3, 2.1; H-4, normalizing nothing)", + "operation-side rewrite bytes for the real move: import-edit extents and reference-conversion spellings byte-asserted against independently composed expected files, staged so no import is added (the one rewrite direction free of implementation latitude) — the own-line target-module import's line dropped with its terminator, the shared-line declaration's own characters alone deleted with the retained third-module import kept byte-for-byte on its kept line, the moved references converted to local form as double-quoted string literals, the single-quoted local reference and the single-quoted descendant `id` attribute each re-identified by prefix replacement with their quote spellings preserved; and the code-source counterpart — two `.ts` files whose origin-module import, its binding referenced only by markers on moved nodes, is removed with the same exact extent (own-line and shared-line variants) while the moved markers are rewritten through the existing target binding, each file byte-equal to its composed expectation (SPEC 6.5, 6.4, 3, 2.1, 2.7, 4.5; H-4, normalizing nothing)", run: async (product) => { await withWorkspace( - SPECS_ONLY_CONFIG, + SPEC_AND_CODE_CONFIG, { [B7_ORIGIN]: B7_ORIGIN_BEFORE, [B7_TARGET]: B7_TARGET_BEFORE, [B7_KEEP]: B7_KEEP_SOURCE, + [B7_TS_OWN]: B7_TS_OWN_BEFORE, + [B7_TS_SHARED]: B7_TS_SHARED_BEFORE, }, async (workspace) => { - // Premise: the staging is valid — most acutely, the shared line's - // two import declarations parse as two bindings (SPEC 2.1), so a - // later failure is the move's, not the staging's. + // Premise: the staging is valid — most acutely, the shared lines' + // two import declarations parse as two bindings in MDX (SPEC 2.1) + // and in TypeScript (SPEC 4) alike, and every marker resolves (SPEC + // 4.5) — so a later failure is the move's, not the staging's. await buildOk(product, workspace, "T6.5-7 `build` over the staging"); await expectExit( @@ -3782,6 +3895,36 @@ const T6_5_7 = defineProductTest({ "changes no bytes (SPEC 6.5)", ); + // The code-source counterpart: the import-removal rule binds code + // sources as it binds MDX, and the moved markers are rewritten + // through the binding the file already has. + await assertFileBytes( + workspace.path(B7_TS_OWN), + B7_TS_OWN_AFTER, + "T6.5-7: the own-line code variant after the move — the " + + "origin-module import, its binding left without references, " + + "is removed with 6.5's exact extent (its line dropped with its " + + "terminator), the moved markers are rewritten through the " + + "existing target binding (`TGT.mv`, `TGT.mv.leaf`; no import " + + "added), and every other byte — the retained imports, the " + + "`TGT.hub` and `Keep.keep` markers, indentation, `;`, and the " + + "trailing comment — is unchanged (SPEC 6.5, 6.4, 4.5, 3; H-4, " + + "normalizing nothing: a product reprinting the code file on " + + "removal fails here)", + ); + await assertFileBytes( + workspace.path(B7_TS_SHARED), + B7_TS_SHARED_AFTER, + "T6.5-7: the shared-line code variant after the move — the " + + "origin-module declaration's own characters alone are deleted " + + "from the line it shares with the retained third-module " + + "import, which is kept byte-for-byte (its `;` and the " + + "separating space included) on its kept line, the moved " + + "markers are rewritten through the existing target binding, " + + "and every other byte is unchanged (SPEC 6.5, 6.4, 4.5, 3; " + + "H-4, normalizing nothing)", + ); + // Soundness guard on the composed expectation itself: everything // resolves after the move — if the product's bytes matched the // expected bytes yet a reference or import failed to resolve, the @@ -3792,8 +3935,9 @@ const T6_5_7 = defineProductTest({ workspace, ["check"], 0, - "T6.5-7 `check` immediately after the move — every converted and " + - "re-identified reference resolves and no staleness remains " + + "T6.5-7 `check` immediately after the move — every converted, " + + "re-identified, and re-rooted reference (MDX references and " + + "TS markers alike) resolves and no staleness remains " + "(SPEC 6.5, 12.2, 14.10)", ); }, diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 20c3290..ecb1066 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -215,7 +215,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.5-4": ["6.5", "14"], "T6.5-5": ["6.5", "12.0"], "T6.5-6": ["6.5", "14"], - "T6.5-7": ["6.5"], + "T6.5-7": ["6.5", "6.4", "2.7", "4.5"], "T6.6-2": ["6.6"], "T6.6-3": ["6.6", "14"], "T6.6-4": ["6.6"], From 068a3259fb37539d944ec02243f61b34106009d6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 10:21:36 +0000 Subject: [PATCH 213/226] =?UTF-8?q?sdg(phase-9):=20T6.5-8=20added-import?= =?UTF-8?q?=20insertion=20discipline=20=E2=80=94=20three=20section-move=20?= =?UTF-8?q?arms,=20each=20moving=20`org.mv`=20to=20the=20top-level=20`mv`?= =?UTF-8?q?=20of=20`specs/Target.mdx`,=20whose=20receiving=20file's=20post?= =?UTF-8?q?-move=20bytes=20are=20composed=20from=20the=20rules=20of=206.4/?= =?UTF-8?q?6.5=20and=203=20up=20to=20the=20fresh=20identifier=20(read=20of?= =?UTF-8?q?f=20the=20one=20rewritten=20reference=20in=206.4's=20pinned=20s?= =?UTF-8?q?pelling)=20and=20the=20insertion=20offset,=20the=20single=20ins?= =?UTF-8?q?erted=20run=20then=20isolated=20by=20diff=20through=20the=20sha?= =?UTF-8?q?red=20`assertAddedImportInsertion`=20helper=20and=20read=20as?= =?UTF-8?q?=20exactly=20the=20declaration=20plus=20U+000A=20at=20a=20line-?= =?UTF-8?q?start=20offset,=20or=20U+000A,=20declaration,=20U+000A=20elsewh?= =?UTF-8?q?ere,=20one=202.1-form=20import=20of=20the=20needed=20module=20b?= =?UTF-8?q?inding=20that=20identifier:=20the=20TS=20arm=20(`src/app.ts`=20?= =?UTF-8?q?importing=20the=20origin=20module=20with=20module-scope=20marke?= =?UTF-8?q?rs=20on=20one=20moved=20and=20one=20unmoved=20node,=20gaining?= =?UTF-8?q?=20a=20target-module=20binding=20while=20the=20origin=20import?= =?UTF-8?q?=20stays),=20the=20MDX=20origin=20arm=20(a=20retained=20`Keep`?= =?UTF-8?q?=20import=20beside=20a=20local=20string=20reference=20to=20a=20?= =?UTF-8?q?moved=20descendant,=20converted=20to=20`<fresh>.mv.leaf`=20so?= =?UTF-8?q?=20the=20origin=20itself=20gains=20the=20target=20module's=20im?= =?UTF-8?q?port),=20and=20the=20MDX=20target=20arm=20(the=20moved=20subtre?= =?UTF-8?q?e's=20local=20reference=20to=20`org.base-line`,=20an=20origin?= =?UTF-8?q?=20node=20with=20a=20non-identifier=20segment,=20converted=20to?= =?UTF-8?q?=20`<fresh>.org["base-line"]`=20under=20an=20origin-module=20im?= =?UTF-8?q?port=20added=20to=20the=20existing=20target);=20the=20fresh=20r?= =?UTF-8?q?oot=20may=20not=20be=20the=20file's=20retained=20binding=20nor,?= =?UTF-8?q?=20in=20MDX,=20`S`/`Spec`/`text`;=20the=20fully=20composed=20or?= =?UTF-8?q?igin,=20target,=20and=20bystander=20files=20are=20asserted=20by?= =?UTF-8?q?te-equal,=20the=20arm's=20complete=20edge=20set=20exact,=20and?= =?UTF-8?q?=20`check`=20clean=20as=20soundness=20guards;=20traceability=20?= =?UTF-8?q?adds=206.4,=202.1,=203.=20Against=20the=20built=20product=20all?= =?UTF-8?q?=20three=20arms=20pass=20(own-line=20insertions=20after=20the?= =?UTF-8?q?=20existing=20import=20in=20each=20file),=20`npm=20run=20test:s?= =?UTF-8?q?elf`=20342/342=20(Task=2028)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 33 -- test/suite/registry/section-6.5.ts | 567 ++++++++++++++++++++++++++++ test/suite/registry/traceability.ts | 1 + 3 files changed, 568 insertions(+), 33 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index dc5bed1..d42bd36 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,39 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 28 — T6.5-8 Added-import insertion discipline (new test: TS, MDX-origin, MDX-target arms) - -Cites: TEST-SPEC T6.5-8 (full text in §6.5): an added import is a line of its -own — declaration + U+000A, preceded by U+000A when the insertion offset is not -at a line start — with identifier and offset left free; three section-move -arms: TS (code file imports the origin, references one moved and one unmoved -node → gains a target-module import, origin import stays), MDX origin (origin -holds a retained third-module import and a local string reference to a moved -descendant → origin gains the target module's import, the reference converting -to imported form in 6.4's pinned spellings), MDX target (moved subtree holds a -local `d` reference to an unmoved origin node with a non-identifier segment → -the target file gains the origin module's import; dot access / double-quoted -computed access; origin loses the section and gains no import); in each arm -the harness isolates the single added byte run by diff against the composed -bytes and asserts exactly `decl + \n` at a line-start offset or `\n + decl + -\n` otherwise, the declaration one import of the needed module's specifier -binding one fresh identifier (value unpinned) that the rewritten references -use, no other byte inserted. SPEC 6.5, 6.4, 2.1, 3. - -Now: not implemented; no registry entry, no H-7 map entry. - -Do: in `test/suite/registry/section-6.5.ts` add `T6.5-8` with the three arms. -Write (or reuse from Task 24) a helper in `test/helpers/` that, given the -composed expected bytes with two unknowns and the actual post-move bytes, -(1) recovers the fresh identifier from the rewritten references, (2) locates -the single inserted run by diff, and (3) checks the `\n` discipline by the -offset's line position and that the run's declaration is a 2.1-form import of -the expected module binding that identifier; everything else byte-identical. -Register the ID and add `"T6.5-8": ["6.5"]` to `traceability.ts`. - -Verify: `npx vitest run … test/suite/section-6.5.test.ts` (pass or diagnosed -product failure, never a harness error); `npm run test:self` green (S-1, S-7). - ### Task 29 — T6.5-9 Fresh identifiers in code (new test: pre-empted local bindings, compile-clean after the move) Cites: TEST-SPEC T6.5-9 (full text in §6.5): T6.5-8's TS arm re-staged with a diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index 265745f..852445f 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -3945,6 +3945,572 @@ const T6_5_7 = defineProductTest({ }, }); +// --------------------------------------------------------------------------- + +// T6.5-8 Added-import insertion discipline (TEST-SPEC T6.5-8): the +// addition-side byte contract of SPEC 6.5 — an added import is inserted as +// a line of its own, the declaration's characters followed by U+000A, +// preceded by one when the insertion point is not at the start of a line — +// asserted with the identifier choice and the insertion offset left free +// (6.5's latitude). Each arm's receiving file has its expected post-move +// bytes composed from the rules of 6.4/6.5 and 3 up to exactly those two +// unknowns: the fresh identifier is read off the rewritten references (the +// one place 6.4's pinned spellings make it observable), and +// `assertAddedImportInsertion` isolates the single inserted run by diff +// against the composed bytes and reads it under the line discipline at +// whatever offset the product made observable. Three section-move arms, +// each moving `org.mv` out of `specs/Origin.mdx` to the top-level `mv` of +// `specs/Target.mdx`: +// - TS (the grammar-freest case): `src/app.ts` imports the origin module and +// holds markers on one moved node (`ORG.org.mv`) and one unmoved node +// (`ORG.org.stay`), so the rewrite needs a target-module binding the file +// lacks — added — while the origin import keeps its remaining reference +// and stays. +// - MDX origin: the origin file holds a retained third-module import +// (`Keep`, referenced by `org.stay`; grammar-permitted offsets exist +// beside it, and freshness is live against its binding) and, outside the +// moved subtree, a local string reference to a moved descendant +// (`d={"org.mv.leaf"}`), whose conversion to imported form +// (`<fresh>.mv.leaf`, dot access) makes the origin file itself gain the +// target module's import. +// - MDX target (the third conversion direction): the moved subtree holds a +// local string reference to an origin node outside it, `org.base-line`, +// whose second segment is not identifier-valid; the target file — an +// existing discovered source holding a retained `Keep` import but no +// import of the origin module — gains that import, the reference +// converting to imported form through the fresh binding in 6.4's pinned +// spellings (`<fresh>.org["base-line"]`: dot access, then double-quoted +// computed access), the moved text otherwise byte-identical; the origin +// loses the section and gains no import. +// Every file the two unknowns do not touch is asserted byte-equal to its +// composed expectation; the arm's edge set and a post-move `check` guard +// the compositions' soundness (every rewritten reference resolves). +const A8_ORIGIN = "specs/Origin.mdx"; +const A8_TARGET = "specs/Target.mdx"; +const A8_KEEP = "specs/Keep.mdx"; +const A8_APP = "src/app.ts"; +const A8_ORIGIN_MODULE = "specs/Origin.xspec"; +const A8_TARGET_MODULE = "specs/Target.xspec"; + +const A8_MOVE_ARGV = [ + "move", + "specs/Origin.mdx#org.mv", + "specs/Target.mdx#mv", +] as const; + +const A8_KEEP_SOURCE = ['<S id="keep">', "Keep text.", "</S>", ""].join("\n"); + +/** A plain target file: one top-level section, no imports. */ +const A8_PLAIN_TARGET = ['<S id="tgt">', "Target text.", "</S>", ""].join("\n"); + +// TS arm. The origin's moved section is a leaf; `org.stay` keeps the origin +// binding referenced after the move. +const A8_TS_ORIGIN_BEFORE = [ + '<S id="org">', + "Origin holder text.", + "", + '<S id="org.mv">', + "Moved text.", + "</S>", + "", + '<S id="org.stay">', + "Staying text.", + "</S>", + "</S>", + "", +].join("\n"); + +// Composed from SPEC 6.5 and 3: the moved construct's own characters are +// deleted in place; the merged line that deletion leaves holds only the +// closing tag's terminator and is dropped with it; both neighbouring blank +// lines were blank before the deletion and stay (two adjacent blank lines +// remain). No import is gained: nothing left in the origin references a +// moved node. +const A8_TS_ORIGIN_AFTER = [ + '<S id="org">', + "Origin holder text.", + "", + "", + '<S id="org.stay">', + "Staying text.", + "</S>", + "</S>", + "", +].join("\n"); + +// Composed from SPEC 6.5: top-level `mv`, so the moved text — re-identified +// by prefix replacement `org.mv` → `mv` — is inserted at the end of the +// file, followed by U+000A; the existing final line is terminated, so the +// insertion point lies at a line start and no preceding U+000A is added. +const A8_TS_TARGET_AFTER = [ + '<S id="tgt">', + "Target text.", + "</S>", + '<S id="mv">', + "Moved text.", + "</S>", + "", +].join("\n"); + +// The receiving code file: markers at module scope (SPEC 4.5), so the +// `references` edges are attributed to the file itself (4.6). Only `ORG` is +// bound, leaving every plausible fresh identifier free (T6.5-9 stages the +// collisions). +const A8_APP_BEFORE = [ + 'import ORG from "../specs/Origin.xspec";', + "", + "ORG.org.mv;", + "ORG.org.stay;", + "", +].join("\n"); + +// `src/app.ts`'s expected post-move bytes WITHOUT the added import (SPEC +// 6.4/6.5): the moved marker is re-rooted at the fresh binding of the +// target module with dot access (`mv` is identifier-valid), its `;` kept; +// the unmoved marker and the origin import stay byte-for-byte (the binding +// keeps a reference, so it is not removed). +const A8_APP_BASE = (root: string): string => + [ + 'import ORG from "../specs/Origin.xspec";', + "", + `${root}.mv;`, + "ORG.org.stay;", + "", + ].join("\n"); + +// The rewritten marker: a root not preceded by an identifier character or a +// `.` (so an unrewritten `ORG.org.mv;` never reads as root `org`), `.mv;`. +const A8_APP_REWRITTEN = /(?<![A-Za-z0-9_$.])([A-Za-z_$][A-Za-z0-9_$]*)\.mv;/g; + +// MDX origin arm. +const A8_ORG_ORIGIN_BEFORE = [ + 'import Keep from "./Keep.xspec"', + "", + '<S id="org">', + "Origin holder text.", + "", + '<S id="org.mv">', + "Moved head text.", + "", + '<S id="org.mv.leaf">', + "Moved leaf text.", + "</S>", + "</S>", + "", + '<S id="org.use" d={"org.mv.leaf"}>', + "Uses the moved leaf.", + "</S>", + "", + '<S id="org.stay" d={Keep.keep}>', + "Staying text.", + "</S>", + "</S>", + "", +].join("\n"); + +// The origin's expected post-move bytes WITHOUT the added import (SPEC +// 6.4/6.5, 3): the moved construct deleted in place with its emptied merged +// line dropped (the two blank neighbours stay), the local reference to the +// moved descendant converted to imported form in 6.4's pinned spelling — +// rooted at the fresh binding, dot access for the identifier-valid segments +// — and the retained `Keep` import kept byte-for-byte. +const A8_ORG_ORIGIN_BASE = (root: string): string => + [ + 'import Keep from "./Keep.xspec"', + "", + '<S id="org">', + "Origin holder text.", + "", + "", + `<S id="org.use" d={${root}.mv.leaf}>`, + "Uses the moved leaf.", + "</S>", + "", + '<S id="org.stay" d={Keep.keep}>', + "Staying text.", + "</S>", + "</S>", + "", + ].join("\n"); + +// Composed from SPEC 6.5: the moved text, re-identified (`org.mv` → `mv`, +// `org.mv.leaf` → `mv.leaf`), appended at end of file plus U+000A, otherwise +// byte-identical. +const A8_ORG_TARGET_AFTER = [ + '<S id="tgt">', + "Target text.", + "</S>", + '<S id="mv">', + "Moved head text.", + "", + '<S id="mv.leaf">', + "Moved leaf text.", + "</S>", + "</S>", + "", +].join("\n"); + +const A8_ORG_REWRITTEN = + /<S id="org\.use" d=\{([A-Za-z_$][A-Za-z0-9_$]*)\.mv\.leaf\}>/g; + +// MDX target arm. `org.base-line` is a valid ID (SPEC 1.4 forbids `.`, `#`, +// whitespace, and control characters alone) whose second segment is not a +// TypeScript identifier. +const A8_TGT_ORIGIN_BEFORE = [ + '<S id="org">', + "Origin holder text.", + "", + '<S id="org.mv" d={"org.base-line"}>', + "Moved text.", + "</S>", + "", + '<S id="org.base-line">', + "Base line text.", + "</S>", + "</S>", + "", +].join("\n"); + +// Composed from SPEC 6.5 and 3 (as the TS arm's origin): the section gone, +// its merged line dropped, the blank neighbours kept; no import gained. +const A8_TGT_ORIGIN_AFTER = [ + '<S id="org">', + "Origin holder text.", + "", + "", + '<S id="org.base-line">', + "Base line text.", + "</S>", + "</S>", + "", +].join("\n"); + +const A8_TGT_TARGET_BEFORE = [ + 'import Keep from "./Keep.xspec"', + "", + '<S id="tgt" d={Keep.keep}>', + "Target text.", + "</S>", + "", +].join("\n"); + +// The target's expected post-move bytes WITHOUT the added import (SPEC +// 6.4/6.5): the moved text appended at end of file plus U+000A, its `id` +// re-identified, and its local reference to the origin node converted to +// imported form in 6.4's pinned spellings — the fresh root, dot access for +// the identifier-valid `org`, double-quoted computed access for +// `base-line` — otherwise byte-identical; the retained `Keep` import and +// `tgt` kept byte-for-byte. +const A8_TGT_TARGET_BASE = (root: string): string => + [ + 'import Keep from "./Keep.xspec"', + "", + '<S id="tgt" d={Keep.keep}>', + "Target text.", + "</S>", + `<S id="mv" d={${root}.org["base-line"]}>`, + "Moved text.", + "</S>", + "", + ].join("\n"); + +const A8_TGT_REWRITTEN = + /<S id="mv" d=\{([A-Za-z_$][A-Za-z0-9_$]*)\.org\["base-line"\]\}>/g; + +/** Names an added import may not bind in an MDX source (SPEC 2.1, 14.15). */ +const A8_MDX_RESERVED = ["S", "Spec", "text"].map((name) => ({ + name, + why: "a compiler-provided name no import in an xspec source file may bind", +})); + +/** One T6.5-8 arm: a receiving file gaining exactly one import. */ +interface AddedImportArm { + readonly label: string; + readonly config: string; + readonly files: Readonly<Record<string, string>>; + /** Workspace-relative path of the file gaining the import. */ + readonly receiving: string; + /** Matches the one rewritten reference; group 1 is the fresh root. */ + readonly rewritten: RegExp; + /** The rewritten reference's expected spelling, for diagnoses. */ + readonly rewrittenForm: string; + /** The receiving file's composed post-move bytes without the import. */ + readonly base: (root: string) => string; + /** Workspace-relative module path the added import must designate. */ + readonly expectedModule: string; + readonly moduleLabel: string; + /** Identifiers the fresh binding may not be, each with its reason. */ + readonly forbiddenRoots: readonly { name: string; why: string }[]; + /** Files whose post-move bytes are fully composed (no latitude). */ + readonly composed: readonly { + rel: string; + expected: string; + why: string; + }[]; + /** The workspace's complete edge set of this kind after the move. */ + readonly edgeKind: "depends" | "references"; + readonly edges: readonly GraphEdge[]; +} + +/** + * The identifier the receiving file's rewritten reference is rooted at — + * the value-unpinned fresh binding (SPEC 6.5), read off the one place 6.4's + * pinned spelling makes it observable; diagnosed when the reference is not + * spelled as 6.4 pins it (or is rewritten more or less than once). + */ +function addedImportReferenceRoot( + text: string, + arm: AddedImportArm, + context: string, +): string { + const matches = [...text.matchAll(arm.rewritten)]; + const root = matches.length === 1 ? matches[0]?.[1] : undefined; + if (root === undefined) { + fail( + `${context}: ${arm.receiving} must hold exactly one ` + + `${arm.rewrittenForm} — the reference to the moved node rewritten ` + + `through a binding of ${arm.moduleLabel} in 6.4's pinned spelling ` + + `(dot access for identifier-valid segments, double-quoted computed ` + + `access for the others; SPEC 6.5, 6.4); found ` + + `${String(matches.length)} in ${JSON.stringify(text)}`, + ); + } + return root; +} + +/** + * Stage one arm, run the section-form move, and assert the receiving file + * is its composed post-move bytes with exactly one import of the needed + * module added under 6.5's line discipline, binding the fresh identifier + * the rewritten reference uses; the fully composed files byte-equal; the + * edge set and a clean `check` as soundness guards. + */ +async function runAddedImportArm( + product: ProductBinding, + arm: AddedImportArm, +): Promise<void> { + const context = `T6.5-8 ${arm.label} arm`; + await withWorkspace(arm.config, arm.files, async (workspace) => { + // Premise: the staging is valid (every reference and marker resolves), + // so a later failure is the move's, not the staging's. + await buildOk(product, workspace, `${context} \`build\` over the staging`); + await expectExit( + product, + workspace, + [...A8_MOVE_ARGV], + 0, + `${context} \`move specs/Origin.mdx#org.mv specs/Target.mdx#mv\``, + ); + + const text = await readSourceText(workspace, arm.receiving, context); + const root = addedImportReferenceRoot(text, arm, context); + for (const forbidden of arm.forbiddenRoots) { + if (root === forbidden.name) { + fail( + `${context}: the added import binds \`${forbidden.name}\`, ` + + `${forbidden.why} — an added import binds fresh identifiers ` + + `colliding with no binding already in the file (SPEC 6.5, ` + + `2.1, 14.15)`, + ); + } + } + // Composed from the rules of 6.4/6.5 and 3 up to the two unknowns — + // the fresh identifier (now known) and the insertion offset (isolated + // by the helper, which reads the run at every admissible offset). + assertAddedImportInsertion( + { + rel: arm.receiving, + base: Buffer.from(arm.base(root), "utf8"), + actual: await workspace.readBytes(arm.receiving), + importerDir: posixPath.dirname(arm.receiving), + expectedModule: arm.expectedModule, + identifier: root, + }, + `${context}: ${arm.receiving} after the move is its composed ` + + `post-move bytes with exactly one import of ${arm.moduleLabel} ` + + `added as a line of its own — the declaration followed by U+000A ` + + `at a line-start offset, preceded by one as well at any other — ` + + `binding the fresh identifier the rewritten reference uses, no ` + + `other byte inserted (SPEC 6.5, 2.1, 6.4, 3)`, + ); + for (const file of arm.composed) { + await assertFileBytes( + workspace.path(file.rel), + file.expected, + `${context}: ${file.rel} after the move — ${file.why} (SPEC 6.5, ` + + `6.4, 3; H-4, normalizing nothing)`, + ); + } + + // Soundness guards on the compositions: the rewritten reference resolves + // to the moved node's new identity through the added binding, and + // nothing else changed hands. + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, arm.edgeKind, context), + arm.edges, + `${context}: the complete \`${arm.edgeKind}\` edge set after the ` + + `move — the rewritten reference reported under the moved node's ` + + `new identity, every other edge unchanged (SPEC 6.5, 5.2)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context} \`check\` immediately after the move — the added import ` + + `and the rewritten reference resolve, the fresh binding collides ` + + `with nothing (14.15), and no staleness remains (SPEC 6.5, 12.2, ` + + `14.10)`, + ); + }); +} + +const A8_ARMS: readonly AddedImportArm[] = [ + { + label: "TS", + config: SPEC_AND_CODE_CONFIG, + files: { + [A8_ORIGIN]: A8_TS_ORIGIN_BEFORE, + [A8_TARGET]: A8_PLAIN_TARGET, + [A8_APP]: A8_APP_BEFORE, + }, + receiving: A8_APP, + rewritten: A8_APP_REWRITTEN, + rewrittenForm: "marker `<binding>.mv;`", + base: A8_APP_BASE, + expectedModule: A8_TARGET_MODULE, + moduleLabel: "the target module", + forbiddenRoots: [ + { + name: "ORG", + why: "the identifier the file's retained origin import already binds", + }, + ], + composed: [ + { + rel: A8_ORIGIN, + expected: A8_TS_ORIGIN_AFTER, + why: + "the moved section deleted in place with its emptied merged " + + "line dropped, the blank neighbours kept, and no import gained", + }, + { + rel: A8_TARGET, + expected: A8_TS_TARGET_AFTER, + why: + "the re-identified moved text appended at end of file plus " + + "U+000A, otherwise byte-identical", + }, + ], + edgeKind: "references", + edges: [ + { from: A8_APP, to: `${A8_TARGET}#mv`, kind: "references" }, + { from: A8_APP, to: `${A8_ORIGIN}#org.stay`, kind: "references" }, + ], + }, + { + label: "MDX origin", + config: SPECS_ONLY_CONFIG, + files: { + [A8_KEEP]: A8_KEEP_SOURCE, + [A8_ORIGIN]: A8_ORG_ORIGIN_BEFORE, + [A8_TARGET]: A8_PLAIN_TARGET, + }, + receiving: A8_ORIGIN, + rewritten: A8_ORG_REWRITTEN, + rewrittenForm: '`<S id="org.use" d={<binding>.mv.leaf}>`', + base: A8_ORG_ORIGIN_BASE, + expectedModule: A8_TARGET_MODULE, + moduleLabel: "the target module", + forbiddenRoots: [ + { + name: "Keep", + why: "the identifier the file's retained third-module import already binds", + }, + ...A8_MDX_RESERVED, + ], + composed: [ + { + rel: A8_TARGET, + expected: A8_ORG_TARGET_AFTER, + why: + "the re-identified moved text appended at end of file plus " + + "U+000A, otherwise byte-identical", + }, + { + rel: A8_KEEP, + expected: A8_KEEP_SOURCE, + why: "an uninvolved bystander, untouched", + }, + ], + edgeKind: "depends", + edges: [ + { + from: `${A8_ORIGIN}#org.use`, + to: `${A8_TARGET}#mv.leaf`, + kind: "depends", + }, + { from: `${A8_ORIGIN}#org.stay`, to: `${A8_KEEP}#keep`, kind: "depends" }, + ], + }, + { + label: "MDX target", + config: SPECS_ONLY_CONFIG, + files: { + [A8_KEEP]: A8_KEEP_SOURCE, + [A8_ORIGIN]: A8_TGT_ORIGIN_BEFORE, + [A8_TARGET]: A8_TGT_TARGET_BEFORE, + }, + receiving: A8_TARGET, + rewritten: A8_TGT_REWRITTEN, + rewrittenForm: '`<S id="mv" d={<binding>.org["base-line"]}>`', + base: A8_TGT_TARGET_BASE, + expectedModule: A8_ORIGIN_MODULE, + moduleLabel: "the origin module", + forbiddenRoots: [ + { + name: "Keep", + why: "the identifier the file's retained third-module import already binds", + }, + ...A8_MDX_RESERVED, + ], + composed: [ + { + rel: A8_ORIGIN, + expected: A8_TGT_ORIGIN_AFTER, + why: + "the moved section deleted in place with its emptied merged " + + "line dropped, the blank neighbours kept, and no import gained", + }, + { + rel: A8_KEEP, + expected: A8_KEEP_SOURCE, + why: "an uninvolved bystander, untouched", + }, + ], + edgeKind: "depends", + edges: [ + { + from: `${A8_TARGET}#mv`, + to: `${A8_ORIGIN}#org.base-line`, + kind: "depends", + }, + { from: `${A8_TARGET}#tgt`, to: `${A8_KEEP}#keep`, kind: "depends" }, + ], + }, +]; + +const T6_5_8 = defineProductTest({ + id: "T6.5-8", + title: + "added-import insertion discipline: the addition-side byte contract of 6.5 asserted value-blind — in three section-move arms (a TS file importing the origin module with markers on one moved and one unmoved node, so a target-module binding is added while the origin import stays; an MDX origin holding a retained third-module import and a local string reference to a moved descendant, converted to imported form so the origin itself gains the target module's import; an MDX target gaining the origin module's import for a moved local reference to an origin node with a non-identifier segment, converted to dot then double-quoted computed access) the receiving file's post-move bytes are composed from the rules of 6.4/6.5 and 3 up to the fresh identifier (read off the rewritten reference) and the insertion offset, and the single inserted run isolated by diff is exactly the declaration followed by U+000A at a line-start offset, or U+000A, the declaration, U+000A at any other — one 2.1-form import of the needed module binding that fresh identifier, no other byte inserted; the fully composed files byte-equal, the edge set exact, `check` clean (SPEC 6.5, 6.4, 2.1, 3; H-4, normalizing nothing)", + run: async (product) => { + for (const arm of A8_ARMS) { + await runAddedImportArm(product, arm); + } + }, +}); + /** TEST-SPEC §6.5, in canonical ID order (SUITE-25). */ export const section65Tests: readonly ProductTestEntry[] = [ T6_5_1, @@ -3954,4 +4520,5 @@ export const section65Tests: readonly ProductTestEntry[] = [ T6_5_5, T6_5_6, T6_5_7, + T6_5_8, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index ecb1066..a32cddb 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -216,6 +216,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.5-5": ["6.5", "12.0"], "T6.5-6": ["6.5", "14"], "T6.5-7": ["6.5", "6.4", "2.7", "4.5"], + "T6.5-8": ["6.5", "6.4", "2.1", "3"], "T6.6-2": ["6.6"], "T6.6-3": ["6.6", "14"], "T6.6-4": ["6.6"], From 72e63b58b07f9b7a4e905109ad7498b563ef36f7 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 10:37:43 +0000 Subject: [PATCH 214/226] =?UTF-8?q?sdg(phase-9):=20T6.5-9=20fresh=20identi?= =?UTF-8?q?fiers=20in=20code=20=E2=80=94=20T6.5-8's=20TS=20arm=20re-staged?= =?UTF-8?q?=20with=20a=20receiving=20`src/app.ts`=20that=20also=20declares?= =?UTF-8?q?=20at=20module=20scope,=20as=20a=20`const`,=20a=20`function`,?= =?UTF-8?q?=20a=20`class`,=20a=20`type`=20alias,=20and=20a=20non-spec=20im?= =?UTF-8?q?port=20binding=20(the=20default=20and=20a=20named=20import=20of?= =?UTF-8?q?=20a=20staged=20`src/util.ts`),=20every=20identifier=20TEST-SPE?= =?UTF-8?q?C=20T6.5-9=20enumerates=20as=20a=20plausible=20derivation=20?= =?UTF-8?q?=E2=80=94=20`Target`,=20`target`,=20`TARGET`,=20`TargetSpec`,?= =?UTF-8?q?=20`TargetSPEC`=20from=20the=20target=20basename=20and=20`ORG1`?= =?UTF-8?q?,=20`ORG2`,=20`ORG=5F`=20from=20the=20origin=20binding=20(the?= =?UTF-8?q?=20digit=20unspecified,=20so=20both=20smallest=20counters=20are?= =?UTF-8?q?=20staged)=20=E2=80=94=20each=20used=20trivially;=20the=20file?= =?UTF-8?q?=20compiles=20clean=20before=20the=20move=20through=20H-2's=20s?= =?UTF-8?q?tandard-tooling=20channel=20(a=20fixture=20self-check=20after?= =?UTF-8?q?=20the=20premise=20`build`),=20and=20after=20the=20section=20mo?= =?UTF-8?q?ve=20a=20fresh=20`ConsumerProject`=20must=20report=20no=20diagn?= =?UTF-8?q?ostics=20(a=20collision=20with=20the=20`const`/`function`/`clas?= =?UTF-8?q?s`=20is=20TS2440,=20with=20the=20import=20binding=20TS2300,=20a?= =?UTF-8?q?n=20unrewritten=20marker=20a=20type=20error=20against=20the=20r?= =?UTF-8?q?egenerated=20modules),=20the=20fresh=20root=20read=20off=20the?= =?UTF-8?q?=20rewritten=20marker=20in=206.4's=20pinned=20spelling=20is=20n?= =?UTF-8?q?one=20of=20the=20pre-empted=20names=20nor=20`ORG`=20=E2=80=94?= =?UTF-8?q?=20the=20direct=20check=20covering=20the=20`type`=20alias,=20wh?= =?UTF-8?q?ich=20standard=20tooling=20accepts=20beside=20a=20same-named=20?= =?UTF-8?q?default=20import=20without=20any=20diagnostic=20(verified:=20th?= =?UTF-8?q?e=20alias=20shadows=20type=20space=20alone,=20the=20import=20su?= =?UTF-8?q?pplies=20the=20value)=20=E2=80=94=20the=20complete=20`reference?= =?UTF-8?q?s`=20edge=20set=20is=20the=20moved=20marker=20under=20`specs/Ta?= =?UTF-8?q?rget.mdx#mv`=20and=20the=20unmoved=20one=20under=20`specs/Origi?= =?UTF-8?q?n.mdx#org.stay`,=20both=20attributed=20to=20the=20file,=20and?= =?UTF-8?q?=20`check`=20is=20clean;=20traceability=206.5,=202.1,=204,=204.?= =?UTF-8?q?5.=20Against=20the=20built=20product=20the=20test=20fails=20as?= =?UTF-8?q?=20a=20diagnosed=20product=20failure:=20the=20premise=20`build`?= =?UTF-8?q?=20and=20the=20pre-move=20compile=20pass,=20then=20the=20move?= =?UTF-8?q?=20exits=201=20with=20an=20`unsupported-node-usage`=20(14.18)?= =?UTF-8?q?=20finding=20located,=20in=20the=20rewritten=20coordinates,=20a?= =?UTF-8?q?t=20the=20`Target`=20of=20`return=20Target=20*=202;`=20?= =?UTF-8?q?=E2=80=94=20the=20product=20derives=20the=20target=20basename?= =?UTF-8?q?=20`Target`=20without=20checking=20local=20declarations=20(with?= =?UTF-8?q?=20that=20const=20renamed=20the=20same=20move=20succeeds=20and?= =?UTF-8?q?=20binds=20`Target`)=20and=20its=20validation=20then=20reads=20?= =?UTF-8?q?the=20local=20const's=20use=20as=20a=20spec-binding=20misuse,?= =?UTF-8?q?=20refusing=20a=20valid=20move=20instead=20of=20binding=20a=20f?= =?UTF-8?q?resh=20identifier=20(SPEC=206.5,=204.5);=20T6.5-1=E2=80=A66=20a?= =?UTF-8?q?nd=20T6.5-8=20pass,=20T6.5-7=20keeps=20its=20Task=2027=20diagno?= =?UTF-8?q?sis,=20`npm=20run=20test:self`=20342/342=20(Task=2029)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 27 --- test/suite/registry/section-6.5.ts | 282 +++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 1 + 3 files changed, 282 insertions(+), 28 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index d42bd36..8087142 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,33 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 29 — T6.5-9 Fresh identifiers in code (new test: pre-empted local bindings, compile-clean after the move) - -Cites: TEST-SPEC T6.5-9 (full text in §6.5): T6.5-8's TS arm re-staged with a -receiving code file that also declares at module scope a local `const`, a -`function`, a `class`, a `type` alias, and a non-spec import binding whose -names pre-empt the identifiers a product would plausibly derive — the target -file's basename as written, lower-cased, upper-cased, `Spec`- and -`SPEC`-suffixed, and the origin binding's name with a digit and with an -underscore appended — the file compiling clean before the move under standard -tooling; after the move, through H-2's standard-tooling channel -(`test/helpers/tooling.ts`), the rewritten file compiles with no diagnostics; -`query edges` reports the moved markers' `references` edges to the new -identities and the unmoved marker's edge through the retained origin binding; -`check` clean. SPEC 6.5, 2.1, 4, 4.5. - -Now: not implemented. - -Do: add `T6.5-9` to `section-6.5.ts` (prerequisite: Task 28's TS arm fixture -to re-stage): stage the pre-empting declarations (exactly the enumerated -derivations; each binding used trivially so the file is not just declarations), -compile before the move (no diagnostics — a fixture self-check), section-move, -compile after (no diagnostics), then the `query edges` and `check` assertions. -Register and map the ID. - -Verify: `npx vitest run … test/suite/section-6.5.test.ts`; `npm run test:self` -green (S-1, S-4 tooling driver unaffected). - ### Task 30 — T6.5-10 Third-module bindings carried with moved text (new test: arms (a) value-blind and (b) byte-composable) Cites: TEST-SPEC T6.5-10 (full text in §6.5): three spec sources `a.mdx` diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index 852445f..7dee73e 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -1,4 +1,4 @@ -// TEST-SPEC §6.5 (move) — SUITE-25: T6.5-1…T6.5-7. +// TEST-SPEC §6.5 (move) — SUITE-25: T6.5-1…T6.5-9. // // Registered product-facing bodies (C-2 "one code path"): each builds its own // fresh workspace (H-1), drives the product strictly as a subprocess (H-2), @@ -225,6 +225,10 @@ import type { ProductBinding, RunResult, } from "../../helpers/subprocess.js"; +import { + ConsumerProject, + assertNoCompileErrors, +} from "../../helpers/tooling.js"; import { TestWorkspace } from "../../helpers/workspace.js"; import type { ConcernedIdentity, FindingSourceExpectation } from "./support.js"; import { @@ -4511,6 +4515,281 @@ const T6_5_8 = defineProductTest({ }, }); +// T6.5-9 — Fresh identifiers in code (TEST-SPEC T6.5-9). The freshness +// clause of 6.5 — an added import binds fresh identifiers colliding with no +// binding already in the file (2.1, 4) — has, in a code file, no xspec-side +// observation: "any binding already in the file" spans local declarations +// as well as imports, and an added import colliding with a local +// declaration is a TypeScript error in the consumer, outside xspec's +// validations (4.5) — no `build`/`check` finding (condition 15 covers +// import-versus-import collisions alone, which is why T6.5-3's post-move +// `check` suffices in MDX, where every binding is an import), no staleness +// — while 4.5's scope-aware rooting may then drop the rewritten markers' +// edges silently. A product checking freshness against import bindings +// alone passes T6.5-8's TS arm, whose receiving file leaves every plausible +// identifier free. This test re-stages that arm with a receiving code file +// that additionally declares, at module scope, bindings pre-empting the +// identifiers a product would plausibly derive — spelled from the target +// file's basename (as written, lower- and upper-cased, `Spec`- and +// `SPEC`-suffixed) and from the origin binding's name with a digit and with +// an underscore appended (the digit is unspecified, so the two smallest +// counters are both staged) — as a local `const`, a `function`, a `class`, a +// `type` alias, and a non-spec import binding, each used trivially so the +// file is not just declarations; the file compiles clean before the move +// under standard tooling (a fixture self-check). After the section move, +// through H-2's standard-tooling channel (`test/helpers/tooling.ts`), the +// rewritten file compiles with no diagnostics: a collision with the +// `const`, `function`, or `class` declaration is TS2440 (import declaration +// conflicts with local declaration), one with the import binding TS2300 +// (duplicate identifier), and an unrewritten or misrooted marker a type +// error against the regenerated modules — so the added declaration's +// identifier equals none of those pre-empted names. `query edges` then +// reports the moved marker's `references` edge from the file to the moved +// node's new identity and the unmoved marker's through the retained origin +// binding, and `check` is clean (T6.5-3). The pre-empted set is a lure, not +// a bound: the identifier stays the product's choice (6.5's latitude). +// +// Observation noted under H-4: standard tooling accepts a default import +// beside a same-named `type` alias — the alias shadows in type space alone +// and the import supplies the value — so that one pre-emption draws no +// diagnostic and is invisible to compile-cleanliness. It is asserted +// directly instead, with T6.5-8's forbidden-roots discipline over the whole +// pre-empted set (SPEC 6.5: colliding with no binding already in the file — +// a type-level binding included, 4): the fresh root, read off the rewritten +// marker in 6.4's pinned spelling, is none of the pre-empted names nor the +// retained origin binding. Any product satisfying 6.5 satisfies both +// assertions, so neither narrows its latitude. +const A9_UTIL = "src/util.ts"; + +/** One pre-empting module-scope binding of the receiving code file. */ +interface PreemptedBinding { + readonly name: string; + /** The declaration kind holding the name. */ + readonly kind: string; + /** The derivation TEST-SPEC T6.5-9 enumerates. */ + readonly derivation: string; +} + +/** + * The pre-empted set: every derivation T6.5-9 enumerates, from the target + * file's basename `Target` (`specs/Target.mdx`) and the origin binding + * `ORG`, spread over the five declaration kinds it names. + */ +const A9_PREEMPTED: readonly PreemptedBinding[] = [ + { + name: "Target", + kind: "a module-scope `const`", + derivation: "the target file's basename as written", + }, + { + name: "target", + kind: "a non-spec import binding (the default import of `src/util.ts`)", + derivation: "the target file's basename lower-cased", + }, + { + name: "TARGET", + kind: "a `function` declaration", + derivation: "the target file's basename upper-cased", + }, + { + name: "TargetSpec", + kind: "a `class` declaration", + derivation: "the target file's basename `Spec`-suffixed", + }, + { + name: "TargetSPEC", + kind: "a `type` alias", + derivation: "the target file's basename `SPEC`-suffixed", + }, + { + name: "ORG1", + kind: "a module-scope `const`", + derivation: "the origin binding's name with a digit appended", + }, + { + name: "ORG2", + kind: "a non-spec import binding (a named import of `src/util.ts`)", + derivation: "the origin binding's name with a digit appended", + }, + { + name: "ORG_", + kind: "a `function` declaration", + derivation: "the origin binding's name with an underscore appended", + }, +]; + +/** The non-spec module whose bindings pre-empt `target` and `ORG2`. */ +const A9_UTIL_SOURCE = ["export default 1;", "export const ORG2 = 2;", ""].join( + "\n", +); + +// The receiving code file: T6.5-8's TS arm (the origin import `ORG`, a +// marker on the moved `org.mv` and one on the unmoved `org.stay`, both at +// module scope so their edges are attributed to the file, 4.6) plus the +// pre-empted set, every binding used trivially — the local uses are rooted +// at local declarations, so none is a spec module reference (4.5) and none +// records an edge. +const A9_APP_BEFORE = [ + 'import ORG from "../specs/Origin.xspec";', + 'import target, { ORG2 } from "./util.js";', + "", + "const Target = target + ORG2;", + "function TARGET(): number {", + " return Target * 2;", + "}", + "class TargetSpec {", + " readonly value = TARGET();", + "}", + "type TargetSPEC = TargetSpec;", + "const ORG1: TargetSPEC = new TargetSpec();", + "function ORG_(): number {", + " return ORG1.value;", + "}", + "ORG_();", + "", + "ORG.org.mv;", + "ORG.org.stay;", + "", +].join("\n"); + +const A9_FILES: Readonly<Record<string, string>> = { + [A8_ORIGIN]: A8_TS_ORIGIN_BEFORE, + [A8_TARGET]: A8_PLAIN_TARGET, + [A8_APP]: A9_APP_BEFORE, + [A9_UTIL]: A9_UTIL_SOURCE, +}; + +/** The workspace's complete `references` edge set after the move. */ +const A9_EDGES: readonly GraphEdge[] = [ + { from: A8_APP, to: `${A8_TARGET}#mv`, kind: "references" }, + { from: A8_APP, to: `${A8_ORIGIN}#org.stay`, kind: "references" }, +]; + +/** + * The identifier the rewritten marker is rooted at — the value-unpinned + * fresh binding (SPEC 6.5), read off the one place 6.4's pinned spelling + * makes it observable (T6.5-8); diagnosed when the marker is not spelled as + * 6.4 pins it, or is rewritten more or less than once. + */ +function a9RewrittenMarkerRoot(text: string, context: string): string { + const matches = [...text.matchAll(A8_APP_REWRITTEN)]; + const root = matches.length === 1 ? matches[0]?.[1] : undefined; + if (root === undefined) { + fail( + `${context}: ${A8_APP} must hold exactly one \`<fresh>.mv;\` — the ` + + `marker on the moved node rewritten through a binding of the ` + + `target module in 6.4's pinned spelling (dot access, \`mv\` being ` + + `identifier-valid; SPEC 6.5, 6.4); found ${String(matches.length)} ` + + `in ${JSON.stringify(text)}`, + ); + } + return root; +} + +const T6_5_9 = defineProductTest({ + id: "T6.5-9", + title: + "fresh identifiers in code: T6.5-8's TS arm re-staged with a receiving code file that also declares at module scope — as a local `const`, a `function`, a `class`, a `type` alias, and a non-spec import binding, each used trivially — the identifiers a product would plausibly derive for the added target-module import (the target file's basename as written, lower- and upper-cased, `Spec`- and `SPEC`-suffixed; the origin binding's name with a digit and with an underscore appended), the file compiling clean before the move under standard tooling; after the section move the rewritten file compiles with no diagnostics through H-2's standard-tooling channel (a collision with the `const`, `function`, or `class` is TS2440, with the import binding TS2300, an unrewritten or misrooted marker a type error against the regenerated modules), the fresh root read off the rewritten marker is none of the pre-empted names — the `type` alias's pre-emption, which standard tooling accepts silently, included — nor the retained origin binding, `query edges` reports the moved marker's `references` edge to the moved node's new identity and the unmoved marker's through the retained origin binding, and `check` is clean (SPEC 6.5, 2.1, 4, 4.5)", + run: async (product) => { + const context = "T6.5-9"; + const preempted = A9_PREEMPTED.map((binding) => binding.name).join(", "); + await withWorkspace(SPEC_AND_CODE_CONFIG, A9_FILES, async (workspace) => { + // Premise: the staging is valid (every reference and marker resolves). + await buildOk( + product, + workspace, + `${context} \`build\` over the staging`, + ); + // Fixture self-check: the pre-empting declarations are valid + // TypeScript and the generated origin module resolves, so a later + // diagnostic is the move's, not the staging's. + assertNoCompileErrors( + await ConsumerProject.load({ + rootDir: workspace.root, + rootFiles: [A8_APP], + }), + `${context} premise: ${A8_APP} compiles clean before the move ` + + `under standard tooling — its pre-empting module-scope ` + + `declarations (${preempted}) are valid TypeScript and the ` + + `generated origin module resolves (SPEC 4, 13.1; a fixture ` + + `self-check)`, + ); + + await expectExit( + product, + workspace, + [...A8_MOVE_ARGV], + 0, + `${context} \`move specs/Origin.mdx#org.mv specs/Target.mdx#mv\` — ` + + `a valid move over the workspace the premise \`build\` accepted ` + + `succeeds (SPEC 6.5); a finding located in ${A8_APP} at this step ` + + `points at the added target-module import binding one of the ` + + `pre-empted identifiers (${preempted}), the file's pre-existing ` + + `local uses of that name then read as value-level uses of a spec ` + + `binding (SPEC 6.5, 4.5, 14.18)`, + ); + + // The assertion T6.5-9 names: no diagnostics, whatever identifier the + // product chose. A fresh project — the language service snapshots + // files on first access, and the move rewrote them. + assertNoCompileErrors( + await ConsumerProject.load({ + rootDir: workspace.root, + rootFiles: [A8_APP], + }), + `${context}: ${A8_APP} after the move compiles with no diagnostics ` + + `under standard tooling — the added target-module import binds an ` + + `identifier colliding with none of the file's module-scope ` + + `bindings (pre-empted: ${preempted}; a collision is TS2440 ` + + `"Import declaration conflicts with local declaration" or TS2300 ` + + `"Duplicate identifier"), and the rewritten marker resolves ` + + `against the regenerated modules (SPEC 6.5, 2.1, 4, 4.5)`, + ); + + // The direct observation, covering the `type` alias standard tooling + // accepts silently: the fresh root is none of the pre-empted names. + const text = await readSourceText(workspace, A8_APP, context); + const root = a9RewrittenMarkerRoot(text, context); + const taken = A9_PREEMPTED.find((binding) => binding.name === root); + if (taken !== undefined) { + fail( + `${context}: the added import binds \`${taken.name}\`, ` + + `${taken.kind} the receiving file already declares at module ` + + `scope (spelled from ${taken.derivation}) — an added import ` + + `binds fresh identifiers colliding with no binding already in ` + + `the file (SPEC 6.5, 2.1, 4)`, + ); + } + if (root === "ORG") { + fail( + `${context}: the added import binds \`ORG\`, the identifier the ` + + `file's retained origin import already binds (SPEC 6.5, 2.1, ` + + `14.15)`, + ); + } + + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "references", context), + A9_EDGES, + `${context}: the complete \`references\` edge set after the move — ` + + `the rewritten marker reported under the moved node's new ` + + `identity through the fresh binding, the unmoved marker's edge ` + + `through the retained origin binding, both attributed to the ` + + `file (SPEC 6.5, 4.5, 4.6, 5.2)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context} \`check\` immediately after the move — the added import ` + + `and the rewritten marker resolve and no staleness remains (SPEC ` + + `6.5, 12.2, 14.10)`, + ); + }); + }, +}); + /** TEST-SPEC §6.5, in canonical ID order (SUITE-25). */ export const section65Tests: readonly ProductTestEntry[] = [ T6_5_1, @@ -4521,4 +4800,5 @@ export const section65Tests: readonly ProductTestEntry[] = [ T6_5_6, T6_5_7, T6_5_8, + T6_5_9, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index a32cddb..5027037 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -217,6 +217,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.5-6": ["6.5", "14"], "T6.5-7": ["6.5", "6.4", "2.7", "4.5"], "T6.5-8": ["6.5", "6.4", "2.1", "3"], + "T6.5-9": ["6.5", "2.1", "4", "4.5"], "T6.6-2": ["6.6"], "T6.6-3": ["6.6", "14"], "T6.6-4": ["6.6"], From 723652d60f0321d5665deb3a3070c9ddba63c710 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 10:50:11 +0000 Subject: [PATCH 215/226] =?UTF-8?q?sdg(phase-9):=20T6.5-10=20third-module?= =?UTF-8?q?=20bindings=20carried=20with=20moved=20text=20=E2=80=94=20two?= =?UTF-8?q?=20section-move=20arms=20over=20three=20spec=20sources=20in=20o?= =?UTF-8?q?ne=20directory=20(origin=20`specs/a.mdx`,=20target=20`specs/b.m?= =?UTF-8?q?dx`,=20`specs/x.mdx`=20holding=20`foo`=20and=20the=20non-identi?= =?UTF-8?q?fier=20ID=20`bar-baz`),=20each=20moving=20`a.mv`=20to=20the=20t?= =?UTF-8?q?op-level=20`mv`=20of=20the=20target,=20where=20the=20moved=20he?= =?UTF-8?q?ad=20carries=20`d=3D{X.foo}`=20and=20the=20moved=20leaf=20embed?= =?UTF-8?q?s=20`{text(X["bar-baz"])}`=20through=20the=20origin's=20`X`=20i?= =?UTF-8?q?mport=20and=20no=20reference=20to=20a=20moved=20node=20lies=20o?= =?UTF-8?q?utside=20the=20subtree:=20arm=20(a),=20value-blind=20under=20T6?= =?UTF-8?q?.5-8's=20discipline=20=E2=80=94=20the=20target=20holds=20no=20i?= =?UTF-8?q?mport=20of=20`x.mdx`'s=20module=20and=20the=20origin's=20only?= =?UTF-8?q?=20`X`=20references=20are=20the=20moved=20ones,=20so=20the=20ta?= =?UTF-8?q?rget's=20post-move=20bytes=20are=20composed=20from=206.4/6.5=20?= =?UTF-8?q?and=203=20up=20to=20the=20fresh=20identifier=20(read=20off=20th?= =?UTF-8?q?e=20rewritten=20`d`=20reference=20in=206.4's=20pinned=20spellin?= =?UTF-8?q?g=20and=20cross-checked=20against=20the=20rewritten=20embedding?= =?UTF-8?q?,=20`X`=20itself=20admissible,=20never=20`S`/`Spec`/`text`)=20a?= =?UTF-8?q?nd=20the=20insertion=20offset,=20the=20single=20inserted=20run?= =?UTF-8?q?=20isolated=20by=20`assertAddedImportInsertion`=20as=20exactly?= =?UTF-8?q?=20one=202.1-form=20import=20designating=20`specs/x.xspec`,=20w?= =?UTF-8?q?hile=20the=20origin=20is=20asserted=20byte-equal=20to=20its=20c?= =?UTF-8?q?omposition=20=E2=80=94=20the=20section=20deleted=20in=20place?= =?UTF-8?q?=20with=20its=20two=20emptied=20lines=20dropped,=20the=20blank?= =?UTF-8?q?=20neighbours=20kept,=20and=20the=20own-line=20`X`=20declaratio?= =?UTF-8?q?n=20deleted=20with=20its=20line's=20U+000A=20so=20the=20file=20?= =?UTF-8?q?opens=20with=20the=20blank=20line=20that=20followed=20it;=20arm?= =?UTF-8?q?=20(b),=20byte-composable=20=E2=80=94=20the=20target=20already?= =?UTF-8?q?=20imports=20the=20module=20as=20`Z`=20(used=20by=20its=20own?= =?UTF-8?q?=20`d=3D{Z.foo}`)=20and=20the=20origin=20keeps=20`a.stay`'s=20`?= =?UTF-8?q?d=3D{X.foo}`,=20so=20no=20import=20is=20added,=20`X.foo`=20?= =?UTF-8?q?=E2=86=92=20`Z.foo`=20and=20`X["bar-baz"]`=20=E2=86=92=20`Z["ba?= =?UTF-8?q?r-baz"]`=20with=20access=20forms=20kept,=20the=20origin's=20dec?= =?UTF-8?q?laration=20stays,=20and=20both=20files=20are=20asserted=20byte-?= =?UTF-8?q?equal=20to=20composed=20expectations;=20in=20both=20arms=20`x.m?= =?UTF-8?q?dx`=20is=20asserted=20untouched,=20the=20complete=20`depends`?= =?UTF-8?q?=20and=20`embeds`=20edge=20sets=20are=20exact=20(the=20moved=20?= =?UTF-8?q?nodes'=20edges=20under=20`specs/b.mdx#mv`=20and=20`specs/b.mdx#?= =?UTF-8?q?mv.leaf`=20to=20`x.mdx`'s=20unchanged=20nodes),=20and=20`check`?= =?UTF-8?q?=20then=20`build`=20are=20clean=20under=20Markdown=20emission?= =?UTF-8?q?=20so=20the=20embedding's=20expansion=20is=20regenerated;=20reg?= =?UTF-8?q?istered=20in=20`section65Tests`=20and=20mapped=20to=206.5,=206.?= =?UTF-8?q?4,=202.1,=203.=20Against=20the=20built=20product=20both=20arms?= =?UTF-8?q?=20pass=20(the=20product=20binds=20`x`=20at=20offset=200=20in?= =?UTF-8?q?=20arm=20(a),=20verified=20by=20hand-replay),=20`npm=20run=20te?= =?UTF-8?q?st:self`=20342/342=20(Task=2030)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 27 -- test/suite/registry/section-6.5.ts | 456 ++++++++++++++++++++++++++++ test/suite/registry/traceability.ts | 1 + 3 files changed, 457 insertions(+), 27 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 8087142..d1b7292 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,33 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 30 — T6.5-10 Third-module bindings carried with moved text (new test: arms (a) value-blind and (b) byte-composable) - -Cites: TEST-SPEC T6.5-10 (full text in §6.5): three spec sources `a.mdx` -(origin), `b.mdx` (target), `x.mdx` in one directory; the moved subtree holds a -`d` reference and a `{text(...)}` embedding through the origin's `X` binding, -one through `X["bar-baz"]`; (a) target lacks an import of `x.mdx` and the -origin's only `X` references lie in the moved subtree → target gains exactly -one import declaration under T6.5-8's line discipline (specifier of 2.1's -form designating `x.mdx`, relative spelling free), each moved reference rooted -at the fresh identifier (`X` itself admissible) with access form kept, moved -text otherwise byte-identical; origin loses the section and its own-line `X` -declaration with the terminator, otherwise byte-identical; (b) target already -imports `x.mdx` as `Z` (referenced by its own section) and the origin keeps an -`X` reference outside the subtree → no import added, `X.foo` → `Z.foo`, -`X["bar-baz"]` → `Z["bar-baz"]`, origin's `X` declaration kept, both files -byte-equal to composed expectations; in both arms `query edges` reports the -moved nodes' `depends` and `embeds` edges under their new identities to -`x.mdx`'s unchanged nodes, `build` and `check` clean. SPEC 6.5, 6.4, 2.1, 3. - -Now: not implemented. - -Do: add `T6.5-10` to `section-6.5.ts` using Task 28's diff-isolation helper -for arm (a) and whole-file byte compares for arm (b); register and map the ID. - -Verify: `npx vitest run … test/suite/section-6.5.test.ts`; `npm run test:self` -green. - ### Task 31 — T6.6-4: rename-preview mapping-order fixture with descendants in document order opposite to byte order Cites: TEST-SPEC T6.6-4 (the preview's mapping lists the renamed section's diff --git a/test/suite/registry/section-6.5.ts b/test/suite/registry/section-6.5.ts index 7dee73e..de793c2 100644 --- a/test/suite/registry/section-6.5.ts +++ b/test/suite/registry/section-6.5.ts @@ -4790,6 +4790,461 @@ const T6_5_9 = defineProductTest({ }, }); +// T6.5-10 — Third-module bindings carried with moved text (TEST-SPEC +// T6.5-10). A reference inside the moved text need not target a moved node +// to need rewriting: an imported-form reference to a node of a spec module +// `X` that is neither origin nor target — `d={X.foo}`, `{text(X["bar-baz"])}` +// — is bound by the origin file's import of `X`, which the moved text leaves +// behind, so in the target file it needs a binding of `X`'s module (SPEC +// 6.5: an import is added when a rewritten reference needs a module binding +// its file lacks — and only then, the reading T6.5-7's TS arm pins for +// markers rewritten through an existing binding). T6.5-7 keeps its +// third-module reference outside the moved subtree and T6.5-8's conversions +// are local↔imported alone, so no fixture of theirs meets this shape. Two +// section-move arms over three spec sources in one directory — origin +// `specs/a.mdx`, target `specs/b.mdx`, and `specs/x.mdx` — each moving +// `a.mv` to the top-level `mv` of the target, where the moved head carries +// `d={X.foo}` and the moved leaf embeds `{text(X["bar-baz"])}` (a +// double-quoted computed segment: `bar-baz` is a valid ID, 1.4, that is not +// a TypeScript identifier, 2.4), and no reference to a moved node lies +// outside the subtree: +// - (a) value-blind, T6.5-8's discipline: the target, an existing discovered +// source, holds no import of `x.mdx`'s module, and the origin's only +// references through `X` lie inside the moved subtree. The target's +// post-move bytes are composed from the rules of 6.4/6.5 and 3 up to the +// fresh identifier — read off the rewritten `d` reference in 6.4's pinned +// spelling and cross-checked against the rewritten embedding; `X` itself +// admissible, being fresh in that file — and the insertion offset, +// `assertAddedImportInsertion` isolating the single inserted run as +// exactly one 2.1-form import designating `x.mdx`'s module under 6.5's +// line discipline; the origin loses the section and, its `X` binding left +// without references, its own-line `X` declaration with the line's +// terminator (6.5's exact extent, T6.5-7), and is otherwise +// byte-identical. +// - (b) byte-composable, no latitude: the target already imports `x.mdx`'s +// module as `Z`, referenced by a section of its own, and the origin keeps +// a reference through `X` outside the moved subtree. No import is added +// (the file lacks no binding of the module), each moved reference is +// re-rooted to `Z` with quote style and access form kept (`X.foo` → +// `Z.foo`, `X["bar-baz"]` → `Z["bar-baz"]`; 6.4: minimal in-place edits), +// the origin's `X` declaration stays byte-for-byte, and the rewritten +// origin and target are each asserted byte-equal to composed expectations. +// In both arms `x.mdx` is a bystander asserted untouched, `query edges` +// reports the moved nodes' `depends` and `embeds` edges under their new +// identities to `x.mdx`'s unchanged nodes (the complete set of each kind), +// and `check` and `build` are clean (6.5: a successful move's finishing +// regeneration runs on a valid workspace; Markdown emission is enabled so +// the embedding's expansion is regenerated as well, 3). A product converting +// only between local and imported forms leaves `X.foo` unbound in the +// target — an invalid workspace behind a reported success — and fails both +// arms; one adding a second import of a module the target already binds +// fails (b)'s whole-file contract. +const C10_ORIGIN = "specs/a.mdx"; +const C10_TARGET = "specs/b.mdx"; +const C10_THIRD = "specs/x.mdx"; +const C10_THIRD_MODULE = "specs/x.xspec"; + +const C10_MOVE_ARGV = ["move", "specs/a.mdx#a.mv", "specs/b.mdx#mv"] as const; + +// The third module: `foo` (identifier-valid) and `bar-baz` (a valid ID that +// is not a TypeScript identifier, reachable only by computed access; SPEC +// 1.4, 2.4). Neither is moved, so both keep their identities. +const C10_THIRD_SOURCE = [ + '<S id="foo">', + "Foo text.", + "</S>", + "", + '<S id="bar-baz">', + "Bar baz text.", + "</S>", + "", +].join("\n"); + +/** + * The moved subtree's lines, spelled with its ID prefix (`a.mv` in the + * origin, `mv` after re-identification in the target) and the binding its + * third-module references are rooted at (`X` in the origin; the fresh + * identifier or the existing `Z` in the target), the access forms kept: + * dot access for `foo`, double-quoted computed access for `bar-baz` (SPEC + * 6.5, 6.4, 2.4). + */ +function c10MovedLines(prefix: string, root: string): string[] { + return [ + `<S id="${prefix}" d={${root}.foo}>`, + "Moved head text.", + "", + `<S id="${prefix}.leaf">`, + "Moved leaf text, as specified:", + "", + `{text(${root}["bar-baz"])}`, + "</S>", + "</S>", + ]; +} + +// Arm (a). The origin's `X` import is the first line, alone; its only +// references through `X` are the moved subtree's two. +const C10_A_ORIGIN_BEFORE = [ + 'import X from "./x.xspec"', + "", + '<S id="a">', + "Origin holder text.", + "", + ...c10MovedLines("a.mv", "X"), + "</S>", + "", +].join("\n"); + +// Composed from SPEC 6.5 and 3, no latitude: the moved construct deleted in +// place, the lines holding its opening and closing tags (left empty) dropped +// with their terminators, the blank neighbours kept; the `X` binding left +// without references, so its declaration is deleted in place and its +// emptied line dropped with its U+000A — the blank line that followed it +// stays, so the file now opens with that terminator (T6.5-7's exact extent). +const C10_A_ORIGIN_AFTER = [ + "", + '<S id="a">', + "Origin holder text.", + "", + "</S>", + "", +].join("\n"); + +const C10_A_TARGET_BEFORE = ['<S id="b">', "Target text.", "</S>", ""].join( + "\n", +); + +// The target's expected post-move bytes WITHOUT the added import (SPEC +// 6.4/6.5): the moved text appended at end of file (the insertion point at +// the start of a line, so no preceding terminator) plus U+000A, its `id`s +// re-identified by prefix replacement, each third-module reference re-rooted +// at the fresh binding with its access form kept, otherwise byte-identical. +const C10_A_TARGET_BASE = (root: string): string => + ['<S id="b">', "Target text.", "</S>", ...c10MovedLines("mv", root), ""].join( + "\n", + ); + +const C10_REWRITTEN_DEPENDS = + /<S id="mv" d=\{([A-Za-z_$][A-Za-z0-9_$]*)\.foo\}>/g; +const C10_REWRITTEN_EMBEDS = + /\{text\(([A-Za-z_$][A-Za-z0-9_$]*)\["bar-baz"\]\)\}/g; + +// Arm (b). The origin keeps `a.stay`'s reference through `X` outside the +// moved subtree; the target already binds `x.mdx`'s module as `Z`, used by +// its own section. +const C10_B_ORIGIN_BEFORE = [ + 'import X from "./x.xspec"', + "", + '<S id="a">', + "Origin holder text.", + "", + ...c10MovedLines("a.mv", "X"), + "", + '<S id="a.stay" d={X.foo}>', + "Staying text.", + "</S>", + "</S>", + "", +].join("\n"); + +// Composed from SPEC 6.5 and 3: the moved construct deleted in place with +// its two emptied lines dropped, the blank neighbours kept; the `X` binding +// keeps `a.stay`'s reference, so its declaration stays byte-for-byte. +const C10_B_ORIGIN_AFTER = [ + 'import X from "./x.xspec"', + "", + '<S id="a">', + "Origin holder text.", + "", + "", + '<S id="a.stay" d={X.foo}>', + "Staying text.", + "</S>", + "</S>", + "", +].join("\n"); + +const C10_B_TARGET_BEFORE = [ + 'import Z from "./x.xspec"', + "", + '<S id="b" d={Z.foo}>', + "Target text.", + "</S>", + "", +].join("\n"); + +// Composed from SPEC 6.5 and 6.4, no latitude: the moved text appended at +// end of file plus U+000A, re-identified, each third-module reference +// re-rooted at the existing `Z` binding with quote style and access form +// kept; no import added, the existing import and `b` byte-for-byte. +const C10_B_TARGET_AFTER = [ + 'import Z from "./x.xspec"', + "", + '<S id="b" d={Z.foo}>', + "Target text.", + "</S>", + ...c10MovedLines("mv", "Z"), + "", +].join("\n"); + +/** The moved nodes' edges under their new identities (SPEC 6.5, 5.2). */ +const C10_MOVED_DEPENDS: GraphEdge = { + from: `${C10_TARGET}#mv`, + to: `${C10_THIRD}#foo`, + kind: "depends", +}; +const C10_MOVED_EMBEDS: GraphEdge = { + from: `${C10_TARGET}#mv.leaf`, + to: `${C10_THIRD}#bar-baz`, + kind: "embeds", +}; + +/** + * The identifier one of the target's rewritten third-module references is + * rooted at, read off 6.4's pinned spelling; diagnosed when the reference + * is not spelled as 6.4 pins it, or is present more or less than once. + */ +function c10ReferenceRoot( + text: string, + pattern: RegExp, + form: string, + context: string, +): string { + const matches = [...text.matchAll(pattern)]; + const root = matches.length === 1 ? matches[0]?.[1] : undefined; + if (root === undefined) { + fail( + `${context}: ${C10_TARGET} must hold exactly one ${form} — the moved ` + + `reference to ${C10_THIRD}'s node rewritten through a binding of ` + + `its module in 6.4's pinned spelling, its access form kept (dot ` + + `access for the identifier-valid segment, double-quoted computed ` + + `access for the other; SPEC 6.5, 6.4); found ` + + `${String(matches.length)} in ${JSON.stringify(text)}`, + ); + } + return root; +} + +/** + * The guards shared by both arms after the move: the third module untouched, + * the complete `depends` and `embeds` edge sets exact — the moved nodes' + * edges reported under their new identities to `x.mdx`'s unchanged nodes — + * and `check` and `build` clean on the rewritten workspace. + */ +async function c10AssertPostMove( + product: ProductBinding, + workspace: TestWorkspace, + edges: { depends: readonly GraphEdge[]; embeds: readonly GraphEdge[] }, + context: string, +): Promise<void> { + await assertFileBytes( + workspace.path(C10_THIRD), + C10_THIRD_SOURCE, + `${context}: ${C10_THIRD} after the move — the third module, whose ` + + `nodes are referenced but not moved, untouched (SPEC 6.5; H-4)`, + ); + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "depends", context), + edges.depends, + `${context}: the complete \`depends\` edge set after the move — the ` + + `moved head's \`d={…foo}\` reported under its new identity to ` + + `${C10_THIRD}#foo, every other edge unchanged (SPEC 6.5, 5.2)`, + ); + assertEdgeSetEqual( + await queryEdgesOfKind(product, workspace, "embeds", context), + edges.embeds, + `${context}: the complete \`embeds\` edge set after the move — the ` + + `moved leaf's \`{text(…["bar-baz"])}\` reported under its new ` + + `identity to ${C10_THIRD}#bar-baz (SPEC 6.5, 5.2, 2.3)`, + ); + await expectExit( + product, + workspace, + ["check"], + 0, + `${context} \`check\` immediately after the move — every rewritten ` + + `reference resolves through its file's binding of ${C10_THIRD}'s ` + + `module and no staleness remains (SPEC 6.5, 12.2, 14.10)`, + ); + await buildOk( + product, + workspace, + `${context} \`build\` after the move — the rewritten workspace is ` + + `valid, the embedding's expansion included (SPEC 6.5, 12.1, 3)`, + ); +} + +const T6_5_10 = defineProductTest({ + id: "T6.5-10", + title: + "third-module bindings carried with moved text: two section-move arms over three spec sources in one directory (origin `a.mdx`, target `b.mdx`, and `x.mdx`), the moved subtree holding a `d={X.foo}` reference and a `{text(X[\"bar-baz\"])}` embedding through the origin's `X` binding and no reference to a moved node lying outside it — (a) value-blind: the target holds no import of `x.mdx`'s module and the origin's only `X` references lie in the moved subtree, so the target's post-move bytes are composed from the rules of 6.4/6.5 and 3 up to the fresh identifier (read off the rewritten references in 6.4's pinned spelling, `X` itself admissible) and the insertion offset, the single inserted run isolated by diff being exactly one 2.1-form import of `x.mdx`'s module under 6.5's line discipline, each moved reference rooted at its binding with access form kept, while the origin loses the section and its own-line `X` declaration with the line's terminator and is otherwise byte-identical; (b) byte-composable: the target already imports `x.mdx`'s module as `Z`, referenced by its own section, and the origin keeps an `X` reference outside the subtree, so no import is added, `X.foo` → `Z.foo` and `X[\"bar-baz\"]` → `Z[\"bar-baz\"]`, the origin's `X` declaration stays, and both files are byte-equal to composed expectations; in both arms `x.mdx` is untouched, `query edges` reports the moved nodes' `depends` and `embeds` edges under their new identities to `x.mdx`'s unchanged nodes, and `check` and `build` are clean (SPEC 6.5, 6.4, 2.1, 3; H-4, normalizing nothing)", + run: async (product) => { + { + const context = "T6.5-10 arm (a) value-blind"; + await withWorkspace( + SPECS_MD_CONFIG, + { + [C10_THIRD]: C10_THIRD_SOURCE, + [C10_ORIGIN]: C10_A_ORIGIN_BEFORE, + [C10_TARGET]: C10_A_TARGET_BEFORE, + }, + async (workspace) => { + // Premise: the staging is valid (every reference resolves), so a + // later failure is the move's, not the staging's. + await buildOk( + product, + workspace, + `${context} \`build\` over the staging`, + ); + await expectExit( + product, + workspace, + [...C10_MOVE_ARGV], + 0, + `${context} \`move specs/a.mdx#a.mv specs/b.mdx#mv\``, + ); + + const text = await readSourceText(workspace, C10_TARGET, context); + const root = c10ReferenceRoot( + text, + C10_REWRITTEN_DEPENDS, + '`<S id="mv" d={<binding>.foo}>`', + context, + ); + const embedRoot = c10ReferenceRoot( + text, + C10_REWRITTEN_EMBEDS, + '`{text(<binding>["bar-baz"])}`', + context, + ); + if (embedRoot !== root) { + fail( + `${context}: the moved references are rooted at different ` + + `identifiers — the \`d\` reference at ${JSON.stringify(root)}, ` + + `the embedding at ${JSON.stringify(embedRoot)} — while both ` + + `were bound by the origin's one \`X\` import and need the ` + + `one binding of ${C10_THIRD}'s module the added import ` + + `supplies (SPEC 6.5, 2.1)`, + ); + } + for (const forbidden of A8_MDX_RESERVED) { + if (root === forbidden.name) { + fail( + `${context}: the added import binds \`${forbidden.name}\`, ` + + `${forbidden.why} — an added import binds fresh ` + + `identifiers colliding with no binding already in the ` + + `file (SPEC 6.5, 2.1, 14.15)`, + ); + } + } + // Composed from the rules of 6.4/6.5 and 3 up to the two unknowns — + // the fresh identifier (now known) and the insertion offset + // (isolated by the helper, which reads the run at every + // admissible offset). + assertAddedImportInsertion( + { + rel: C10_TARGET, + base: Buffer.from(C10_A_TARGET_BASE(root), "utf8"), + actual: await workspace.readBytes(C10_TARGET), + importerDir: posixPath.dirname(C10_TARGET), + expectedModule: C10_THIRD_MODULE, + identifier: root, + }, + `${context}: ${C10_TARGET} after the move is its composed ` + + `post-move bytes with exactly one import of ${C10_THIRD}'s ` + + `module added as a line of its own — the declaration followed ` + + `by U+000A at a line-start offset, preceded by one as well at ` + + `any other — binding the identifier the moved references are ` + + `rooted at, no other byte inserted (SPEC 6.5, 2.1, 6.4, 3)`, + ); + await assertFileBytes( + workspace.path(C10_ORIGIN), + C10_A_ORIGIN_AFTER, + `${context}: ${C10_ORIGIN} after the move — the moved section ` + + `deleted in place with its emptied lines dropped, the blank ` + + `neighbours kept, and the \`X\` import, its binding left ` + + `without references, deleted in place with its emptied line's ` + + `U+000A (6.5's exact extent), otherwise byte-identical (SPEC ` + + `6.5, 2.1, 3; H-4, normalizing nothing)`, + ); + await c10AssertPostMove( + product, + workspace, + { depends: [C10_MOVED_DEPENDS], embeds: [C10_MOVED_EMBEDS] }, + context, + ); + }, + ); + } + { + const context = "T6.5-10 arm (b) byte-composable"; + await withWorkspace( + SPECS_MD_CONFIG, + { + [C10_THIRD]: C10_THIRD_SOURCE, + [C10_ORIGIN]: C10_B_ORIGIN_BEFORE, + [C10_TARGET]: C10_B_TARGET_BEFORE, + }, + async (workspace) => { + await buildOk( + product, + workspace, + `${context} \`build\` over the staging`, + ); + await expectExit( + product, + workspace, + [...C10_MOVE_ARGV], + 0, + `${context} \`move specs/a.mdx#a.mv specs/b.mdx#mv\``, + ); + await assertFileBytes( + workspace.path(C10_TARGET), + C10_B_TARGET_AFTER, + `${context}: ${C10_TARGET} after the move — the re-identified ` + + `moved text appended at end of file plus U+000A, its ` + + `third-module references re-rooted at the existing \`Z\` ` + + `binding with quote style and access form kept (\`X.foo\` → ` + + `\`Z.foo\`, \`X["bar-baz"]\` → \`Z["bar-baz"]\`), no import ` + + `added (the file lacks no binding of the module), otherwise ` + + `byte-identical (SPEC 6.5, 6.4, 2.1; H-4, normalizing nothing)`, + ); + await assertFileBytes( + workspace.path(C10_ORIGIN), + C10_B_ORIGIN_AFTER, + `${context}: ${C10_ORIGIN} after the move — the moved section ` + + `deleted in place with its emptied lines dropped, the blank ` + + `neighbours kept, and the \`X\` import kept byte-for-byte, its ` + + `binding keeping \`a.stay\`'s reference (SPEC 6.5, 2.1, 3; ` + + `H-4, normalizing nothing)`, + ); + await c10AssertPostMove( + product, + workspace, + { + depends: [ + C10_MOVED_DEPENDS, + { + from: `${C10_TARGET}#b`, + to: `${C10_THIRD}#foo`, + kind: "depends", + }, + { + from: `${C10_ORIGIN}#a.stay`, + to: `${C10_THIRD}#foo`, + kind: "depends", + }, + ], + embeds: [C10_MOVED_EMBEDS], + }, + context, + ); + }, + ); + } + }, +}); + /** TEST-SPEC §6.5, in canonical ID order (SUITE-25). */ export const section65Tests: readonly ProductTestEntry[] = [ T6_5_1, @@ -4801,4 +5256,5 @@ export const section65Tests: readonly ProductTestEntry[] = [ T6_5_7, T6_5_8, T6_5_9, + T6_5_10, ]; diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 5027037..2061f41 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -218,6 +218,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.5-7": ["6.5", "6.4", "2.7", "4.5"], "T6.5-8": ["6.5", "6.4", "2.1", "3"], "T6.5-9": ["6.5", "2.1", "4", "4.5"], + "T6.5-10": ["6.5", "6.4", "2.1", "3"], "T6.6-2": ["6.6"], "T6.6-3": ["6.6", "14"], "T6.6-4": ["6.6"], From 08de4beb662eea4546624513ca685fdbf924231f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 11:00:39 +0000 Subject: [PATCH 216/226] =?UTF-8?q?sdg(phase-9):=20T6.6-4=20rename-preview?= =?UTF-8?q?=20mapping-order=20fixture=20=E2=80=94=20arm=20(a)'s=20renamed?= =?UTF-8?q?=20`core.mid`=20now=20carries=20two=20descendants,=20`core.mid.?= =?UTF-8?q?z`=20then=20`core.mid.c`=20in=20document=20order,=20whose=20`fr?= =?UTF-8?q?om`=20identities=20sort=20the=20other=20way=20(`=E2=80=A6#core.?= =?UTF-8?q?mid.c`=20<=20`=E2=80=A6#core.mid.z`),=20so=20a=20product=20list?= =?UTF-8?q?ing=20the=20mapping=20in=20document=20order=20fails=20both=20de?= =?UTF-8?q?codePreviewReport's=20strict=20`from`-byte=20order=20check=20(1?= =?UTF-8?q?2.7,=20H-3)=20and=20the=20exact-list=20mapping=20assertion;=20t?= =?UTF-8?q?he=20expected=20mapping=20is=20composed=20by=20the=20new=20`map?= =?UTF-8?q?pingInFromByteOrder`=20helper,=20which=20reads=20the=20pairs'?= =?UTF-8?q?=20document=20order=20off=20the=20staged=20bytes=20(each=20mapp?= =?UTF-8?q?ed=20ID's=20`id`=20attribute=20through=20the=20unique=20locator?= =?UTF-8?q?)=20and=20fails=20loud=20as=20a=20staging=20defect=20when=20tha?= =?UTF-8?q?t=20order=20coincides=20with=20the=20byte=20order=20=E2=80=94?= =?UTF-8?q?=20a=20re-staging=20that=20loses=20the=20discriminator=20can=20?= =?UTF-8?q?never=20pass=20silently;=20`z`=20takes=20over=20`leaf`'s=20refe?= =?UTF-8?q?rences=20(the=20two=20`d`=20entries=20and=20the=20MDX=20embeddi?= =?UTF-8?q?ng=20in=20`Core.mdx`,=20the=20external=20embedding=20in=20`Othe?= =?UTF-8?q?r.mdx`,=20the=20TS=20marker=20in=20`src/use.ts`)=20so=20every?= =?UTF-8?q?=20reference-rewrite=20assertion=20of=20the=20arm=20is=20kept,?= =?UTF-8?q?=20and=20`c`=20bears=20no=20reference,=20its=20only=20edit=20th?= =?UTF-8?q?e=20added=20`id-rewrite`,=20keeping=20the=20discriminator=20in?= =?UTF-8?q?=20the=20mapping=20alone;=20title=20and=20comments=20updated,?= =?UTF-8?q?=20traceability=20adds=2012.7=20(the=20ordering=20rule's=20sect?= =?UTF-8?q?ion).=20Against=20the=20built=20product=20the=20whole=20`sectio?= =?UTF-8?q?n-6.6`=20file=20passes=20(5/5,=20T6.6-4=20in=203.7=20s=20?= =?UTF-8?q?=E2=80=94=20the=20product=20orders=20by=20`from`=20bytes),=20`n?= =?UTF-8?q?pm=20run=20typecheck`=20and=20`npm=20run=20format:check`=20clea?= =?UTF-8?q?n,=20`npm=20run=20test:self`=20342/342=20(Task=2031)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 18 ----- test/suite/registry/section-6.6.ts | 114 +++++++++++++++++++++------- test/suite/registry/traceability.ts | 2 +- 3 files changed, 87 insertions(+), 47 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index d1b7292..95f5840 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,24 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 31 — T6.6-4: rename-preview mapping-order fixture with descendants in document order opposite to byte order - -Cites: TEST-SPEC T6.6-4 (the preview's mapping lists the renamed section's -descendants in an order the decoder checks by `from` byte order, and the -fixture must be able to discriminate a document-order product: descendants -whose document order differs from the byte order of their `from` spellings — -`a.z` before `a.c`); SPEC 6.6, 6.4. - -Now: `test/suite/registry/section-6.6.ts` ≈ lines 508–553 rename `core.mid` -with the single descendant `core.mid.leaf`, so the ordering check is vacuous. - -Do: restage with at least two descendants whose document order is `a.z` then -`a.c` (byte order of the mapping's `from` values reversed relative to document -order); keep the decoder's check and assert the mapping's order per the spec's -pinned tie-break; keep every other assertion of the test. - -Verify: `npx vitest run … test/suite/section-6.6.test.ts`. - ### Task 32 — T6.6-5: lagging-record counterpart (emission enabled after the build, no rebuild) Cites: TEST-SPEC T6.6-5 (revised: with Markdown emission enabled after the diff --git a/test/suite/registry/section-6.6.ts b/test/suite/registry/section-6.6.ts index 90a6d6e..c0e4dcf 100644 --- a/test/suite/registry/section-6.6.ts +++ b/test/suite/registry/section-6.6.ts @@ -87,7 +87,10 @@ // multi-byte characters sit before every located construct so byte // offsets diverge from code-point and UTF-16 counts — and compared // list-for-list: an extra file entry, a missing edit, a phantom class, or -// a one-byte range drift each fail. Judgment calls pinned here (H-4): an +// a one-byte range drift each fail; the rename arm's descendants `z` and +// `c` stand in document order opposite to `from`-byte order, so a mapping +// in document order fails the decoder's 12.7 order check and the exact +// list alike (TEST-SPEC T6.6-4). Judgment calls pinned here (H-4): an // `id`-attribute rewrite spans the attribute's own characters (`id="…"`, // name through closing quote — SPEC 6.6 "the `id` attribute's own // characters", the construct-spelling reading its sibling clauses use for @@ -1755,14 +1758,25 @@ export default defineConfig({ `; // --- Arm (a): rename preview — id-rewrites and the four 5.7 occurrence -// kinds across MDX and TS. `core.mid` (with descendant `core.mid.leaf`) is -// renamed to `core.hub`; affected references: two `d` entries and one MDX +// kinds across MDX and TS. `core.mid` (with descendants `core.mid.z` and +// `core.mid.c`, in that document order) is renamed to `core.hub`; affected +// references, all through `core.mid.z`: two `d` entries and one MDX // embedding in the origin file (string form), a `d` chain and an embedding // in a second MDX file (external form), and a marker plus a `text(...)` call // in a TS file. Controls that must produce NO edit: `d={"core.plain"}` (its // target keeps its identity), every unaffected `id` attribute, and the // unrelocated `./Core.xspec` import specifiers. Multi-byte text ("hölder", // "ünicode", "Δ") precedes every located construct. +// +// The mapping-order fixture (TEST-SPEC T6.6-4): the descendants stand in +// document order (`z` before `c`) opposite to the byte order of their +// `from` identities (`…#core.mid.c` < `…#core.mid.z`), so a product listing +// the mapping in document order — the order its subtree traversal yields — +// fails both decodePreviewReport's `from`-byte order check (SPEC 12.7, H-3) +// and the exact-list comparison against the expected mapping. `c` bears no +// reference (its only edit is its `id-rewrite`), keeping the discriminator +// in the mapping alone; mappingInFromByteOrder composes the expectation and +// guards that the two orders really differ. const A4_CORE = "specs/Core.mdx"; const A4_OTHER = "specs/Other.mdx"; const A4_USE = "src/use.ts"; @@ -1773,13 +1787,17 @@ const A4_CORE_SOURCE = [ '<S id="core.mid" d={"core.plain"}>', "Mid text.", "", - '<S id="core.mid.leaf">', - "Leaf text.", + '<S id="core.mid.z">', + "Z text.", + "</S>", + "", + '<S id="core.mid.c">', + "C text.", "</S>", "</S>", "", - '<S id="core.sib" d={["core.mid", "core.mid.leaf"]}>', - 'Sib embeds: {text("core.mid.leaf")}', + '<S id="core.sib" d={["core.mid", "core.mid.z"]}>', + 'Sib embeds: {text("core.mid.z")}', "</S>", "", '<S id="core.plain">', @@ -1797,7 +1815,7 @@ const A4_OTHER_SOURCE = [ '<S id="oth.dep" d={CORE.core.mid}>', "Dep text.", "", - "{text(CORE.core.mid.leaf)}", + "{text(CORE.core.mid.z)}", "</S>", "</S>", "", @@ -1807,24 +1825,65 @@ const A4_USE_SOURCE = [ 'import SPEC, { text } from "../specs/Core.xspec"', "", "export function useMid(): string {", - " SPEC.core.mid.leaf;", + " SPEC.core.mid.z;", " return text(SPEC.core.mid);", "}", "", ].join("\n"); const A4_RENAME_ARGV = ["rename", A4_CORE, "core.mid", "core.hub"] as const; +/** + * The expected mapping in `from`-byte order (SPEC 12.7), composed from the + * mapped pairs of one spec file — order of listing immaterial — with a + * staging guard: the pairs' DOCUMENT order, read off the staged bytes as + * the order of the mapped IDs' `id` attributes, must differ from the byte + * order, or the fixture could not fail a product emitting document order + * (TEST-SPEC T6.6-4) — a re-staging whose descendants happen to sort alike + * is a staging defect (harness error), never a weaker test. + */ +function mappingInFromByteOrder( + source: string, + pairs: readonly AppliedMappingPair[], + where: string, +): readonly AppliedMappingPair[] { + const documentIndex = (pair: AppliedMappingPair): number => + uniqueCharIndex( + source, + `id="${pair.from.slice(pair.from.indexOf("#") + 1)}"`, + `${where}: ${pair.from}`, + ); + const documentOrder = [...pairs].sort( + (a, b) => documentIndex(a) - documentIndex(b), + ); + const byteOrder = [...pairs].sort((a, b) => + Buffer.compare(Buffer.from(a.from, "utf8"), Buffer.from(b.from, "utf8")), + ); + if (byteOrder.every((pair, index) => pair === documentOrder[index])) { + throw new Error( + `T6.6-4 staging (${where}): the mapping's document order coincides ` + + `with its \`from\`-byte order, so the fixture cannot discriminate a ` + + `product emitting document order (TEST-SPEC T6.6-4)`, + ); + } + return byteOrder; +} + function armAPlan(): ExpectedPreviewPlan { const core = A4_CORE_SOURCE; - const dArray = 'd={["core.mid", "core.mid.leaf"]}'; + const dArray = 'd={["core.mid", "core.mid.z"]}'; return { - mapping: [ - { from: "specs/Core.mdx#core.mid", to: "specs/Core.mdx#core.hub" }, - { - from: "specs/Core.mdx#core.mid.leaf", - to: "specs/Core.mdx#core.hub.leaf", - }, - ], + // The complete mapping — the renamed ID and every descendant — in + // `from`-byte order: `…#core.mid.c` before `…#core.mid.z`, the reverse + // of the document order the pairs are listed in (SPEC 12.7, 6.4). + mapping: mappingInFromByteOrder( + core, + [ + { from: "specs/Core.mdx#core.mid", to: "specs/Core.mdx#core.hub" }, + { from: "specs/Core.mdx#core.mid.z", to: "specs/Core.mdx#core.hub.z" }, + { from: "specs/Core.mdx#core.mid.c", to: "specs/Core.mdx#core.hub.c" }, + ], + "a: mapping", + ), files: [ { file: A4_CORE, @@ -1837,7 +1896,11 @@ function armAPlan(): ExpectedPreviewPlan { }, { class: "id-rewrite", - range: uniqueSpan(core, 'id="core.mid.leaf"', "a: leaf id"), + range: uniqueSpan(core, 'id="core.mid.z"', "a: z id"), + }, + { + class: "id-rewrite", + range: uniqueSpan(core, 'id="core.mid.c"', "a: c id"), }, // Each `d` array entry is its own occurrence spanning that one // reference's own expression (SPEC 5.7) — located through the @@ -1848,18 +1911,13 @@ function armAPlan(): ExpectedPreviewPlan { }, { class: "reference-rewrite", - range: spanWithin( - core, - dArray, - '"core.mid.leaf"', - "a: d core.mid.leaf", - ), + range: spanWithin(core, dArray, '"core.mid.z"', "a: d core.mid.z"), }, // An MDX embedding spans the entire `{text(...)}` container, // opening brace through closing brace (SPEC 5.7). { class: "reference-rewrite", - range: uniqueSpan(core, '{text("core.mid.leaf")}', "a: embedding"), + range: uniqueSpan(core, '{text("core.mid.z")}', "a: embedding"), }, ]), }, @@ -1879,7 +1937,7 @@ function armAPlan(): ExpectedPreviewPlan { class: "reference-rewrite", range: uniqueSpan( A4_OTHER_SOURCE, - "{text(CORE.core.mid.leaf)}", + "{text(CORE.core.mid.z)}", "a: external embedding", ), }, @@ -1892,7 +1950,7 @@ function armAPlan(): ExpectedPreviewPlan { // exclusive of the statement terminator (SPEC 5.7). { class: "reference-rewrite", - range: uniqueSpan(A4_USE_SOURCE, "SPEC.core.mid.leaf", "a: marker"), + range: uniqueSpan(A4_USE_SOURCE, "SPEC.core.mid.z", "a: marker"), }, // A TS `text(...)` occurrence spans the entire call expression, // callee through closing parenthesis (SPEC 5.7). @@ -2340,7 +2398,7 @@ function armEPlan(): ExpectedPreviewPlan { const T6_6_4 = defineProductTest({ id: "T6.6-4", title: - "report content: byte-precise fixtures asserted against precomputed pre-operation offsets, form-exact per 12.7 — (a) a rename preview reports the complete identity mapping (the renamed ID and every descendant) and, per rewritten file, `id-rewrite` edits spanning each rewritten `id` attribute's own characters and `reference-rewrite` edits spanning each affected occurrence's span (5.7) across MDX and TS; (b) a section-move preview into an existing target file reports the `origin-deletion` as one contiguous range (the construct's own characters extended over the adjunct-dropped leftover whitespace and line terminator), the re-identification's `id-rewrite` edits and the moved text's reference rewrites nested inside that range, `target-insertion` zero-length at the insertion offset, `target-parent-rewrite` spanning the self-closing target parent's tag, `import-addition` zero-length at the exact offset the real operation then uses (byte-asserted by running the operation on the preview-pinned state), and `import-removal` spanning the declaration plus its adjunct drops; (c) a file-form move preview reports `import-specifier-rewrite` edits spanning the specifier literals and `file-relocation` spanning the entire moved file under its pre-operation path; (d) a created-target section-move preview reports exactly one `file-creation` edit at the new file's start — the insertion and import additions there subsumed — with the moved text's own rewrites inside the origin deletion; every edit class-plus-range only, every class one of the ten 12.7 names, and the full 12.7 edit comparator asserted over whatever edits are emitted, staged (e) where an import addition can coincide with the end-of-file target insertion (SPEC 6.6, 12.7, 6.4, 6.5, 5.7, 1.7, 2.1, 3; H-3, H-4)", + "report content: byte-precise fixtures asserted against precomputed pre-operation offsets, form-exact per 12.7 — (a) a rename preview reports the complete identity mapping (the renamed ID and every descendant) ordered by `from` bytes — the descendants `core.mid.z` and `core.mid.c` standing in document order opposite to byte order, so a product emitting document order fails — and, per rewritten file, `id-rewrite` edits spanning each rewritten `id` attribute's own characters and `reference-rewrite` edits spanning each affected occurrence's span (5.7) across MDX and TS; (b) a section-move preview into an existing target file reports the `origin-deletion` as one contiguous range (the construct's own characters extended over the adjunct-dropped leftover whitespace and line terminator), the re-identification's `id-rewrite` edits and the moved text's reference rewrites nested inside that range, `target-insertion` zero-length at the insertion offset, `target-parent-rewrite` spanning the self-closing target parent's tag, `import-addition` zero-length at the exact offset the real operation then uses (byte-asserted by running the operation on the preview-pinned state), and `import-removal` spanning the declaration plus its adjunct drops; (c) a file-form move preview reports `import-specifier-rewrite` edits spanning the specifier literals and `file-relocation` spanning the entire moved file under its pre-operation path; (d) a created-target section-move preview reports exactly one `file-creation` edit at the new file's start — the insertion and import additions there subsumed — with the moved text's own rewrites inside the origin deletion; every edit class-plus-range only, every class one of the ten 12.7 names, and the full 12.7 edit comparator asserted over whatever edits are emitted, staged (e) where an import addition can coincide with the end-of-file target insertion (SPEC 6.6, 12.7, 6.4, 6.5, 5.7, 1.7, 2.1, 3; H-3, H-4)", run: async (product) => { // --- Arm (a): rename preview across MDX and TS --- await withWorkspace( diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 2061f41..9871547 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -221,7 +221,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.5-10": ["6.5", "6.4", "2.1", "3"], "T6.6-2": ["6.6"], "T6.6-3": ["6.6", "14"], - "T6.6-4": ["6.6"], + "T6.6-4": ["6.6", "12.7"], "T6.6-5": ["6.6"], "T6.6-6": ["6.6", "14"], "T6.7-1": ["6.7"], From c13ede197f2d906633c9d05c2a8dbd32950db472 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 11:09:56 +0000 Subject: [PATCH 217/226] =?UTF-8?q?sdg(phase-9):=20T6.6-5=20lagging-record?= =?UTF-8?q?=20counterpart=20=E2=80=94=20a=20fourth=20arm=20over=20arm=20(c?= =?UTF-8?q?)'s=20three=20sources=20built=20under=20`SPECS=5FONLY=5FCONFIG`?= =?UTF-8?q?=20(no=20emission;=20the=20premise=20build's=20writes=20observe?= =?UTF-8?q?d=20as=20modules=20and=20companions=20alone,=20any=20Markdown?= =?UTF-8?q?=20write=20failing=20diagnosed=20as=20unattributable),=20then?= =?UTF-8?q?=20`xspec.config.ts`=20rewritten=20to=20the=20Markdown-emitting?= =?UTF-8?q?=20configuration=20without=20rebuilding,=20so=20the=20record=20?= =?UTF-8?q?lags=20the=20configuration,=20and=20the=20same=20file-form=20`m?= =?UTF-8?q?ove=20specs/Mv.mdx=20specs/sub/Mv2.mdx=20--preview=20--json`=20?= =?UTF-8?q?asserted=20through=20the=20existing=20`assertDeltaContent`:=20`?= =?UTF-8?q?generated`=20exactly=20the=20destination's=20module=20and=20com?= =?UTF-8?q?panions=20(the=20origin's=20observed=20suffix=20set=20transpose?= =?UTF-8?q?d=20under=20the=20destination=20name,=20SPEC=2013.1)=20with=20t?= =?UTF-8?q?he=20destination's=20Markdown=20destination=20together=20with?= =?UTF-8?q?=20every=20OTHER=20discovered=20source's=20Markdown=20emit=20de?= =?UTF-8?q?stination=20(`specs/Pal.md`,=20`specs/User.md`,=2013.2/7.3=20?= =?UTF-8?q?=E2=80=94=20the=20paths=20the=20current=20configuration=20gener?= =?UTF-8?q?ates=20that=20the=20stale=20record=20lacks;=20the=20staying=20s?= =?UTF-8?q?ources'=20recorded=20modules=20regenerate=20in=20place,=20in=20?= =?UTF-8?q?neither=20direction,=20the=20origin's=20never-recorded=20Markdo?= =?UTF-8?q?wn=20in=20neither),=20and=20`removed`=20exactly=20the=20recorde?= =?UTF-8?q?d=20pre-move=20module=20and=20companions,=20no=20Markdown=20amo?= =?UTF-8?q?ng=20them=20(13.3);=20a=20whole-root=20compare=20around=20the?= =?UTF-8?q?=20invocation=20pins=20that=20the=20preview=20writes=20nothing?= =?UTF-8?q?=20and=20the=20record=20stays=20lagging=20(6.6).=20Title=20and?= =?UTF-8?q?=20module-header=20judgment=20calls=20extended;=20traceability?= =?UTF-8?q?=20adds=2013.2=20and=2013.3.=20Against=20the=20built=20product?= =?UTF-8?q?=20the=20arm=20passes=20(a=20hand=20replay=20in=20a=20scratch?= =?UTF-8?q?=20workspace=20shows=20the=20product's=20delta=20equal=20to=20t?= =?UTF-8?q?he=20composed=20sets=20and=20no=20Markdown=20written),=20the=20?= =?UTF-8?q?whole=20`section-6.6`=20file=205/5=20(T6.6-5=20in=202.5=20s),?= =?UTF-8?q?=20`npm=20run=20typecheck`=20and=20`npm=20run=20format:check`?= =?UTF-8?q?=20clean,=20`npm=20run=20test:self`=20342/342=20(Task=2032)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 18 ------ test/suite/registry/section-6.6.ts | 93 ++++++++++++++++++++++++++++- test/suite/registry/traceability.ts | 2 +- 3 files changed, 92 insertions(+), 21 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 95f5840..0fd1f18 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,24 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 32 — T6.6-5: lagging-record counterpart (emission enabled after the build, no rebuild) - -Cites: TEST-SPEC T6.6-5 (revised: with Markdown emission enabled after the -last `build` and no rebuild, the move preview's `generated` set is exactly the -destination's module + companions + Markdown emit destination plus every other -spec source's Markdown emit destination, and `removed` is exactly the recorded -pre-move module and companions); SPEC 6.6, 13.1, 13.2, 13.4. - -Now: `test/suite/registry/section-6.6.ts` ≈ lines 2673–2675 cover the -current-record case only. - -Do: add the arm: build, then enable `markdown.emit` in the configuration -without rebuilding, take `move --preview`, decode `generated`/`removed` -(existing preview adapter) and assert the exact sets above (paths composed -from SPEC 13.1/13.2's naming rules), nothing written. - -Verify: `npx vitest run … test/suite/section-6.6.test.ts`. - ### Task 33 — T7-1: `--config <nonexistent file>` is configuration error 14.14 (exit 2, concerned path per T12.7-3) Cites: TEST-SPEC T7-1 (an explicit `--config` naming no file is reported as diff --git a/test/suite/registry/section-6.6.ts b/test/suite/registry/section-6.6.ts index c0e4dcf..698af9e 100644 --- a/test/suite/registry/section-6.6.ts +++ b/test/suite/registry/section-6.6.ts @@ -148,7 +148,20 @@ // 14.23 unavailability of T6.6-6, which covers recorded state that exists // but cannot be read — and the preview never refreshes it (whole-root // compare around the invocation; graph data asserted still absent -// afterward). +// afterward). The lagging-record arm builds WITHOUT emission, then enables +// `markdown.emit` in the configuration and takes the same move preview +// with no rebuild: the record holds modules and companions alone — a +// lagging record alone is never staleness (SPEC 13.3), and a preview +// never refreshes it — so `generated` is exactly the destination's +// module, companions, and Markdown together with every OTHER discovered +// source's Markdown emit destination (the paths the current configuration +// generates that the stale record lacks; the staying sources' recorded +// modules and companions regenerate in place, in neither direction; the +// origin's Markdown, never recorded and not generated post-move, in +// neither) and `removed` exactly the recorded pre-move module and +// companions, no Markdown among them — a product composing either +// direction from the configuration alone, or from presence, fails a set +// equality; whole-root compare around the invocation. // - T6.6-6 stages the unreadable record through the H-3 corrupt-record // adapter (record-staging.ts): shape-blind garbage — files present, their // bytes readable as no record, not even valid UTF-8 — over every @@ -2793,7 +2806,7 @@ function assertDeltaContent( const T6_6_5 = defineProductTest({ id: "T6.6-5", title: - "delta: after a build, a file-form move preview reports the derived-file delta both directions — under `generated` the destination's module, companion, and (emission enabled) Markdown paths, nothing being recorded there, and under `removed` the recorded pre-move module, companions, and Markdown the operation would leave no longer generated; a rename preview on the same workspace reports [] in both directions (regeneration rewrites recorded paths in place); the created-target move of T6.6-4(d) reports the new file's derived paths under `generated`; record-based, not presence-based: with graph data deleted (T13.3-2's operational definition) the same move preview's `generated` is the full post-move regeneration set and its `removed` [] — nothing being recorded, presence on disk deciding neither direction — and the preview still writes nothing: no refresh, graph data still absent afterward (SPEC 6.6, 12.7, 13.1, 13.2, 13.3, 7.3, 12.1; H-3, H-4)", + "delta: after a build, a file-form move preview reports the derived-file delta both directions — under `generated` the destination's module, companion, and (emission enabled) Markdown paths, nothing being recorded there, and under `removed` the recorded pre-move module, companions, and Markdown the operation would leave no longer generated; a rename preview on the same workspace reports [] in both directions (regeneration rewrites recorded paths in place); the created-target move of T6.6-4(d) reports the new file's derived paths under `generated`; record-based, not presence-based: with graph data deleted (T13.3-2's operational definition) the same move preview's `generated` is the full post-move regeneration set and its `removed` [] — nothing being recorded, presence on disk deciding neither direction — and the preview still writes nothing: no refresh, graph data still absent afterward; lagging-record counterpart: with emission enabled in the configuration after the build and no rebuild, the same preview's `generated` is exactly the destination's module, companions, and Markdown together with every other discovered spec source's Markdown emit destination — the paths the current configuration generates that the stale record lacks — and its `removed` exactly the recorded pre-move module and companions, the preview writing nothing and the record left lagging (SPEC 6.6, 12.7, 13.1, 13.2, 13.3, 7.3, 12.1; H-3, H-4)", run: async (product) => { // --- File-form move, rename, and the record-deleted arm: one // Markdown-emitting workspace (arm (c)'s sources) --- @@ -2974,6 +2987,82 @@ const T6_6_5 = defineProductTest({ ); }, ); + + // --- Lagging-record counterpart: arm (c)'s sources built WITHOUT + // emission, then `markdown.emit` enabled in the configuration with no + // rebuild, and the same file-form move previewed --- + await withWorkspace( + SPECS_ONLY_CONFIG, + { + [C4_MV]: C4_MV_SOURCE, + [C4_PAL]: C4_PAL_SOURCE, + [C4_USER]: C4_USER_SOURCE, + }, + async (workspace) => { + const context = "T6.6-5 file-form move (record lagging)"; + const before = await snapshotDirectory(workspace.root); + await buildOk( + product, + workspace, + `${context}: staging premise \`build\` with emission disabled — ` + + `the record then holds modules and companions alone (SPEC ` + + `12.1, 13.3)`, + ); + const after = await snapshotDirectory(workspace.root); + assertGraphDataPresent( + after, + `${context}: staging premise — the record the delta consults`, + ); + const observed = observeDerivedWrites( + addedFiles(before, after), + [C4_MV, C4_PAL, C4_USER], + false, + `${context} staging observation`, + ); + const mv = observed.get(C4_MV)!; + + // Enable emission in the configuration, no rebuild: the record now + // lags the configuration — no Markdown path recorded — and a + // lagging record alone is never staleness (SPEC 13.3); the preview + // plans under the current configuration and consults the record as + // it stands (6.6). + await workspace.file("xspec.config.ts", SPECS_MD_CONFIG); + + // `generated`: the paths the current configuration generates that + // the stale record lacks — the destination's module, companions, + // and Markdown (nothing recorded there) together with every OTHER + // discovered source's Markdown emit destination (unrecorded, the + // record predating emission). The staying sources' recorded modules + // and companions regenerate in place, in neither direction; the + // origin's Markdown — never recorded, not generated post-move — is + // in neither direction either. + const generated = byteSortedPaths([ + ...transposeModuleCompanions(mv, C4_MV, F5_DEST), + markdownDestination(F5_DEST), + markdownDestination(C4_PAL), + markdownDestination(C4_USER), + ]); + // `removed`: exactly the recorded pre-move module and companions — + // the premise build's observed writes for the origin, no Markdown + // among them (SPEC 13.3). + const removed = byteSortedPaths(derivedPathsOf(mv)); + + await assertLeavesUnchanged( + workspace.root, + async () => { + const report = await runPreviewJson( + product, + workspace, + C4_MOVE_ARGV, + context, + ); + assertDeltaContent(report, { generated, removed }, context); + }, + `${context}: the preview writes nothing — the record stays ` + + `lagging, never refreshed by a preview (SPEC 6.6, 13.3)`, + ); + }, + ); }, }); diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 9871547..3e99b37 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -222,7 +222,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.6-2": ["6.6"], "T6.6-3": ["6.6", "14"], "T6.6-4": ["6.6", "12.7"], - "T6.6-5": ["6.6"], + "T6.6-5": ["6.6", "13.2", "13.3"], "T6.6-6": ["6.6", "14"], "T6.7-1": ["6.7"], "T7-1": ["7", "14"], From e476c9672094d2e4adaffcca395030146b7289d6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 11:18:55 +0000 Subject: [PATCH 218/226] =?UTF-8?q?sdg(phase-9):=20T7-1=20`--config`=20nam?= =?UTF-8?q?ing=20a=20nonexistent=20file=20is=20missing=20configuration=20(?= =?UTF-8?q?14.14)=20=E2=80=94=20a=20fourth=20arm=20on=20the=20location=20f?= =?UTF-8?q?ixture:=20`build=20--config=20alt/missing.config.ts=20--json`?= =?UTF-8?q?=20run=20from=20the=20workspace=20root,=20whose=20own=20valid?= =?UTF-8?q?=20`xspec.config.ts`=20the=20upward=20search=20would=20find,=20?= =?UTF-8?q?naming=20an=20absent=20file=20beside=20`alt/`'s=20valid=20confi?= =?UTF-8?q?guration,=20so=20a=20product=20falling=20back=20to=20the=20sear?= =?UTF-8?q?ch=20or=20to=20a=20configuration=20near=20the=20named=20path=20?= =?UTF-8?q?builds=20and=20exits=200=20while=20the=20arm=20demands=20exit?= =?UTF-8?q?=202=20through=20`expectConfigurationError`=20(the=2012.7=20err?= =?UTF-8?q?or=20document=20as=20the=20entire=20stdout,=20a=20`configuratio?= =?UTF-8?q?n`-naming=20stderr=20diagnostic),=20the=20decoded=20finding=20t?= =?UTF-8?q?hen=20asserted=20exactly=20`{code:=20"configuration-error",=20p?= =?UTF-8?q?ath:=20"alt/missing.config.ts",=20locations:=20[]}`=20=E2=80=94?= =?UTF-8?q?=20the=20concerned=20path=20being=20the=20path=20`--config`=20n?= =?UTF-8?q?ames=20in=2011.6's=20anchoring=20form=20relative=20to=20the=20i?= =?UTF-8?q?nvocation=20directory,=20spelled=20as=20given=20(zero=20ascent,?= =?UTF-8?q?=20two=20descending=20segments,=20no=20`.`=20segment;=20the=20s?= =?UTF-8?q?ibling-directory=20ascent=20spelling=20stays=20T12.7-3's=20arm,?= =?UTF-8?q?=20Task=2043),=20never=20a=20plain=20usage=20error's=20null=20c?= =?UTF-8?q?ode=20and=20path=20and=20never=20`.`=20(SPEC=2014,=2012.7)=20?= =?UTF-8?q?=E2=80=94=20and=20a=20whole-root=20snapshot=20compare=20around?= =?UTF-8?q?=20the=20invocation=20pinning=20that=20a=20build=20failing=20at?= =?UTF-8?q?=20configuration=20load=20writes=20nothing=20(12.1);=20title=20?= =?UTF-8?q?and=20module-header=20notes=20extended,=20traceability=20unchan?= =?UTF-8?q?ged=20(`7`,=20`14`=20already=20cover=20the=20asserted=20passage?= =?UTF-8?q?s,=20as=20for=20T7-2/T7-3).=20Against=20the=20built=20product?= =?UTF-8?q?=20the=20arm=20passes=20(hand=20replay:=20exit=202,=20that=20fi?= =?UTF-8?q?nding,=20an=20empty=20tree=20delta),=20`section-7-basics`=203/3?= =?UTF-8?q?=20(T7-1=20in=200.9=20s),=20`npm=20run=20typecheck`=20and=20`np?= =?UTF-8?q?m=20run=20format`=20clean,=20`npm=20run=20test:self`=20342/342?= =?UTF-8?q?=20(Task=2033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 17 ------ test/suite/registry/section-7-basics.ts | 79 ++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 0fd1f18..7cc67aa 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,23 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 33 — T7-1: `--config <nonexistent file>` is configuration error 14.14 (exit 2, concerned path per T12.7-3) - -Cites: TEST-SPEC T7-1 (an explicit `--config` naming no file is reported as -14.14 — exit 2 with the 12.7 error document naming the concerned path as -T12.7-3 spells it — never a plain usage error); SPEC 7, 12.0, 12.7, 14.14. - -Now: `test/suite/registry/section-7-basics.ts` ≈ line 228 has only the -override run. - -Do: add the arm: `build --config <missing path>` → exit 2, error document -decoded through the existing error adapter with the 14.14 stable code and the -concerned path spelled as given (relative to the invocation directory, per -T12.7-3 — see Task 43 for the sibling-directory ascent form, which this arm -need not repeat); nothing written. - -Verify: `npx vitest run … test/suite/section-7-basics.test.ts`. - ### Task 34 — T7-3: configuration value-shape arms → 14.14 Cites: TEST-SPEC T7-3 (revised: a spec or code group whose value is a single diff --git a/test/suite/registry/section-7-basics.ts b/test/suite/registry/section-7-basics.ts index e0322cb..a9e4686 100644 --- a/test/suite/registry/section-7-basics.ts +++ b/test/suite/registry/section-7-basics.ts @@ -33,6 +33,17 @@ // temporary directory (H-1) whose filesystem ancestors (the OS temp // directory and its parents) hold no `xspec.config.ts`, so the upward // search exhausts without a hit. +// - T7-1 `--config` naming a nonexistent file: run from the location +// fixture's root, whose own `xspec.config.ts` the upward search would +// find, naming `alt/missing.config.ts` — absent, beside `alt/`'s valid +// configuration — so a product falling back to the search, or to a +// configuration near the named path, builds and exits 0, and a product +// classing the failed `--config` as a plain usage error reports `code` +// and `path` null (T12.7-3). The concerned path is asserted exactly as +// the argument spells it: zero ascent segments, two descending ones, no +// `.` segment — already 11.6's canonical form (the sibling-directory +// ascent spelling is T12.7-3's arm); a whole-root snapshot compare around +// the invocation pins that nothing is written (SPEC 12.1). // - T7-2 single-deviation staging: every invalid fixture is the valid // canonical configuration with exactly one deviation, so the refusal is // attributable to the arm's malformation and nothing else. @@ -77,6 +88,10 @@ import { } from "../../helpers/assertions.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { + assertSnapshotsEqual, + snapshotDirectory, +} from "../../helpers/snapshot.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { runProduct, summarizeResult } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; @@ -177,6 +192,11 @@ const LOCATION_FILES: Readonly<Record<string, string>> = { const SEARCH_CWD = "nested/inner"; const OVERRIDE_CWD = "nested"; const OVERRIDE_CONFIG_ARG = "../alt/xspec.config.ts"; +// The nonexistent --config path, run from the workspace root: two +// descending segments, no `.` segment — the argument's own spelling is +// already the canonical anchoring form (SPEC 11.6), so the concerned path is +// asserted exactly as given (SPEC 14; T12.7-3). +const MISSING_CONFIG_ARG = "alt/missing.config.ts"; const T7_1 = defineProductTest({ id: "T7-1", @@ -184,7 +204,9 @@ const T7_1 = defineProductTest({ "configuration location: upward search from a nested working " + "directory; --config, resolved against the working directory, " + "overrides the search; no configuration reachable is a configuration " + - "error (SPEC 7, 12.0, 14.14)", + "error — by a failed upward search, and by --config naming a " + + "nonexistent file, never a plain usage error (SPEC 7, 12.0, 12.7, " + + "14.14)", run: async (product) => { await withWorkspace( { files: LOCATION_FILES, dirs: [SEARCH_CWD] }, @@ -246,6 +268,61 @@ const T7_1 = defineProductTest({ `workspace root — the listing carries alt/specs/B.mdx as ` + `specs/B.mdx and nothing of the root project (SPEC 7, 12.0)`, ); + + // --config naming a nonexistent file: missing configuration, a + // configuration error (SPEC 14.14) — never a plain usage error and + // never a fallback. Run from the root, whose own xspec.config.ts the + // upward search would find, naming alt/missing.config.ts — absent, + // beside alt/'s valid configuration — so a product falling back to + // the search, or to a configuration near the named path, builds and + // exits 0 (module header). The error document's finding carries the + // stable code, locations [] (an unlocated condition, SPEC 14), and + // the concerned path: the path --config names, in the anchoring + // form of 11.6 — spelled here exactly as given (SPEC 14, 12.7; the + // sibling-directory ascent spelling is T12.7-3's arm). A build + // failing at configuration load modifies nothing (SPEC 12.1): + // whole-root compare around the run. + const missingLabel = + `T7-1 \`build --config ${MISSING_CONFIG_ARG} --json\` run from ` + + `the workspace root (--config naming a nonexistent file)`; + const beforeMissing = await snapshotDirectory(workspace.root); + const missingRun = await expectConfigurationError( + product, + workspace, + ["build", "--config", MISSING_CONFIG_ARG], + missingLabel, + ); + const missingFinding = expectErrorDocument(missingRun, missingLabel); + assertSameJson( + { + code: missingFinding.code, + path: missingFinding.path, + locations: missingFinding.locations.map( + (location) => location.file, + ), + }, + { + code: "configuration-error", + path: MISSING_CONFIG_ARG, + locations: [], + }, + `${missingLabel}: --config naming a nonexistent file is missing ` + + `configuration (SPEC 14.14) — the error document's one finding ` + + `carries the stable code "configuration-error", locations [] ` + + `(an unlocated condition), and as its concerned path the path ` + + `--config names, in the anchoring form of 11.6 relative to the ` + + `invocation working directory (SPEC 14, 12.7; T12.7-3) — never ` + + `a plain usage error's null code and path, and never "." (the ` + + `failed-upward-search spelling, reserved for no --config given)`, + ); + assertSnapshotsEqual( + beforeMissing, + await snapshotDirectory(workspace.root), + `${missingLabel}: a build failing at configuration load modifies ` + + `nothing (SPEC 12.1, 12.0) — the configurations at the root and ` + + `beside the named path are never consulted, so no derived file ` + + `or graph data appears anywhere under the root`, + ); }, ); From 215ccb27c41f158d065ae34867a05d8f845d5fd1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 11:27:40 +0000 Subject: [PATCH 219/226] =?UTF-8?q?sdg(phase-9):=20T7-3=20value-shape=20ar?= =?UTF-8?q?ms=20(14.14)=20=E2=80=94=20seven=20fixtures=20in=20a=20new=20`V?= =?UTF-8?q?ALUE=5FSHAPE=5FVIOLATIONS`=20list,=20TEST-SPEC=20T7-3's=20"one?= =?UTF-8?q?=20arm=20each"=20(the=20plan's=20"six=20configurations"=20under?= =?UTF-8?q?counts=20it):=20a=20spec=20group=20and=20a=20code=20group=20val?= =?UTF-8?q?ued=20by=20a=20single=20string,=20a=20spec=20group's=20glob=20l?= =?UTF-8?q?ist=20`[true]`,=20`coverage:=20{}`,=20`policy:=20{}`,=20and=20`?= =?UTF-8?q?specs`=20and=20`code`=20given=20as=20lists=20=E2=80=94=20each?= =?UTF-8?q?=20SPECS=5FONLY=5FCONFIG=20with=20exactly=20one=20shape=20devia?= =?UTF-8?q?tion=20the=20declarative=20form=20of=207=20admits=20(a=20static?= =?UTF-8?q?=20string,=20the=20boolean=20literal,=20an=20object=20or=20arra?= =?UTF-8?q?y=20literal=20=E2=80=94=20never=20a=20T7-2=20form=20error),=20s?= =?UTF-8?q?o=20the=20refusal=20is=2014.14's=20invalid=20group=20shape=20an?= =?UTF-8?q?d=20attributable=20to=20that=20deviation=20alone;=20the=20`[tru?= =?UTF-8?q?e]`=20fixture=20matches=20no=20staged=20file,=20but=20a=20produ?= =?UTF-8?q?ct=20tolerating=20it=20discovers=20no=20source=20and=20still=20?= =?UTF-8?q?builds=20(a=20group=20matching=20no=20files=20is=20valid,=20SPE?= =?UTF-8?q?C=207),=20so=20exit=200=20keeps=20discriminating.=20`expectConf?= =?UTF-8?q?igRefused`,=20serving=20every=20T7-2/T7-3=20refusal=20arm,=20is?= =?UTF-8?q?=20strengthened=20beyond=20the=20shared=2014.14=20contract:=20t?= =?UTF-8?q?he=20finding=20is=20asserted=20exactly=20`{code:=20"configurati?= =?UTF-8?q?on-error",=20path:=20"xspec.config.ts",=20locations:=20[]}`=20?= =?UTF-8?q?=E2=80=94=20the=20file=20the=20upward=20search=20found,=20in=20?= =?UTF-8?q?11.6's=20anchoring=20form=20relative=20to=20the=20invocation=20?= =?UTF-8?q?working=20directory,=20the=20workspace=20root,=20so=20the=20bar?= =?UTF-8?q?e=20name=20(SPEC=2014,=2012.7),=20and=20no=20source=20range=20f?= =?UTF-8?q?or=20a=20configuration=20condition=20(14)=20=E2=80=94=20and=20a?= =?UTF-8?q?=20whole-root=20snapshot=20compare=20around=20the=20invocation?= =?UTF-8?q?=20pins=20that=20a=20build=20failing=20at=20configuration=20loa?= =?UTF-8?q?d=20writes=20nothing=20(12.1).=20Title=20and=20module-header=20?= =?UTF-8?q?notes=20extended;=20traceability=20adds=207.1=20and=207.2=20(th?= =?UTF-8?q?e=20"each=20a=20list=20of=20globs"=20clauses=20the=20arms=20ass?= =?UTF-8?q?ert).=20Against=20the=20built=20product=20all=20three=20tests?= =?UTF-8?q?=20pass=20(T7-3=20in=206.7=20s=20over=20its=2018=20refusal=20ar?= =?UTF-8?q?ms=20plus=20the=20omission/empty=20arms;=20a=20hand=20replay=20?= =?UTF-8?q?of=20the=20seven=20shapes=20shows=20exit=202,=20the=20pinned=20?= =?UTF-8?q?finding,=20and=20an=20unchanged=20tree,=20while=20a=20valid=20c?= =?UTF-8?q?ontrol=20builds=20and=20writes),=20`npm=20run=20typecheck`=20an?= =?UTF-8?q?d=20`npm=20run=20format`=20clean,=20`npm=20run=20test:self`=203?= =?UTF-8?q?42/342;=20T7-1=E2=80=A6T7-3=20lie=20in=20no=20certification=20s?= =?UTF-8?q?cope,=20so=20C-1=20is=20untouched=20(Task=2034)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 15 -- test/suite/registry/section-7-basics.ts | 180 +++++++++++++++++++++++- test/suite/registry/traceability.ts | 2 +- 3 files changed, 174 insertions(+), 23 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 7cc67aa..c1d4de2 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,21 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 34 — T7-3: configuration value-shape arms → 14.14 - -Cites: TEST-SPEC T7-3 (revised: a spec or code group whose value is a single -string; a glob list holding `true`; `coverage: {}` / `policy: {}`; `specs` or -`code` given as lists — each a 14.14 configuration error, exit 2); SPEC 7, -14.14. - -Now: `test/suite/registry/section-7-basics.ts` ≈ lines 896–901 lack these -shapes. - -Do: add one arm per shape (six configurations), each `build` → exit 2 with the -14.14 error document naming the configuration file; nothing written. - -Verify: `npx vitest run … test/suite/section-7-basics.test.ts`. - ### Task 35 — T7.4-1: `edgeKinds` non-subset and non-string `targetTags` element → 14.14 Cites: TEST-SPEC T7.4-1 (revised: `edgeKinds` values `"contains"`, `"depend"`, diff --git a/test/suite/registry/section-7-basics.ts b/test/suite/registry/section-7-basics.ts index a9e4686..d9404c8 100644 --- a/test/suite/registry/section-7-basics.ts +++ b/test/suite/registry/section-7-basics.ts @@ -18,7 +18,10 @@ // configuration load as a usage error (exit 2), before all source analysis. // `specs` is required; `code`, `markdown`, `coverage`, and `policy` are // optional with defined omission semantics; empty `coverage`/`policy` lists -// equal omission; unknown keys anywhere in the argument are 14.14. +// equal omission; unknown keys anywhere in the argument are 14.14, as is a +// value of the wrong shape — 14.14's "otherwise invalid group shape" (7.1, +// 7.2, 7.4, 7.5: `specs` and `code` are maps of named groups, each a list +// of glob strings; `coverage` and `policy` are lists). // // Conservative operationalizations (noted per H-3/H-4): // - 14.14 contract: `expectConfigurationError` (shared, ./support.ts) — run @@ -72,6 +75,23 @@ // emit the error document) and a non-empty stderr diagnostic (12.0: usage // error messages are standard-error content). This usage error is not a // 14.14, so no /config/i duty applies. +// - T7-3 value shapes: seven fixtures — TEST-SPEC's "one arm each" over a +// spec group and a code group valued by a single string, a glob list +// holding `true`, `coverage` and `policy` given as `{}`, and `specs` and +// `code` given as lists — each SPECS_ONLY_CONFIG with one shape deviation +// the declarative form of 7 admits, so the refusal is 14.14's +// non-conformance (an invalid group shape), never a form error (T7-2). +// The `[true]` fixture matches no staged file: a product tolerating it +// (dropping or stringifying the element) discovers no source and builds +// anyway — a group matching no files is valid (7) — so exit 0 still +// discriminates it. +// - every `expectConfigRefused` arm (T7-2, T7-3): the finding's concerned +// path is exactly `xspec.config.ts` — the file the upward search found, +// in 11.6's anchoring form relative to the invocation working directory, +// the workspace root (SPEC 14, 12.7) — its locations [] (a configuration +// condition carries the file it concerns, no source range; 14), and a +// whole-root snapshot compare around the invocation pins that nothing is +// written (12.1). import * as fsp from "node:fs/promises"; import type { GraphEdge } from "../../helpers/adapters/index.js"; @@ -143,10 +163,19 @@ async function withWorkspace<T>( /** * Stage a workspace whose only defect is the given configuration text and * assert `build --json` refuses it per 14.14. The staged source file is - * valid and matched by every fixture's `specs/**\/*.mdx` glob, so a product - * that wrongly accepts the configuration proceeds to a successful build - * (exit 0) and fails the exit-code assertion — never exits 2 for a - * side reason. + * valid and matched by every fixture's `specs/**\/*.mdx` glob (or, where + * the deviation replaces that glob, by nothing — a group matching no files + * is valid, SPEC 7), so a product that wrongly accepts the configuration + * proceeds to a successful build (exit 0) and fails the exit-code + * assertion — never exits 2 for a side reason. Beyond the shared 14.14 + * contract (`expectConfigurationError`), the finding is pinned to the + * configuration file: its concerned path is exactly `xspec.config.ts` — + * the file the upward search found, in the anchoring form of 11.6 + * relative to the invocation working directory, here the workspace root, + * so the bare name (SPEC 14, 12.7) — and its locations are [] (a + * configuration condition carries the file it concerns, no source range; + * SPEC 14); a whole-root snapshot compare around the invocation pins that + * a build failing at configuration load writes nothing (SPEC 12.1). */ async function expectConfigRefused( product: ProductBinding, @@ -161,7 +190,40 @@ async function expectConfigRefused( }, }, async (workspace) => { - await expectConfigurationError(product, workspace, ["build"], context); + const before = await snapshotDirectory(workspace.root); + const result = await expectConfigurationError( + product, + workspace, + ["build"], + context, + ); + const finding = expectErrorDocument(result, context); + assertSameJson( + { + code: finding.code, + path: finding.path, + locations: finding.locations.map((location) => location.file), + }, + { + code: "configuration-error", + path: "xspec.config.ts", + locations: [], + }, + `${context}: the error document's one finding carries the stable ` + + `code "configuration-error", locations [] (a configuration ` + + `condition carries the file it concerns, never a source range), ` + + `and as its concerned path the configuration file the upward ` + + `search found, in the anchoring form of 11.6 relative to the ` + + `invocation working directory — the workspace root, so exactly ` + + `"xspec.config.ts" (SPEC 14, 12.7, 11.6)`, + ); + assertSnapshotsEqual( + before, + await snapshotDirectory(workspace.root), + `${context}: a build failing at configuration load modifies ` + + `nothing (SPEC 12.1, 12.0) — no derived file or graph data ` + + `appears anywhere under the root`, + ); }, ); } @@ -858,6 +920,101 @@ export default defineConfig({ }, ]; +// Value shapes (SPEC 7, 7.1, 7.2; 14.14: a configuration that does not +// conform, an otherwise invalid group shape) — TEST-SPEC T7-3's "one arm +// each": seven fixtures, every one SPECS_ONLY_CONFIG with exactly one shape +// deviation, so the refusal is attributable to it alone. Each deviating +// value is admitted by the declarative form of 7 (a static string literal, +// the boolean literal `true`, an object or array literal — never a T7-2 +// form error) and excluded by the shapes 7.1, 7.2, 7.4, and 7.5 prescribe: +// a group's value is a list of globs, a glob is a string, `coverage` and +// `policy` are lists, and `specs` and `code` are maps of named groups — +// discriminating a product that reads the declarative form loosely, +// accepting whatever its own loader tolerates. +const VALUE_SHAPE_VIOLATIONS: readonly { label: string; config: string }[] = [ + { + label: "a spec group whose value is a single string rather than a list", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: "specs/**/*.mdx" + } +}) +`, + }, + { + label: "a code group whose value is a single string rather than a list", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: { + impl: "src/**/*.ts" + } +}) +`, + }, + { + label: "a glob list holding a non-string element ([true])", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: [true] + } +}) +`, + }, + { + label: "`coverage` given as an object rather than a list ({})", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + coverage: {} +}) +`, + }, + { + label: "`policy` given as an object rather than a list ({})", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + policy: {} +}) +`, + }, + { + label: "`specs` given as a list rather than a map of groups", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: ["specs/**/*.mdx"] +}) +`, + }, + { + label: "`code` given as a list rather than a map of groups", + config: `import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + main: ["specs/**/*.mdx"] + }, + code: ["src/**/*.ts"] +}) +`, + }, +]; + // `code` omitted: a marker-bearing TypeScript file that WOULD be a valid // code source (a spec module import plus a marker recording a `references` // edge, SPEC 4.5) — so a product that wrongly discovers `.ts` files without @@ -975,13 +1132,22 @@ const T7_3 = defineProductTest({ "keys: specs is required; omitted code/markdown/coverage/policy mean " + "no code groups, no emission, zero profiles, and no policy findings; " + "empty coverage/policy lists equal omission; unknown keys at every " + - "position are configuration errors (SPEC 7, 14.14)", + "position, and values of the wrong shape (a group valued by a single " + + "string, a glob list holding true, coverage/policy given as objects, " + + "specs/code given as lists), are configuration errors (SPEC 7, 7.1, " + + "7.2, 14.14)", run: async (product) => { // (a) `specs` missing and the unknown-key matrix — each 14.14, exit 2. for (const arm of KEY_VIOLATIONS) { await expectConfigRefused(product, arm.config, `T7-3 (${arm.label})`); } + // (a′) value shapes — each 14.14, exit 2, the configuration file named + // and nothing written (SPEC 7, 7.1, 7.2; 14.14). + for (const arm of VALUE_SHAPE_VIOLATIONS) { + await expectConfigRefused(product, arm.config, `T7-3 (${arm.label})`); + } + // (b) `code` omitted — no code groups: the marker-bearing .ts file is // undiscovered, no edge is sourced at it, and naming it in --from is // unknown (exit 2; SPEC 7, 11). diff --git a/test/suite/registry/traceability.ts b/test/suite/registry/traceability.ts index 3e99b37..0c77362 100644 --- a/test/suite/registry/traceability.ts +++ b/test/suite/registry/traceability.ts @@ -227,7 +227,7 @@ export const H7_TRACEABILITY: Readonly<Record<string, readonly string[]>> = { "T6.7-1": ["6.7"], "T7-1": ["7", "14"], "T7-2": ["7", "14"], - "T7-3": ["7", "14"], + "T7-3": ["7", "7.1", "7.2", "14"], "T7-4": ["7", "14"], "T7-5": ["7"], "T7-6": ["7", "13.4", "14"], From 0d245a62d27e31d2506a4d77103532674aaa18e9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 11:37:48 +0000 Subject: [PATCH 220/226] =?UTF-8?q?sdg(phase-9):=20T7.4-1=20`edgeKinds`=20?= =?UTF-8?q?non-subset=20and=20non-string=20`targetTags`=20element=20arms?= =?UTF-8?q?=20(14.14)=20=E2=80=94=20four=20fixtures=20appended=20to=20`PRO?= =?UTF-8?q?FILE=5FMATRIX`,=20TEST-SPEC=20T7.4-1's=20one=20arm=20each:=20`e?= =?UTF-8?q?dgeKinds`=20holding=20the=20non-dependency=20kind=20`"contains"?= =?UTF-8?q?`,=20the=20unknown=20token=20`"depend"`,=20and=20the=20non-stri?= =?UTF-8?q?ng=20element=20`true`,=20each=20staged=20last=20beside=20the=20?= =?UTF-8?q?valid=20kind=20`"depends"`=20so=20the=20list=20is=20non-empty?= =?UTF-8?q?=20and=20non-subset=20at=20once=20(a=20product=20dropping=20unk?= =?UTF-8?q?nown=20members,=20or=20filtering=20them=20and=20only=20then=20a?= =?UTF-8?q?pplying=20the=20empty-list=20rule,=20keeps=20`["depends"]`=20an?= =?UTF-8?q?d=20builds,=20exit=200=20=E2=80=94=20where=20a=20lone=20stray?= =?UTF-8?q?=20member=20would=20let=20it=20refuse=20by=20the=20wrong=20rule?= =?UTF-8?q?,=20an=20emptied=20list,=20and=20pass=20by=20accident),=20and?= =?UTF-8?q?=20`targetTags:=20[true]`=20as=20TEST-SPEC=20spells=20it=20(a?= =?UTF-8?q?=20tolerant=20product,=20dropping=20or=20stringifying=20the=20e?= =?UTF-8?q?lement,=20accepts=20the=20configuration=20and=20builds);=20`tru?= =?UTF-8?q?e`=20being=20the=20boolean=20literal=20the=20declarative=20form?= =?UTF-8?q?=20of=207=20admits,=20those=20refusals=20are=2014.14's=20invali?= =?UTF-8?q?d=20profile=20shape,=20never=20a=20form=20error.=20The=20module?= =?UTF-8?q?'s=20`expectConfigRefused`,=20serving=20every=20T7.4-1/T7.5-1?= =?UTF-8?q?=20refusal=20arm,=20is=20strengthened=20as=20Task=2034=20streng?= =?UTF-8?q?thened=20`section-7-basics`':=20the=20finding=20is=20asserted?= =?UTF-8?q?=20exactly=20`{code:=20"configuration-error",=20path:=20"xspec.?= =?UTF-8?q?config.ts",=20locations:=20[]}`=20=E2=80=94=20the=20file=20the?= =?UTF-8?q?=20upward=20search=20found,=20in=2011.6's=20anchoring=20form=20?= =?UTF-8?q?relative=20to=20the=20invocation=20working=20directory,=20the?= =?UTF-8?q?=20workspace=20root=20(SPEC=2014,=2012.7),=20and=20no=20source?= =?UTF-8?q?=20range=20for=20a=20configuration=20condition=20(14)=20?= =?UTF-8?q?=E2=80=94=20and=20a=20whole-root=20snapshot=20compare=20around?= =?UTF-8?q?=20the=20invocation=20pins=20that=20a=20build=20failing=20at=20?= =?UTF-8?q?configuration=20load=20writes=20nothing=20(12.1).=20Title=20and?= =?UTF-8?q?=20module-header=20notes=20extended;=20traceability=20unchanged?= =?UTF-8?q?=20(`7.4`,=20`14`=20cover=20the=20asserted=20passages).=20Again?= =?UTF-8?q?st=20the=20built=20product=20all=20four=20arms=20pass=20(a=20ha?= =?UTF-8?q?nd=20replay=20of=20the=20shapes=20shows=20exit=202,=20that=20fi?= =?UTF-8?q?nding,=20and=20an=20unchanged=20tree,=20while=20a=20valid=20`ed?= =?UTF-8?q?geKinds:=20["depends"]`=20control=20builds=20and=20writes),=20`?= =?UTF-8?q?section-7.4-7.5`=208/8=20(T7.4-1=20in=206.6=20s=20over=20its=20?= =?UTF-8?q?19=20matrix=20arms=20plus=20the=20ambiguity,=20inference,=20and?= =?UTF-8?q?=20usage-error=20arms),=20`npm=20run=20typecheck`=20and=20`npm?= =?UTF-8?q?=20run=20format`=20clean,=20`npm=20run=20test:self`=20342/342;?= =?UTF-8?q?=20T7.4-1=20lies=20in=20no=20certification=20scope,=20so=20C-1?= =?UTF-8?q?=20is=20untouched=20(Task=2035)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 13 --- test/suite/registry/section-7.4-7.5.ts | 147 ++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 19 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index c1d4de2..59970f0 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,19 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 35 — T7.4-1: `edgeKinds` non-subset and non-string `targetTags` element → 14.14 - -Cites: TEST-SPEC T7.4-1 (revised: `edgeKinds` values `"contains"`, `"depend"`, -and `true` — one arm each — and a `targetTags` list holding `true` are 14.14 -configuration errors); SPEC 7.4, 14.14. - -Now: `test/suite/registry/section-7.4-7.5.ts` ≈ lines 542–550 lack them. - -Do: add the four arms, each `build` → exit 2, 14.14 error document, nothing -written. - -Verify: `npx vitest run … test/suite/section-7.4-7.5.test.ts`. - ### Task 36 — T7.5-1: policy `kinds` non-subset and non-string selector `tags` element → 14.14 Cites: TEST-SPEC T7.5-1 (revised: `kinds` values `"contains"`, `"depend"`, diff --git a/test/suite/registry/section-7.4-7.5.ts b/test/suite/registry/section-7.4-7.5.ts index 1c1117a..104f91a 100644 --- a/test/suite/registry/section-7.4-7.5.ts +++ b/test/suite/registry/section-7.4-7.5.ts @@ -33,6 +33,22 @@ // the staged deviation is the workspace's only defect: a product that // wrongly accepts the configuration proceeds to a clean build (exit 0) and // fails the exit-code assertion — never exits 2 for a side reason. +// Every `expectConfigRefused` arm (T7.4-1, T7.5-1) further pins the +// finding's concerned path to exactly `xspec.config.ts` — the file the +// upward search found, in 11.6's anchoring form relative to the invocation +// working directory, the workspace root (SPEC 14, 12.7) — its locations [] +// (a configuration condition carries the file it concerns, no source +// range; 14), and, by a whole-root snapshot compare around the invocation, +// that nothing is written (12.1). +// - T7.4-1 stray-member arms (`edgeKinds` not a subset of the three +// dependency kinds; a non-string `targetTags` element): each `edgeKinds` +// stray member — "contains", "depend", `true` — is staged last beside the +// valid kind "depends", so a product that drops unknown members (or +// filters them and only then applies the empty-list rule) keeps a valid +// list and builds, exit 0, instead of refusing by the wrong rule; +// `targetTags: [true]` is staged as TEST-SPEC spells it. `true` is the +// boolean literal the declarative form of 7 admits, so those refusals are +// 14.14's invalid profile shape, never a form error. // - Unknown profile name at `coverage <name>` (T7.4-1) is a 12.0 usage error, // not a 14.14: asserted as exit 2 with the single 12.7 error document as // the entire stdout under --json (12.0: with JSON output in effect, an @@ -100,6 +116,10 @@ import { import { assertRunTwiceDeterministic } from "../../helpers/determinism.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { + assertSnapshotsEqual, + snapshotDirectory, +} from "../../helpers/snapshot.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { summarizeResult } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; @@ -143,6 +163,14 @@ async function withWorkspace<T>( * Stage a workspace with the given configuration and source files and assert * `build --json` refuses it per 14.14 (module header: the deviation is the * only defect, so wrong acceptance surfaces as a failed exit-code assertion). + * Beyond the shared 14.14 contract (`expectConfigurationError`), the finding + * is pinned to the configuration file: its concerned path is exactly + * `xspec.config.ts` — the file the upward search found, in the anchoring + * form of 11.6 relative to the invocation working directory, here the + * workspace root, so the bare name (SPEC 14, 12.7) — and its locations are + * [] (a configuration condition carries the file it concerns, no source + * range; SPEC 14); a whole-root snapshot compare around the invocation pins + * that a build failing at configuration load writes nothing (SPEC 12.1). */ async function expectConfigRefused( product: ProductBinding, @@ -153,7 +181,40 @@ async function expectConfigRefused( await withWorkspace( { files: { "xspec.config.ts": config, ...files } }, async (workspace) => { - await expectConfigurationError(product, workspace, ["build"], context); + const before = await snapshotDirectory(workspace.root); + const result = await expectConfigurationError( + product, + workspace, + ["build"], + context, + ); + const finding = expectErrorDocument(result, context); + assertSameJson( + { + code: finding.code, + path: finding.path, + locations: finding.locations.map((location) => location.file), + }, + { + code: "configuration-error", + path: "xspec.config.ts", + locations: [], + }, + `${context}: the error document's one finding carries the stable ` + + `code "configuration-error", locations [] (a configuration ` + + `condition carries the file it concerns, never a source range), ` + + `and as its concerned path the configuration file the upward ` + + `search found, in the anchoring form of 11.6 relative to the ` + + `invocation working directory — the workspace root, so exactly ` + + `"xspec.config.ts" (SPEC 14, 12.7, 11.6)`, + ); + assertSnapshotsEqual( + before, + await snapshotDirectory(workspace.root), + `${context}: a build failing at configuration load modifies ` + + `nothing (SPEC 12.1, 12.0) — no derived file or graph data ` + + `appears anywhere under the root`, + ); }, ); } @@ -492,6 +553,78 @@ const PROFILE_MATRIX: readonly { ], ], }, + // `edgeKinds` not a subset of ["depends", "embeds", "references"] (7.4) — + // an otherwise invalid profile shape (14.14), one arm per kind of stray + // member. Each stray member sits last beside the valid kind "depends", so + // the list is non-empty and non-subset at once: a product that drops or + // ignores members it does not know, or that filters them out and only then + // applies the empty-list rule, keeps a well-formed ["depends"] and builds + // (exit 0) — where a lone stray member would let such a product refuse by + // the wrong rule (an emptied list) and pass by accident. + { + label: + '`edgeKinds` holding the non-dependency kind "contains" beside ' + + '"depends" — not a subset of the three dependency kinds (7.4), ' + + "discriminating a product that lets `contains` grant coverage (8) or " + + "ignores the stray member", + profiles: [ + [ + 'name: "p"', + 'target: "main"', + 'boundary: "aux"', + 'mode: "direct"', + 'edgeKinds: ["depends", "contains"]', + ], + ], + }, + { + label: + '`edgeKinds` holding the unknown token "depend" beside "depends" — ' + + "not a subset (7.4), discriminating a product that accepts and " + + "ignores a stray member", + profiles: [ + [ + 'name: "p"', + 'target: "main"', + 'boundary: "aux"', + 'mode: "direct"', + 'edgeKinds: ["depends", "depend"]', + ], + ], + }, + { + label: + "`edgeKinds` holding the non-string element `true` beside " + + '"depends" — the boolean literal the declarative form of 7 admits, ' + + "so the refusal is 14.14's invalid profile shape, not a form error", + profiles: [ + [ + 'name: "p"', + 'target: "main"', + 'boundary: "aux"', + 'mode: "direct"', + 'edgeKinds: ["depends", true]', + ], + ], + }, + { + label: + "`targetTags` holding the non-string element `true` (`[true]`, as " + + "TEST-SPEC T7.4-1 spells the fixture) — the boolean literal the " + + "declarative form of 7 admits, so the refusal is 14.14's invalid " + + "profile shape; a product tolerating it (dropping or stringifying the " + + "element) accepts the configuration and builds, so exit 0 " + + "discriminates it", + profiles: [ + [ + 'name: "p"', + 'target: "main"', + "targetTags: [true]", + 'boundary: "aux"', + 'mode: "direct"', + ], + ], + }, ]; // `boundary: "dual"` where dual is both a spec and a code group, boundaryKind @@ -543,11 +676,13 @@ const T7_4_1 = defineProductTest({ title: "profile validation: duplicate names, each missing required field, " + "invalid targets/mode/boundaryKind values, unknown and wrong-kind " + - "target/boundary references, empty targetTags/edgeKinds, and an " + - "ambiguous boundary without boundaryKind are configuration errors " + - "(14.14, exit 2); boundaryKind is inferred when unambiguous; an unknown " + - "profile name at `coverage <name>` is a usage error (SPEC 7.4, 14.14, " + - "12.0)", + "target/boundary references, empty targetTags/edgeKinds, edgeKinds " + + 'holding "contains", an unknown token, or a non-string element, a ' + + "non-string targetTags element, and an ambiguous boundary without " + + "boundaryKind are configuration errors (14.14, exit 2, the finding " + + "naming xspec.config.ts, nothing written); boundaryKind is inferred " + + "when unambiguous; an unknown profile name at `coverage <name>` is a " + + "usage error (SPEC 7.4, 14.14, 12.0)", run: async (product) => { // (a) The 14.14 matrix over the standard group layout. for (const arm of PROFILE_MATRIX) { From fc1ace98239ad5535314ea61dc2390623bedc7d4 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 11:45:23 +0000 Subject: [PATCH 221/226] =?UTF-8?q?sdg(phase-9):=20T7.5-1=20`kinds`=20non-?= =?UTF-8?q?subset=20and=20non-string=20selector=20`tags`=20element=20arms?= =?UTF-8?q?=20(14.14)=20=E2=80=94=20four=20fixtures=20appended=20to=20`RUL?= =?UTF-8?q?E=5FMATRIX`,=20TEST-SPEC=20T7.5-1's=20one=20arm=20each,=20mirro?= =?UTF-8?q?ring=20Task=2035's=20T7.4-1=20arms:=20`kinds`=20holding=20the?= =?UTF-8?q?=20non-dependency=20kind=20`"contains"`,=20the=20unknown=20toke?= =?UTF-8?q?n=20`"depend"`,=20and=20the=20non-string=20element=20`true`,=20?= =?UTF-8?q?each=20staged=20last=20beside=20the=20valid=20kind=20`"depends"?= =?UTF-8?q?`=20so=20the=20list=20is=20non-empty=20and=20non-subset=20at=20?= =?UTF-8?q?once=20(a=20product=20dropping=20unknown=20members,=20or=20filt?= =?UTF-8?q?ering=20them=20and=20only=20then=20applying=20the=20empty-list?= =?UTF-8?q?=20rule,=20keeps=20`["depends"]`=20and=20builds,=20exit=200=20?= =?UTF-8?q?=E2=80=94=20`build`=20never=20evaluates=20policy=20(12.1),=20so?= =?UTF-8?q?=20a=20tolerated=20rule=20can=20never=20fail=20the=20build=20by?= =?UTF-8?q?=20a=20side=20reason=20=E2=80=94=20where=20a=20lone=20stray=20m?= =?UTF-8?q?ember=20would=20let=20it=20refuse=20by=20the=20wrong=20rule,=20?= =?UTF-8?q?an=20emptied=20list,=20and=20pass=20by=20accident),=20and=20a?= =?UTF-8?q?=20selector=20`tags:=20[true]`=20as=20TEST-SPEC=20spells=20it?= =?UTF-8?q?=20(a=20product=20stringifying=20or=20silently=20tolerating=20t?= =?UTF-8?q?he=20element=20accepts=20the=20configuration=20and=20builds);?= =?UTF-8?q?=20`true`=20being=20the=20boolean=20literal=20the=20declarative?= =?UTF-8?q?=20form=20of=207=20admits,=20those=20refusals=20are=2014.14's?= =?UTF-8?q?=20invalid=20rule=20shape,=20never=20a=20form=20error.=20Every?= =?UTF-8?q?=20arm=20runs=20through=20the=20module's=20`expectConfigRefused?= =?UTF-8?q?`,=20already=20pinning=20the=20finding=20exactly=20`{code:=20"c?= =?UTF-8?q?onfiguration-error",=20path:=20"xspec.config.ts",=20locations:?= =?UTF-8?q?=20[]}`=20(SPEC=2014,=2012.7,=2011.6)=20and,=20by=20a=20whole-r?= =?UTF-8?q?oot=20snapshot=20compare,=20that=20nothing=20is=20written=20(12?= =?UTF-8?q?.1).=20Title=20extended;=20the=20module-header=20stray-member?= =?UTF-8?q?=20note=20now=20covers=20T7.4-1=20and=20T7.5-1=20together;=20tr?= =?UTF-8?q?aceability=20unchanged=20(`7.5`,=20`14`=20cover=20the=20asserte?= =?UTF-8?q?d=20passages).=20Against=20the=20built=20product=20all=20four?= =?UTF-8?q?=20arms=20pass=20(a=20hand=20replay=20of=20the=20shapes=20shows?= =?UTF-8?q?=20exit=202,=20that=20finding,=20and=20an=20unchanged=20tree,?= =?UTF-8?q?=20while=20the=20valid=20controls=20`kinds:=20["depends"]`=20an?= =?UTF-8?q?d=20`tags:=20["t"]`=20build=20and=20write),=20`section-7.4-7.5`?= =?UTF-8?q?=208/8=20(T7.5-1=20in=205.2=20s=20over=20its=2022=20matrix=20ar?= =?UTF-8?q?ms=20plus=20the=20ambiguity=20arm),=20`npm=20run=20typecheck`?= =?UTF-8?q?=20and=20`npm=20run=20format:check`=20clean,=20`npm=20run=20tes?= =?UTF-8?q?t:self`=20342/342;=20T7.5-1=20lies=20in=20no=20certification=20?= =?UTF-8?q?scope,=20so=20C-1=20is=20untouched=20(Task=2036)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 12 --- test/suite/registry/section-7.4-7.5.ts | 103 +++++++++++++++++++++---- 2 files changed, 90 insertions(+), 25 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 59970f0..6a2b2fa 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,18 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 36 — T7.5-1: policy `kinds` non-subset and non-string selector `tags` element → 14.14 - -Cites: TEST-SPEC T7.5-1 (revised: `kinds` values `"contains"`, `"depend"`, -`true`, and a selector `tags` list holding `true` are 14.14 configuration -errors); SPEC 7.5, 14.14. - -Now: `test/suite/registry/section-7.4-7.5.ts` ≈ lines 1055–1061 lack them. - -Do: add the four arms as in Task 35. - -Verify: `npx vitest run … test/suite/section-7.4-7.5.test.ts`. - ### Task 37 — T10.5-1: `parent-consistency` context sets over A → B → C with only C changed Cites: TEST-SPEC T10.5-1 (revised: with A → B → C and only C `changed`, A's diff --git a/test/suite/registry/section-7.4-7.5.ts b/test/suite/registry/section-7.4-7.5.ts index 104f91a..f6bcada 100644 --- a/test/suite/registry/section-7.4-7.5.ts +++ b/test/suite/registry/section-7.4-7.5.ts @@ -40,15 +40,18 @@ // (a configuration condition carries the file it concerns, no source // range; 14), and, by a whole-root snapshot compare around the invocation, // that nothing is written (12.1). -// - T7.4-1 stray-member arms (`edgeKinds` not a subset of the three -// dependency kinds; a non-string `targetTags` element): each `edgeKinds` -// stray member — "contains", "depend", `true` — is staged last beside the -// valid kind "depends", so a product that drops unknown members (or -// filters them and only then applies the empty-list rule) keeps a valid -// list and builds, exit 0, instead of refusing by the wrong rule; -// `targetTags: [true]` is staged as TEST-SPEC spells it. `true` is the -// boolean literal the declarative form of 7 admits, so those refusals are -// 14.14's invalid profile shape, never a form error. +// - T7.4-1 and T7.5-1 stray-member arms (`edgeKinds` / `kinds` not a subset +// of the three dependency kinds; a non-string `targetTags` / selector +// `tags` element): each `edgeKinds` or `kinds` stray member — "contains", +// "depend", `true` — is staged last beside the valid kind "depends", so a +// product that drops unknown members (or filters them and only then +// applies the empty-list rule) keeps a valid list and builds, exit 0, +// instead of refusing by the wrong rule (`build` never evaluates policy, +// 12.1, so a tolerated rule can never fail the build by a side reason); +// `targetTags: [true]` and `tags: [true]` are staged as TEST-SPEC spells +// them. `true` is the boolean literal the declarative form of 7 admits, so +// those refusals are 14.14's invalid profile or rule shape, never a form +// error. // - Unknown profile name at `coverage <name>` (T7.4-1) is a 12.0 usage error, // not a 14.14: asserted as exit 2 with the single 12.7 error document as // the entire stdout under --json (12.0: with JSON output in effect, an @@ -1082,6 +1085,61 @@ const RULE_MATRIX: readonly { ], ], }, + // `kinds` not a subset of the dependency edge kinds (7.5) — an otherwise + // invalid rule shape (14.14), one arm per kind of stray member. As in + // T7.4-1, each stray member sits last beside the valid kind "depends", so + // the list is non-empty and non-subset at once: a product that drops or + // ignores members it does not know, or that filters them out and only then + // applies the empty-list rule, keeps a well-formed ["depends"] and builds + // (exit 0 — `build` never evaluates policy, 12.1) — where a lone stray + // member would let such a product refuse by the wrong rule (an emptied + // list) and pass by accident. + { + label: + '`kinds` holding the non-dependency kind "contains" beside ' + + '"depends" — not a subset of the dependency edge kinds (7.5), ' + + "discriminating a product that evaluates policy over `contains` " + + "edges or ignores the stray member", + rules: [ + [ + 'name: "r"', + 'type: "forbidden"', + 'from: { group: "main" }', + 'to: { group: "aux" }', + 'kinds: ["depends", "contains"]', + ], + ], + }, + { + label: + '`kinds` holding the unknown token "depend" beside "depends" — not ' + + "a subset (7.5), discriminating a product that accepts and ignores a " + + "stray member", + rules: [ + [ + 'name: "r"', + 'type: "forbidden"', + 'from: { group: "main" }', + 'to: { group: "aux" }', + 'kinds: ["depends", "depend"]', + ], + ], + }, + { + label: + "`kinds` holding the non-string element `true` beside " + + '"depends" — the boolean literal the declarative form of 7 admits, ' + + "so the refusal is 14.14's invalid rule shape, not a form error", + rules: [ + [ + 'name: "r"', + 'type: "forbidden"', + 'from: { group: "main" }', + 'to: { group: "aux" }', + 'kinds: ["depends", true]', + ], + ], + }, { label: "empty selector `tags` list", rules: [ @@ -1093,6 +1151,22 @@ const RULE_MATRIX: readonly { ], ], }, + { + label: + "selector `tags` holding the non-string element `true` (`[true]`, as " + + "TEST-SPEC T7.5-1 spells the fixture) — the boolean literal the " + + "declarative form of 7 admits, so the refusal is 14.14's invalid rule " + + "shape; a product stringifying or silently tolerating the element " + + "accepts the configuration and builds, so exit 0 discriminates it", + rules: [ + [ + 'name: "r"', + 'type: "forbidden"', + "from: { tags: [true] }", + 'to: { group: "aux" }', + ], + ], + }, { label: "selector with zero of group/files/tags", rules: [ @@ -1190,10 +1264,13 @@ const T7_5_1 = defineProductTest({ id: "T7.5-1", title: "rule validation: duplicate names, each missing required field, an " + - "invalid type, empty kinds/tags lists, selectors with zero or two of " + - "group/files/tags, unknown, wrong-kind, and ambiguous group references, " + - "a capture used twice in `from`, and a `to` referencing an absent " + - "capture are configuration errors (SPEC 7.5, 14.14, exit 2)", + "invalid type, empty kinds/tags lists, `kinds` not a subset of the " + + "dependency edge kinds (a `contains` member, an unknown token, a " + + "non-string element), a non-string selector `tags` element, selectors " + + "with zero or two of group/files/tags, unknown, wrong-kind, and " + + "ambiguous group references, a capture used twice in `from`, and a " + + "`to` referencing an absent capture are configuration errors (SPEC " + + "7.5, 14.14, exit 2)", run: async (product) => { for (const arm of RULE_MATRIX) { await expectConfigRefused( From 9c54d840578f805b9dd836e58a773ad6dde4cb1e Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 11:53:28 +0000 Subject: [PATCH 222/226] =?UTF-8?q?sdg(phase-9):=20T10.5-1=20two-levels-de?= =?UTF-8?q?ep=20chain=20arm=20(10.5)=20=E2=80=94=20a=20third=20workspace?= =?UTF-8?q?=20on=20T10.5-1=20staging=20`specs/Y.mdx`=20as=20a=20>=20a.b=20?= =?UTF-8?q?>=20a.b.c=20with=20only=20a.b.c's=20text=20edited=20(SPEC=205.6?= =?UTF-8?q?:=20the=20leaf=20alone=20is=20changed,=20its=20ancestors=20desc?= =?UTF-8?q?endant-changed),=20built,=20and=20put=20under=20a=20`--base`=20?= =?UTF-8?q?session;=20`review=20status`=20pinned=20to=20exactly=20the=20th?= =?UTF-8?q?ree=20items=20(a.b.c's=20subtree-coherence,=20a.b's=20and=20a's?= =?UTF-8?q?=20parent-consistency=20=E2=80=94=20the=20changed=20node=20and?= =?UTF-8?q?=20the=20file=20root=20get=20none),=20and=20the=20`review=20exp?= =?UTF-8?q?ort`=20payload=20asserted=20by=20identity=20set:=20a.b.c's=20su?= =?UTF-8?q?btree-coherence=20context=20is=20its=20ancestor=20chain=20(file?= =?UTF-8?q?=20root,=20a,=20a.b)=20and=20its=20origin=20{a.b.c};=20a.b's=20?= =?UTF-8?q?parent-consistency=20context=20is=20exactly=20{a.b.c}=20(its=20?= =?UTF-8?q?child=20on=20the=20changed=20branch,=20here=20the=20changed=20n?= =?UTF-8?q?ode=20itself);=20a's=20is=20exactly=20{a.b}=20=E2=80=94=20the?= =?UTF-8?q?=20branch=20head,=20a's=20child=20on=20the=20branch,=20each=20c?= =?UTF-8?q?hanged=20branch=20entering=20as=20one=20context=20node=20?= =?UTF-8?q?=E2=80=94=20never=20a.b.c,=20both=20origins=20{a.b.c}=20(TEST-S?= =?UTF-8?q?PEC=20T10.5-1=20as=20revised;=20a=20product=20listing=20the=20c?= =?UTF-8?q?hanged=20nodes=20or=20the=20whole=20branch=20as=20context=20fai?= =?UTF-8?q?ls=20here),=20plus=20the=20blocking=20chain=20over=20the=20same?= =?UTF-8?q?=20three=20items=20(a.b's=20item=20blocked=20by=20a.b.c's=20sub?= =?UTF-8?q?tree-coherence=20item,=20a's=20by=20a.b's=20parent-consistency?= =?UTF-8?q?=20item;=20T10.5-2=20keeps=20the=20general=20chains).=20Title?= =?UTF-8?q?=20and=20module-header=20notes=20extended;=20traceability=20unc?= =?UTF-8?q?hanged=20(`10`,=20`10.5`=20cover=20the=20asserted=20passages).?= =?UTF-8?q?=20Against=20the=20built=20product=20the=20arm=20passes=20(a=20?= =?UTF-8?q?hand=20replay=20in=20a=20scratch=20workspace=20shows=20exactly?= =?UTF-8?q?=20those=20context,=20origin,=20and=20blockedBy=20sets),=20`sec?= =?UTF-8?q?tion-10.5`=206/6,=20`npm=20run=20typecheck`=20and=20`npm=20run?= =?UTF-8?q?=20format`=20clean,=20`npm=20run=20test:self`=20342/342;=20T10.?= =?UTF-8?q?5-1=20lies=20in=20no=20certification=20scope,=20so=20C-1=20is?= =?UTF-8?q?=20untouched=20(Task=2037)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 17 --- test/suite/registry/section-10.5.ts | 154 +++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 19 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 6a2b2fa..8b77c58 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,23 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 37 — T10.5-1: `parent-consistency` context sets over A → B → C with only C changed - -Cites: TEST-SPEC T10.5-1 (revised: with A → B → C and only C `changed`, A's -`parent-consistency` context is exactly `{B}` — never `C` — and B's exactly -`{C}`; identities asserted); SPEC 10.5, 5.6. - -Now: `test/suite/registry/section-10.5.ts` T10.5-1 lacks the chain arm; T10.5-2's -`a > a.b > a.b.c` fixture asserts blocking only, no context sets. - -Do: add the arm to T10.5-1: three sections with `d` edges A→B→C (or the -section nesting the spec's arm names), a session, then edit C; decode the -review payload (existing review adapter) and assert each item's -`parent-consistency` context set by identity: A's `{B}`, B's `{C}`, C's -empty/absent as the spec states. - -Verify: `npx vitest run … test/suite/section-10.5.test.ts`. - ### Task 38 — T11-6: `query subtree` / `query ancestors` on a code-group `path` or `path#unit` → exit 2 Cites: TEST-SPEC T11-6 (§11.1: wrong-kind operands — `subtree` and diff --git a/test/suite/registry/section-10.5.ts b/test/suite/registry/section-10.5.ts index 109f337..f98044b 100644 --- a/test/suite/registry/section-10.5.ts +++ b/test/suite/registry/section-10.5.ts @@ -353,7 +353,9 @@ async function captureHashes( // --------------------------------------------------------------------------- // T10.5-1 — generation: SPEC 15's worked change, plus the skipping rule, -// subtree scope, and the shared-ancestor union +// subtree scope, the shared-ancestor union, and the two-levels-deep chain +// (a > a.b > a.b.c with only a.b.c changed: a's parent-consistency context +// is exactly {a.b} — the branch head — never a.b.c; a.b's is {a.b.c}) // --------------------------------------------------------------------------- // The SPEC 15 example workspace, verbatim. @@ -444,10 +446,39 @@ function x1Spec( ].join("\n"); } +// Chain fixture (SPEC 10.5 rule 2, the two-levels-deep change): a > a.b > +// a.b.c with only a.b.c changed. Each changed branch enters an ancestor's +// parent-consistency item as one context node — the ancestor's child on +// that branch — so a's context is exactly {a.b}, never the changed node +// a.b.c two levels beneath it, while a.b's context is {a.b.c}; the origin of +// both is the branch's changed node. T10.4-1's deep-edit sensitivity +// presupposes this context node (TEST-SPEC T10.5-1). +const Y_FILE = "specs/Y.mdx"; +const Y_A = "specs/Y.mdx#a"; +const Y_AB = "specs/Y.mdx#a.b"; +const Y_ABC = "specs/Y.mdx#a.b.c"; + +function ySpec(cText: string): string { + return [ + '<S id="a">', + "Aye own text.", + "", + '<S id="a.b">', + "Abe own text.", + "", + '<S id="a.b.c">', + cText, + "</S>", + "</S>", + "</S>", + "", + ].join("\n"); +} + const T10_5_1 = defineProductTest({ id: "T10.5-1", title: - "path-blocks generation: SPEC 15's worked change (a leaf text edit to print.hello) yields exactly the four listed items — print.hello's subtree-coherence item (context: its ancestor chain; origin: itself), print's parent-consistency item blocked by it (context/origin: print.hello), derived.hello's dependency-consistency item (context/origin: print.hello), and a code-impact item for src/hello.ts#hello (context: derived.hello, the impact-edge target that makes it impacted; origin: print.hello) — and the extended fixture pins the skipping rule (a changed node with a changed ancestor generates no own item; the ancestor's single subtree-coherence item carries both changed nodes as origin and its scope covers the node plus all descendants, its scope text the scope root's subtree text) and the shared-ancestor union (two changed leaves under one unchanged ancestor yield one parent-consistency item whose context and origin are the union of changed branches) (SPEC 5.6, 9.2, 10.5, 15)", + "path-blocks generation: SPEC 15's worked change (a leaf text edit to print.hello) yields exactly the four listed items — print.hello's subtree-coherence item (context: its ancestor chain; origin: itself), print's parent-consistency item blocked by it (context/origin: print.hello), derived.hello's dependency-consistency item (context/origin: print.hello), and a code-impact item for src/hello.ts#hello (context: derived.hello, the impact-edge target that makes it impacted; origin: print.hello) — and the extended fixture pins the skipping rule (a changed node with a changed ancestor generates no own item; the ancestor's single subtree-coherence item carries both changed nodes as origin and its scope covers the node plus all descendants, its scope text the scope root's subtree text) and the shared-ancestor union (two changed leaves under one unchanged ancestor yield one parent-consistency item whose context and origin are the union of changed branches) and the two-levels-deep chain (a > a.b > a.b.c with only a.b.c changed: a's parent-consistency item's context is exactly {a.b} — the branch head, a's child on the changed branch, each changed branch entering as one context node — never the changed node a.b.c, while a.b's item's context is exactly {a.b.c}, the identities asserted, both items' origin the changed node) (SPEC 5.6, 9.2, 10.5, 15)", timeoutMs: 300_000, run: async (product) => { // --- SPEC 15's worked change --------------------------------------------- @@ -713,6 +744,125 @@ const T10_5_1 = defineProductTest({ ); }, ); + + // --- chain fixture: a change two levels beneath an ancestor ----------- + await withWorkspace( + SPECS_ONLY_CONFIG, + { [Y_FILE]: ySpec("Cee text v0.") }, + async (workspace) => { + const prefix = "T10.5-1 chain fixture"; + await workspace.gitInit(); + const base = await workspace.gitCommitAll("baseline"); + await workspace.file(Y_FILE, ySpec("Cee text v1.")); + await buildOk(product, workspace, `${prefix} \`build\` after the edit`); + await createBaseSession(product, workspace, base, "s", prefix); + + // Only a.b.c is changed (SPEC 5.6: a leaf text edit changes the leaf + // alone; its ancestors are descendant-changed), so it gets the one + // subtree-coherence item and each non-root ancestor on its path — + // a.b and a — one parent-consistency item; the file root gets none. + const status = await sessionStatus(product, workspace, "s", prefix); + assertSameJson( + kindScopeSet(status), + [ + `parent-consistency ${Y_A}`, + `parent-consistency ${Y_AB}`, + `subtree-coherence ${Y_ABC}`, + ].sort(), + `${prefix}: only a.b.c is changed, so it gets the one ` + + `subtree-coherence item and each non-root ancestor on the path ` + + `to the root (a.b, a) one parent-consistency item — the changed ` + + `node and the file root get none (SPEC 5.6, 10.5)`, + ); + + const exported = await exportSession(product, workspace, "s", prefix); + const scABC = requireItem( + exported.items, + "subtree-coherence", + Y_ABC, + prefix, + ); + const pcAB = requireItem( + exported.items, + "parent-consistency", + Y_AB, + prefix, + ); + const pcA = requireItem( + exported.items, + "parent-consistency", + Y_A, + prefix, + ); + + // subtree-coherence: context is a.b.c's ancestor chain (a.b, a, and + // the file root); origin the changed node (SPEC 10.5 rule 1). + assertSameJson( + identitySet(scABC.context), + [Y_FILE, Y_A, Y_AB].sort(), + `${prefix}: a.b.c's subtree-coherence item's context is its ` + + `ancestor chain (SPEC 10.5)`, + ); + assertSameJson( + identitySet(scABC.origin), + [Y_ABC], + `${prefix}: a.b.c's subtree-coherence item's origin is the changed ` + + `node in scope (SPEC 10.5)`, + ); + + // a.b: its child on the changed branch is the changed node itself. + assertSameJson( + identitySet(pcAB.context), + [Y_ABC], + `${prefix}: a.b's parent-consistency item's context is exactly ` + + `{a.b.c} — a.b's child on the changed branch, here the changed ` + + `node itself (SPEC 10.5)`, + ); + assertSameJson( + identitySet(pcAB.origin), + [Y_ABC], + `${prefix}: a.b's parent-consistency item's origin is the changed ` + + `branch's changed node (SPEC 10.5)`, + ); + + // a: the changed branch enters as ONE context node — a's child on + // that branch, the branch head a.b — never the changed node a.b.c + // two levels beneath (SPEC 10.5 rule 2; a product listing the + // changed nodes, or the whole branch, as context fails here). + assertSameJson( + identitySet(pcA.context), + [Y_AB], + `${prefix}: a's parent-consistency item's context is exactly ` + + `{a.b} — the branch head, a's child on the changed branch — ` + + `never the changed node a.b.c two levels beneath it (SPEC 10.5: ` + + `each changed branch enters as one context node)`, + ); + assertSameJson( + identitySet(pcA.origin), + [Y_ABC], + `${prefix}: a's parent-consistency item's origin is the changed ` + + `branch's changed node, a.b.c (SPEC 10.5)`, + ); + + // The blocking chain over the same three items (SPEC 10.5 rule 2; + // T10.5-2 pins the general chains): a.b's item is blocked by its + // child's subtree-coherence item, a's by its child's + // parent-consistency item. + assertBlockedBy(scABC, [], `${prefix} a.b.c subtree-coherence item`); + assertBlockedBy( + pcAB, + [scABC.id], + `${prefix} a.b's parent-consistency item — the changed branch's ` + + `changed node is a.b's child, so its subtree-coherence item blocks`, + ); + assertBlockedBy( + pcA, + [pcAB.id], + `${prefix} a's parent-consistency item — the change lies deeper ` + + `than a's child a.b, so a.b's parent-consistency item blocks`, + ); + }, + ); }, }); From b7ed9655ffb658b2941a22785e9fe93aed25fea6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 12:01:18 +0000 Subject: [PATCH 223/226] =?UTF-8?q?sdg(phase-9):=20T11-6=20`query=20subtre?= =?UTF-8?q?e`/`query=20ancestors`=20wrong-kind=20operand=20arms=20(11.1,?= =?UTF-8?q?=2012.0)=20=E2=80=94=20the=20wrong-kind=20loop=20widened=20from?= =?UTF-8?q?=20`query=20node`=20and=20`show`=20to=20the=20four=20commands?= =?UTF-8?q?=20taking=20a=20requirement-node=20identity=20(`<node>`=20for?= =?UTF-8?q?=20`node`,=20`subtree`,=20and=20`ancestors`=20under=2011.1;=20`?= =?UTF-8?q?show`=20under=2012.4),=20each=20given=20the=20code-group=20whol?= =?UTF-8?q?e-file=20`src/code.ts`=20and=20the=20named=20unit=20`src/code.t?= =?UTF-8?q?s#Box.v`=20of=20T11-6's=20existing=20staging=20(SPEC=204.6),=20?= =?UTF-8?q?eight=20arms=20through=20the=20module's=20`expectUsageError`=20?= =?UTF-8?q?(exit=202=20exactly,=20the=2012.7=20error=20document=20as=20the?= =?UTF-8?q?=20entire=20`--json`=20stdout),=20TEST-SPEC=20T11-6's=20"each?= =?UTF-8?q?=20exit=202"=20for=20the=20two=20newly=20covered=20commands;=20?= =?UTF-8?q?every=20arm=20now=20runs=20under=20`assertLeavesUnchanged`=20(`?= =?UTF-8?q?test/helpers/snapshot.ts`),=20so=20the=20whole=20workspace=20ro?= =?UTF-8?q?ot,=20derived=20files=20included,=20is=20byte-identical=20aroun?= =?UTF-8?q?d=20each=20invocation=20=E2=80=94=20a=20read=20command=20never?= =?UTF-8?q?=20writes,=20and=20the=20argument=20check=20is=20judged=20befor?= =?UTF-8?q?e=20anything=20else=20(12.0).=20Title=20extended=20("each=20exi?= =?UTF-8?q?t=202,=20modifying=20nothing");=20traceability=20unchanged=20(`?= =?UTF-8?q?11.1`=20covers=20the=20asserted=20passage).=20Against=20the=20b?= =?UTF-8?q?uilt=20product=20all=20eight=20arms=20pass=20(a=20hand=20replay?= =?UTF-8?q?=20in=20a=20scratch=20workspace=20shows=20exit=202,=20the=20nul?= =?UTF-8?q?l-code=20error=20document=20naming=20the=20code=20location,=20a?= =?UTF-8?q?nd=20an=20unchanged=20tree=20for=20all=20four=20new=20arms),=20?= =?UTF-8?q?`section-11`=207/7=20(T11-6=20in=203.9=20s),=20`npm=20run=20typ?= =?UTF-8?q?echeck`=20and=20`npm=20run=20format`=20clean,=20`npm=20run=20te?= =?UTF-8?q?st:self`=20342/342;=20T11-6=20lies=20in=20no=20certification=20?= =?UTF-8?q?scope,=20so=20C-1=20is=20untouched=20(Task=2038)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 16 --------- test/suite/registry/section-11.ts | 56 +++++++++++++++++++------------ 2 files changed, 35 insertions(+), 37 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index 8b77c58..ca9999f 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,22 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 38 — T11-6: `query subtree` / `query ancestors` on a code-group `path` or `path#unit` → exit 2 - -Cites: TEST-SPEC T11-6 (§11.1: wrong-kind operands — `subtree` and -`ancestors` take a spec node; given a code-group whole-file `path` or a -`path#unit` location they are usage errors, exit 2); SPEC 11.1, 12.0. - -Now: `test/suite/registry/section-11.ts` T11-6 (≈ line 1466) covers `query -node` and `show` only; every `subtree`/`ancestors` invocation names a spec -source. - -Do: add four arms (`subtree`/`ancestors` × `path`/`path#unit`) on a workspace -with a discovered code file holding one unit; each → exit 2 with the 12.7 -error document, nothing modified. - -Verify: `npx vitest run … test/suite/section-11.test.ts`. - ### Task 39 — T11.4-3: stage the valueless prop as T2.7-3's shared `<S id="x" tags>` fixture Cites: TEST-SPEC T11.4-3 (§11.4: the valueless-prop case is staged as T2.7-3's diff --git a/test/suite/registry/section-11.ts b/test/suite/registry/section-11.ts index 3dc625b..aa5ca3a 100644 --- a/test/suite/registry/section-11.ts +++ b/test/suite/registry/section-11.ts @@ -84,6 +84,7 @@ import { } from "../../helpers/determinism.js"; import { defineProductTest } from "../../helpers/registry.js"; import type { ProductTestEntry } from "../../helpers/registry.js"; +import { assertLeavesUnchanged } from "../../helpers/snapshot.js"; import type { ProductBinding } from "../../helpers/subprocess.js"; import { runProduct } from "../../helpers/subprocess.js"; import { TestWorkspace } from "../../helpers/workspace.js"; @@ -1463,7 +1464,7 @@ const T11_6_S1_ID = "specs/S.mdx#s1"; const T11_6 = defineProductTest({ id: "T11-6", title: - "identity resolution: a bare `path` resolves to the root node for a spec-group file and to a code location for a code-group file; `path#unit` and `path#unit@N` address code locations (a getter/setter pair as the duplicate unit chain); a path in no configured group is unknown, exit 2; wrong-kind operands — `query node` and `show` given a code-group `path` or `path#unit` — each exit 2; an unspelled unit name on a discovered code source is unknown in every graph-node argument position (`edges --from`/`--to`, `reachable --from`/`--to`), exit 2, as are an out-of-range disambiguator (`@2` on a once-occurring chain) and `@1` at every occurrence count — no occurrence bears `@1`, staged at one and at two occurrences (SPEC 11, 1.5, 4.6, 12.0, 12.4)", + "identity resolution: a bare `path` resolves to the root node for a spec-group file and to a code location for a code-group file; `path#unit` and `path#unit@N` address code locations (a getter/setter pair as the duplicate unit chain); a path in no configured group is unknown, exit 2; wrong-kind operands — `query node`, `query subtree`, `query ancestors`, and `show` given a code-group `path` or `path#unit` — each exit 2, modifying nothing; an unspelled unit name on a discovered code source is unknown in every graph-node argument position (`edges --from`/`--to`, `reachable --from`/`--to`), exit 2, as are an out-of-range disambiguator (`@2` on a once-occurring chain) and `@1` at every occurrence count — no occurrence bears `@1`, staged at one and at two occurrences (SPEC 11, 1.5, 4.6, 12.0, 12.4)", run: async (product) => { await withWorkspace( SPEC_AND_CODE_CONFIG, @@ -1580,27 +1581,40 @@ const T11_6 = defineProductTest({ "T11-6 `query edges --from docs/N.mdx`", ); - // Wrong-kind operands (SPEC 12.0; 11.1, 12.4): `query node` and - // `show` take a requirement-node identity, so a code-group `path` or + // Wrong-kind operands (SPEC 12.0; 11.1, 12.4): `query node`, `query + // subtree`, and `query ancestors` each take a `<node>` — a + // requirement-node identity, `path#id` or a bare spec-group `path` + // (11.1) — and so does `show` (12.4), so a code-group `path` or // `path#unit` — a code source named where a requirement-node identity - // is required — is a usage error for each command, in each form. - for (const operand of ["src/code.ts", "src/code.ts#Box.v"]) { - await expectUsageError( - product, - workspace, - ["query", "node", operand], - "a code source named where a requirement-node identity is " + - "required (wrong-kind operand)", - `T11-6 \`query node ${operand}\``, - ); - await expectUsageError( - product, - workspace, - ["show", operand], - "a code source named where a requirement-node identity is " + - "required (wrong-kind operand)", - `T11-6 \`show ${operand}\``, - ); + // is required — is a usage error for each command, in each form. A + // read command never writes and the argument check is judged before + // anything else (12.0), so each arm runs under the modifies-nothing + // compare: the whole workspace root, derived files included (13), is + // byte-identical around the invocation. + const wrongKindCommands: readonly (readonly string[])[] = [ + ["query", "node"], + ["query", "subtree"], + ["query", "ancestors"], + ["show"], + ]; + for (const command of wrongKindCommands) { + for (const operand of ["src/code.ts", "src/code.ts#Box.v"]) { + const argv = [...command, operand]; + const context = `T11-6 \`${argv.join(" ")}\``; + await assertLeavesUnchanged( + workspace.root, + () => + expectUsageError( + product, + workspace, + argv, + "a code source named where a requirement-node identity is " + + "required (wrong-kind operand)", + context, + ), + context, + ); + } } // Unknown code units (SPEC 12.0, 4.6): the check is judged From 7e004fa4e4b990d873251e21887433a5bccdd6b2 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 12:08:29 +0000 Subject: [PATCH 224/226] =?UTF-8?q?sdg(phase-9):=20extend=20T2.7-3's=20exp?= =?UTF-8?q?orted=20`<S=20id=3D"x"=20tags>`=20fixture=20with=20the=20valid?= =?UTF-8?q?=20sibling's=20offsets=20(11.4)=20=E2=80=94=20`VALUELESS=5FTAGS?= =?UTF-8?q?=5FFIXTURE`=20(`section-2.7.ts`)=20now=20also=20declares=20the?= =?UTF-8?q?=20sibling=20`<S=20id=3D"ok">`:=20its=20spelled=20id,=20its=20c?= =?UTF-8?q?onstruct=20range=20(the=20file's=20first=20bytes,=20SPEC=201.7)?= =?UTF-8?q?,=20and=20its=20one=20`id=3D"ok"`=20attribute=20entry,=20each?= =?UTF-8?q?=20derived=20from=20the=20exact=20parts=20(`SIBLING=5FCONSTRUCT?= =?UTF-8?q?`=20split=20out=20of=20`SIBLING`,=20bytes=20unchanged)=20and=20?= =?UTF-8?q?slice-checked=20back=20against=20`source`=20by=20`assertValuele?= =?UTF-8?q?ssTagsFixture`=20before=20T2.7-3=20stages=20it=20=E2=80=94=20so?= =?UTF-8?q?=20T11.4-3's=20`view`=20arm=20on=20the=20shared=20fixture=20(Ta?= =?UTF-8?q?sk=2039,=20next)=20can=20assert=20the=20whole=20positional=20tr?= =?UTF-8?q?ee=20against=20declared=20offsets=20the=20build=20arm=20has=20v?= =?UTF-8?q?erified,=20the=20sibling=20every=20datum=20plain=20beside=20the?= =?UTF-8?q?=20one=20defect.=20`npm=20run=20typecheck`=20and=20`npm=20run?= =?UTF-8?q?=20format`=20clean,=20`section-2.7`=203/3=20against=20the=20bui?= =?UTF-8?q?lt=20product;=20no=20test=20ID=20or=20certification=20set=20cha?= =?UTF-8?q?nges.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- test/suite/registry/section-2.7.ts | 55 ++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/test/suite/registry/section-2.7.ts b/test/suite/registry/section-2.7.ts index 4b1ab96..4046b8e 100644 --- a/test/suite/registry/section-2.7.ts +++ b/test/suite/registry/section-2.7.ts @@ -97,7 +97,8 @@ export default defineConfig({ // Shared negative-arm template (the SUITE-02/03 discipline): a valid sibling // first, so the offending construct is a proper sub-range of the file and the // location assertion has teeth. -const SIBLING = '<S id="ok">\nA valid sibling section.\n</S>\n\n'; +const SIBLING_CONSTRUCT = '<S id="ok">\nA valid sibling section.\n</S>'; +const SIBLING = `${SIBLING_CONSTRUCT}\n\n`; // T2.7-3's one-defect file: the sibling, then the offending section — its // opening tag the staged construct, its body and closing tag fixed — at one @@ -688,8 +689,11 @@ export interface StagedAttribute { /** * T2.7-3's valueless-`tags` file, the fixture T11.4-3 shares for `view` - * (TEST-SPEC T11.4-3: one fixture for build and view). Pure ASCII, so string - * indices are byte offsets; every offset derives from the exact parts, and + * (TEST-SPEC T11.4-3: one fixture for build and view — the build arm asserts + * its one 14.17; the view arm asserts the bearer's identity defined beside + * it, its attribute entries, and its interpreted tags unavailable, SPEC + * 11.2/11.4). Pure ASCII, so string indices are byte offsets; every offset — + * the bearer's and the valid sibling's — derives from the exact parts, and * T2.7-3 slices each back against `source` before staging. */ export interface ValuelessTagsFixture { @@ -705,6 +709,19 @@ export interface ValuelessTagsFixture { readonly sectionRange: SourceRange; /** The bearer's attributes in tag order: `id="x"`, then the bare `tags`. */ readonly attributes: readonly StagedAttribute[]; + /** + * The valid sibling preceding the bearer — `<S id="ok">`, the file's first + * bytes — every datum of it plain under SPEC 11.2 (the defaults for its + * absent `tags` and `coverage`): the control beside the one defect. + */ + readonly sibling: { + /** Its spelled `id` value. */ + readonly id: string; + /** Its construct range, opening tag through closing tag (SPEC 1.7). */ + readonly sectionRange: SourceRange; + /** Its attributes in tag order: the one `id="ok"`. */ + readonly attributes: readonly StagedAttribute[]; + }; /** Where the one 14.17 finding must locate: the opening tag's window (SPEC 14). */ readonly finding: FindingSourceExpectation; } @@ -716,6 +733,8 @@ function valuelessTagsFixture(): ValuelessTagsFixture { const tagStart = Buffer.byteLength(SIBLING, "utf8"); const idStart = tagStart + Buffer.byteLength("<S ", "utf8"); const tagsStart = idStart + Buffer.byteLength(`${idText} `, "utf8"); + const siblingIdText = 'id="ok"'; + const siblingIdStart = Buffer.byteLength("<S ", "utf8"); return { file: INVALID_PROP_FILE, source: invalidPropSource(construct), @@ -737,6 +756,23 @@ function valuelessTagsFixture(): ValuelessTagsFixture { text: tagsText, }, ], + sibling: { + id: "ok", + sectionRange: { + start: 0, + end: Buffer.byteLength(SIBLING_CONSTRUCT, "utf8"), + }, + attributes: [ + { + name: "id", + range: { + start: siblingIdStart, + end: siblingIdStart + siblingIdText.length, + }, + text: siblingIdText, + }, + ], + }, finding: { file: INVALID_PROP_FILE, window: byteWindow(SIBLING, construct), @@ -771,6 +807,19 @@ function assertValuelessTagsFixture(): void { fixture.construct + INVALID_PROP_BODY, `${context}: the section range slices to the bearer's whole construct`, ); + for (const attribute of fixture.sibling.attributes) { + assertSameJson( + slice(attribute.range), + attribute.text, + `${context}: the sibling's \`${attribute.name}\` attribute's range ` + + `slices to its text`, + ); + } + assertSameJson( + slice(fixture.sibling.sectionRange), + SIBLING_CONSTRUCT, + `${context}: the sibling's section range slices to its whole construct`, + ); } const INVALID_PROP_ARMS: readonly InvalidPropArm[] = [ From 07652a65d96bbba131bb6095b52afaf9ce18a45b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 12:14:17 +0000 Subject: [PATCH 225/226] =?UTF-8?q?sdg(phase-9):=20T11.4-3=20`view`=20arm?= =?UTF-8?q?=20on=20T2.7-3's=20shared=20`<S=20id=3D"x"=20tags>`=20fixture?= =?UTF-8?q?=20(11.4,=2011.2,=202.7)=20=E2=80=94=20a=20third=20invocation?= =?UTF-8?q?=20in=20`section-11.4.ts`:=20`VALUELESS=5FTAGS=5FFIXTURE`=20(im?= =?UTF-8?q?ported=20from=20`section-2.7.ts`,=20the=20exact=20specs/A.mdx?= =?UTF-8?q?=20bytes=20the=20build=20arm=20stages,=20its=20declared=20offse?= =?UTF-8?q?ts=20re-verified=20through=20the=20now-exported=20`assertValuel?= =?UTF-8?q?essTagsFixture`=20plus=20this=20module's=20own=20root-range=20s?= =?UTF-8?q?lice=20check)=20staged=20alone=20in=20its=20own=20workspace=20a?= =?UTF-8?q?nd=20viewed=20bare=20=E2=80=94=20the=20CONF-AVAIL=20surface,=20?= =?UTF-8?q?no=20gate-reference=20`build`,=20no=20snapshot=20compare=20?= =?UTF-8?q?=E2=80=94=20so=20build=20and=20view=20share=20one=20fixture=20(?= =?UTF-8?q?TEST-SPEC=20T11.4-3).=20The=20matrix=20file's=20valueless=20`ta?= =?UTF-8?q?gs`=20rides=20a=20tag=20whose=20identity=20the=20repeated=20`id?= =?UTF-8?q?`=20has=20already=20withdrawn,=20so=20it=20never=20asked=20this?= =?UTF-8?q?:=20the=20bearer's=20identity=20is=20asserted=20the=20plain=20`?= =?UTF-8?q?specs/A.mdx#x`=20(SPEC=2011.2:=20exactly=20one=20quoted=20stati?= =?UTF-8?q?c=20`id`,=20well-formed=20and=20unique,=20spells=20and=20define?= =?UTF-8?q?s=20it=20whatever=20invalid-form=20prop=20stands=20beside=20it?= =?UTF-8?q?=20=E2=80=94=20a=20product=20withdrawing=20identity=20on=20the?= =?UTF-8?q?=20valueless=20prop=20alone=20fails),=20its=20attributes=20the?= =?UTF-8?q?=20`id`=20entry=20then=20the=20bare-name=20`tags`=20entry=20in?= =?UTF-8?q?=20tag=20order,=20byte-exact=20at=20the=20fixture's=20offsets,?= =?UTF-8?q?=20the=20bare=20name's=20text=20the=20name=20alone=20(11.4:=20i?= =?UTF-8?q?nclusion=20is=20by=20form),=20its=20interpreted=20tags=20explic?= =?UTF-8?q?itly=20unavailable=20beside=20the=20absent-prop=20default=20cov?= =?UTF-8?q?erage=20"required"=20(a=20product=20reading=20the=20bare=20name?= =?UTF-8?q?=20as=20an=20absent=20prop=20reports=20the=20plain=20default=20?= =?UTF-8?q?`[]`=20and=20fails),=20the=20valid=20sibling=20`ok`=20every=20d?= =?UTF-8?q?atum=20plain=20as=20the=20control,=20the=20root's=20tags/covera?= =?UTF-8?q?ge=20the=20stated=20`null`;=20exactly=20one=2014.17=20accompani?= =?UTF-8?q?es=20=E2=80=94=20never=2014.1=20=E2=80=94=20located=20within=20?= =?UTF-8?q?the=20bearer's=20opening=20tag=20through=20the=20same=20`Findin?= =?UTF-8?q?gSourceExpectation`=20T2.7-3=20holds=20the=20build's=20finding?= =?UTF-8?q?=20to=20(the=20condition=20beside=20the=20view=20is=20the=20con?= =?UTF-8?q?dition=20the=20build=20reports),=20and=20the=20invocation=20exi?= =?UTF-8?q?ts=201=20with=20the=20full=20document=20(11.2).=20Whole=20tree?= =?UTF-8?q?=20compared=20through=20the=20module's=20`projectAttributeData`?= =?UTF-8?q?;=20title,=20module=20header,=20and=20certification=20notes=20e?= =?UTF-8?q?xtended=20(two=20bare=20`view`s=20plus=20one=20`<file>`-operand?= =?UTF-8?q?=20`view`=20now);=20traceability=20unchanged=20(`11.4`=20covers?= =?UTF-8?q?=20the=20asserted=20passages).=20Against=20the=20built=20produc?= =?UTF-8?q?t=20the=20arm=20passes=20(a=20hand=20replay=20in=20a=20scratch?= =?UTF-8?q?=20workspace=20shows=20exactly=20those=20identities,=20entries,?= =?UTF-8?q?=20datums,=20the=20one=20finding=20at=20bytes=2053..57=20inside?= =?UTF-8?q?=20the=20tag=20window=2043..59,=20exit=201),=20`section-11.4`?= =?UTF-8?q?=206/6,=20`section-2.7`=203/3,=20`npm=20run=20typecheck`=20and?= =?UTF-8?q?=20`npm=20run=20format`=20clean,=20`npm=20run=20test:self`=2034?= =?UTF-8?q?2/342=20with=20C-1=20exact=20=E2=80=94=20T11.4-3=20PASS=20again?= =?UTF-8?q?st=20the=20conformer=20and=20VIOL-AVAIL-NOFILE,=20FAIL=20agains?= =?UTF-8?q?t=20VIOL-AVAIL-NULLMARKER=20and=20VIOL-AVAIL-OMIT,=20the=20cert?= =?UTF-8?q?ified=20sets=20unchanged=20(Task=2039,=20removed=20from=20the?= =?UTF-8?q?=20plan;=208=20tasks=20remain)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 26 ---- test/suite/registry/section-11.4.ts | 176 ++++++++++++++++++++++++++-- test/suite/registry/section-2.7.ts | 5 +- 3 files changed, 172 insertions(+), 35 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index ca9999f..de98f11 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,32 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 39 — T11.4-3: stage the valueless prop as T2.7-3's shared `<S id="x" tags>` fixture - -Cites: TEST-SPEC T11.4-3 (§11.4: the valueless-prop case is staged as T2.7-3's -`<S id="x" tags>` — one fixture shared by `build` and `view` — so that `view`'s -answer for a section whose identity is well-formed but which carries a -form-invalid valueless prop is asserted as the spec states: identity present, -the attribute listed in tag order, no unavailability where 11.2 defines none); -CERTIFICATIONS.md §CONF-AVAIL (bare valueless attributes → 14.17; -document-order listings); SPEC 11.2, 11.4, 2.7. - -Now: `test/suite/registry/section-11.4.ts` T11.4-3 (≈ line 1274) stages -valueless `tags` only on `<S id="dup" id="dup" note="mystery" {...extras} -tags>`, whose identity is already unavailable, so a product withdrawing -identity on the valueless prop alone is not discriminated; nothing is shared -with `section-2.7.ts`. - -Do: import Task 15's exported fixture constant and add a `view` arm on that -exact file: decode through `decodeViewReport` and assert the node's identity -is the plain `x`, its `attributes` list the valueless `tags` entry in tag -order with the datum form T11.4-3 states, and the per-node fields 11.2 leaves -defined are present; keep the existing compound arm. Prerequisite: Task 15. - -Verify: `npx vitest run … test/suite/section-11.4.test.ts`; `npm run -test:self` green — CONF-AVAIL passes T11.4-3; NULLMARKER and OMIT still fail -exactly their sets (both list T11.4-3). - ### Task 40 — T12.0-10: missing required flag/argument rows in the syntax class (no configuration load) Cites: TEST-SPEC T12.0-10 (§12.0 revised: within class 2 the rows include "a diff --git a/test/suite/registry/section-11.4.ts b/test/suite/registry/section-11.4.ts index 5ec2e07..6c19815 100644 --- a/test/suite/registry/section-11.4.ts +++ b/test/suite/registry/section-11.4.ts @@ -104,8 +104,8 @@ // view's substance is pinned at identity level (root and child identity); // ranges, attributes, and interpreted values stay T11.4-1/-3's subject. // -// T11.4-3 — attributes and per-node data (SPEC 11.4, 11.2, 2.7). One -// workspace, two files, two invocations: +// T11.4-3 — attributes and per-node data (SPEC 11.4, 11.2, 2.7). Two +// workspaces, three files, three invocations: // // - specs/attrs.mdx, staged via the running-offset builder: a // five-attribute section tag `<S id="dup" id="dup" note="mystery" @@ -145,6 +145,27 @@ // structural absence as unavailability owes exit 1 per 11.2's // any-unavailable-datum rule and fails the exit compare; the bare // invocation exits 1 for the matrix file's findings and markers). +// - The third invocation is a bare `view` over T2.7-3's shared fixture +// (`VALUELESS_TAGS_FIXTURE`, imported from section-2.7: the exact +// specs/A.mdx bytes the build arm stages — `<S id="ok">` then +// `<S id="x" tags>` — with the declared offsets that arm slice-checks +// before staging), alone in its own workspace, so build and view share +// one fixture (TEST-SPEC T11.4-3). The matrix file's valueless `tags` +// rides a tag whose identity the repeated `id` has already withdrawn, +// so it cannot ask what this arm asks: the bearer's identity is +// well-formed and unique, hence defined (SPEC 11.2: exactly one quoted +// static `id` spells; tags/coverage invalidity never undefines identity) +// — asserted the plain `specs/A.mdx#x`, never the marker — while the +// bare-name prop alone leaves its interpreted tags unavailable beside +// the absent-prop default coverage `"required"`, BOTH attribute entries +// listed in tag order (the bare name's text the name alone), and the one +// 14.17 the build reports located within the opening tag's window (the +// same `FindingSourceExpectation` T2.7-3 holds the build's finding to), +// nothing else (no 14.1: the identity is spelled), exit 1. A product +// withdrawing identity on the valueless prop alone, reading the bare +// name as an absent prop (the plain default `[]` where 11.2 leaves the +// value unavailable), or dropping the valueless entry from the listing +// fails here; the sibling `ok` keeps every datum plain as the control. // // T11.4-4 — imports (SPEC 11.4, 11.2, 2.1). One workspace, two files, one // bare `view`, the imports member asserted as ONE exact list: @@ -294,7 +315,7 @@ // constraints exactly: spec-only workspaces of `.mdx` sources at valid-UTF-8 // `#`-free paths, imports as the fixtures stage them; every command driven // is drawn from the enumerated surface — T11.4-1's and T11.4-4's bare -// whole-domain `view`s, T11.4-3's bare `view` plus one `<file>`-operand +// whole-domain `view`s, T11.4-3's two bare `view`s plus one `<file>`-operand // `view`, never `occurrences` or `at` — with NO gate-reference `build` (each // answer's own findings member is the staging integrity) and NO snapshot // compare (graph-data and refresh behavior are expressly out of CONF-AVAIL @@ -310,9 +331,10 @@ // exactly as certified (`null` is never omission — decodeViewReport rejects // the absent members). T11.4-3 is the per-node unavailability carrier the // document names: under VIOL-AVAIL-NULLMARKER its identity, tags, and -// coverage unavailability arms read `null` where the test asserts the -// marker literally (a `null` identity fails the form-exact decode outright; -// `null` tags/coverage fail the tree compare against the expected marker); +// coverage unavailability arms — the shared fixture's bare-name `tags` +// among them — read `null` where the test asserts the marker literally (a +// `null` identity fails the form-exact decode outright; `null` +// tags/coverage fail the tree compare against the expected marker); // under VIOL-AVAIL-OMIT every stated-`null` member its answers carry (each // root's `tags`/`coverage`, every finding's `null` path, the spread entry's // `null` name) is absent and the decode rejects the omission, the exit-0 @@ -366,6 +388,10 @@ import { SPEC_AND_CODE_CONFIG, SPECS_ONLY_CONFIG, } from "./section-11.2.js"; +import { + assertValuelessTagsFixture, + VALUELESS_TAGS_FIXTURE, +} from "./section-2.7.js"; import { assertConditionCounts, assertFindingLocated, @@ -1270,10 +1296,54 @@ const CLEAN_TREE: AttributeDataShape = { ], }; +// T2.7-3's shared fixture (module header): the exact specs/A.mdx bytes the +// build arm stages, with the offsets that arm verifies — build and view +// share one fixture (TEST-SPEC T11.4-3). +const SHARED = VALUELESS_TAGS_FIXTURE; +const SHARED_ROOT_RANGE: SourceRange = { + start: 0, + end: Buffer.byteLength(SHARED.source, "utf8"), +}; + +const SHARED_TREE: AttributeDataShape = { + identity: SHARED.file, + range: SHARED_ROOT_RANGE, + attributes: [], + tags: null, + coverage: null, + children: [ + { + // The valid sibling: every datum plain — its identity defined, its + // absent props the defaults (SPEC 11.2) — the control beside the one + // defect. + identity: `${SHARED.file}#${SHARED.sibling.id}`, + range: SHARED.sibling.sectionRange, + attributes: SHARED.sibling.attributes, + tags: [], + coverage: "required", + children: [], + }, + { + // The bearer: exactly one quoted static `id`, well-formed and unique, + // spells and defines its identity whatever invalid-form prop stands + // beside it (SPEC 11.2) — the plain value, never the marker; BOTH + // attribute entries in tag order, the bare name's text the name alone + // (SPEC 11.4); interpreted tags unavailable (a valueless prop is no + // quoted static string, SPEC 2.7), coverage the absent-prop default. + identity: `${SHARED.file}#${SHARED.id}`, + range: SHARED.sectionRange, + attributes: SHARED.attributes, + tags: UNAVAILABLE, + coverage: "required", + children: [], + }, + ], +}; + const T11_4_3 = defineProductTest({ id: "T11.4-3", title: - 'raw attribute spellings as parsed, one entry per spelled attribute in tag order on the five-attribute tag `<S id="dup" id="dup" note="mystery" {...extras} tags>` — a repeated `id` (BOTH entries), an unknown prop, a spread attribute (its `name` structurally absent — the stated `null` — its source text the whole braced construct), a valueless bare-name `tags` — each entry\'s name, range, and source text byte-asserted against precomputed offsets behind a multi-byte prefix; inclusion is by form: every invalid form stays a listed entry, its invalidity a located finding beside the view, never a view omission — exactly five 14.17 (those four plus a braced `coverage={"none"}` on a second section), each located in the matrix file; per-node `identity`, `tags`, `coverage` each plain or explicitly unavailable per T11.2-2, every state carried once (identity unavailable on the repeated-`id` bearer; tags unavailable on the valueless `tags` beside its absent-prop default coverage "required"; coverage unavailable on the braced value beside its defined identity and default empty tags; all three plain in the sibling file); a root\'s `tags` and `coverage` are structurally absent — the stated `null`, never the unavailability marker, no finding and no exit-1 consequence: the finding-free specs/clean.mdx named as a `<file>` operand exits 0 with them `null`, the bare whole-domain view exiting 1 for the matrix file\'s findings and markers (SPEC 11.4, 11.2, 2.7, 12.7, 14; CERTIFICATIONS.md CONF-AVAIL in scope)', + 'raw attribute spellings as parsed, one entry per spelled attribute in tag order on the five-attribute tag `<S id="dup" id="dup" note="mystery" {...extras} tags>` — a repeated `id` (BOTH entries), an unknown prop, a spread attribute (its `name` structurally absent — the stated `null` — its source text the whole braced construct), a valueless bare-name `tags` — each entry\'s name, range, and source text byte-asserted against precomputed offsets behind a multi-byte prefix; inclusion is by form: every invalid form stays a listed entry, its invalidity a located finding beside the view, never a view omission — exactly five 14.17 (those four plus a braced `coverage={"none"}` on a second section), each located in the matrix file; per-node `identity`, `tags`, `coverage` each plain or explicitly unavailable per T11.2-2, every state carried once (identity unavailable on the repeated-`id` bearer; tags unavailable on the valueless `tags` beside its absent-prop default coverage "required"; coverage unavailable on the braced value beside its defined identity and default empty tags; all three plain in the sibling file); a root\'s `tags` and `coverage` are structurally absent — the stated `null`, never the unavailability marker, no finding and no exit-1 consequence: the finding-free specs/clean.mdx named as a `<file>` operand exits 0 with them `null`, the bare whole-domain view exiting 1 for the matrix file\'s findings and markers; T2.7-3\'s shared `<S id="x" tags>` fixture (specs/A.mdx, the exact bytes its build arm stages) viewed bare in its own workspace: the bearer\'s identity the plain `specs/A.mdx#x` — exactly one quoted static `id`, well-formed and unique, keeps its defined identity whatever invalid-form prop stands beside it — its `id` and bare-name `tags` entries listed in tag order at the fixture\'s declared offsets, its interpreted tags unavailable beside the absent-prop default coverage "required", the one 14.17 the build reports located within the opening tag\'s window and nothing else (never 14.1), exit 1, the valid sibling every datum plain (SPEC 11.4, 11.2, 2.7, 12.7, 14; CERTIFICATIONS.md CONF-AVAIL in scope)', run: async (product) => { // Fixture self-checks (T5.7-2 discipline) — composed-range arithmetic // proven against the staged bytes before any product invocation. @@ -1315,6 +1385,16 @@ const T11_4_3 = defineProductTest({ "the clean construct", ); sliceCheck(CLEAN_SOURCE, CLEAN_ROOT_RANGE, CLEAN_SOURCE, "the clean file"); + // The shared fixture's declared offsets against its own bytes: the + // fixture's own check (section-2.7), run here too since this test may + // run alone, plus the root range this module derives. + assertValuelessTagsFixture(); + sliceCheck( + SHARED.source, + SHARED_ROOT_RANGE, + SHARED.source, + "the shared file", + ); const workspace = await TestWorkspace.create({ files: { @@ -1463,6 +1543,88 @@ const T11_4_3 = defineProductTest({ } finally { await workspace.dispose(); } + + // --- Invocation 3: T2.7-3's shared fixture, viewed bare in its own + // workspace (module header) — the identity question the matrix file + // cannot ask: a well-formed, unique `id` beside a valueless `tags`. + const sharedWorkspace = await TestWorkspace.create({ + files: { + "xspec.config.ts": SPECS_ONLY_CONFIG, + [SHARED.file]: SHARED.source, + }, + }); + try { + const sharedContext = + 'T11.4-3 bare `view` over T2.7-3\'s shared `<S id="x" tags>` fixture'; + const sharedResult = await expectExit( + product, + sharedWorkspace, + ["view"], + 1, + `${sharedContext} — the answer carries the bare-name prop's 14.17 ` + + `and its explicitly-unavailable tags datum, so the invocation ` + + `exits 1 with the full document still emitted (SPEC 11.2, 11.4)`, + ); + const sharedReport = decodeViewReport( + parseJsonStdout( + sharedResult, + `${sharedContext} — a single JSON document is the only output ` + + `form, with or without --json (SPEC 11)`, + ), + { text: false }, + sharedContext, + ); + assertConditionCounts( + sharedReport.findings, + { "14.17": 1 }, + `${sharedContext}: exactly the one 14.17 T2.7-3's build arm ` + + `asserts on the same bytes accompanies the view — the valueless ` + + `tags (SPEC 2.7, 14) — and nothing else: no 14.1 (the bearer ` + + `spells an identity: exactly one quoted static id, SPEC 11.2), ` + + `no 14.2/14.3 (ok and x are unique and conformant)`, + ); + assertFindingLocated( + sharedReport.findings[0]!, + SHARED.finding, + `${sharedContext} — the 14.17 locates in ${SHARED.file} within ` + + `the bearer's opening tag, the window T2.7-3 holds the build's ` + + `finding to: the condition beside the view is the condition the ` + + `build reports (SPEC 11.2, 14)`, + ); + assertSameJson( + sharedReport.views.map((view) => view.file), + [SHARED.file], + `${sharedContext}: the one discovered spec source is viewed (SPEC 11.4)`, + ); + assertSameJson( + projectAttributeData(sharedReport.views[0]!.root), + SHARED_TREE, + `${sharedContext} — ${SHARED.file}: the bearer's identity is the ` + + `plain "${SHARED.file}#${SHARED.id}" — exactly one quoted static ` + + `id, well-formed and unique, spells and defines it whatever ` + + `invalid-form prop stands beside it (SPEC 11.2: tags/coverage ` + + `invalidity never undefines identity; a product withdrawing ` + + `identity on the valueless prop alone fails) — its attributes ` + + `the id entry then the bare-name tags entry in tag order, each ` + + `byte-exact at the fixture's declared offsets, the bare name's ` + + `text the name alone (SPEC 11.4: inclusion is by form), its ` + + `interpreted tags explicitly unavailable (a valueless prop is ` + + `no quoted static string, SPEC 2.7 — a product reading the bare ` + + `name as an absent prop reports the plain default [] and fails) ` + + `beside the absent-prop default coverage "required"; the sibling ` + + `ok carries every datum plain, and the root's tags/coverage are ` + + `the stated null, never the marker (SPEC 12.7)`, + ); + const sharedView = sharedReport.views[0]!; + assertSameJson( + [sharedView.imports, sharedView.occurrences, sharedView.comments], + [[], [], []], + `${sharedContext}: no import, reference spelling, or MDX comment ` + + `is staged — empty lists are [], never null (SPEC 11.4, 12.7)`, + ); + } finally { + await sharedWorkspace.dispose(); + } }, }); diff --git a/test/suite/registry/section-2.7.ts b/test/suite/registry/section-2.7.ts index 4046b8e..9b66340 100644 --- a/test/suite/registry/section-2.7.ts +++ b/test/suite/registry/section-2.7.ts @@ -787,9 +787,10 @@ export const VALUELESS_TAGS_FIXTURE: ValuelessTagsFixture = * The exported fixture's declared offsets against its own bytes (staging * integrity, T11.4-3's slice-check precedent): each attribute's range slices * to its text and the section range to the bearer's whole construct, so - * T11.4-3 asserts `view` against offsets the build arm has verified. + * T11.4-3 asserts `view` against offsets the build arm has verified — and + * runs this same check itself, since it may run alone. */ -function assertValuelessTagsFixture(): void { +export function assertValuelessTagsFixture(): void { const fixture = VALUELESS_TAGS_FIXTURE; const context = "T2.7-3 staging: the exported valueless-`tags` fixture"; const bytes = Buffer.from(fixture.source, "utf8"); From 238f9c27a06d5fbad2b4b8e7206349f040cc6419 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 2 Sep 2026 12:27:18 +0000 Subject: [PATCH 226/226] =?UTF-8?q?sdg(phase-9):=20T12.0-10=20missing=20re?= =?UTF-8?q?quired=20flag/argument=20rows=20in=20the=20within-class-2=20syn?= =?UTF-8?q?tax=20arm=20(12.0,=2010.7,=2011.5)=20=E2=80=94=20two=20rows=20a?= =?UTF-8?q?dded=20to=20`syntaxRows`=20in=20`section-12.0-ii.ts`,=20TEST-SP?= =?UTF-8?q?EC=20T12.0-10's=20revised=20class-2=20list:=20`review=20create?= =?UTF-8?q?=20--name=20n`=20with=20none=20of=20`--base`,=20`--strategy=20a?= =?UTF-8?q?udit`,=20or=20`--coverage`=20(a=20missing=20required=20flag,=20?= =?UTF-8?q?SPEC=2010.7)=20and=20`at=20<file>`=20alone,=20its=20`<offset>`?= =?UTF-8?q?=20absent=20(a=20missing=20required=20argument,=20SPEC=2011.5)?= =?UTF-8?q?=20=E2=80=94=20each=20run=20under=20the=20existing=20no-configu?= =?UTF-8?q?ration-load=20proof:=20exit=202=20with=20the=20single=2012.7=20?= =?UTF-8?q?error=20document,=20the=20plain=20usage=20error=20(`code`=20and?= =?UTF-8?q?=20`path`=20null,=20never=20the=20deliberately=20invalid=20conf?= =?UTF-8?q?iguration's=2014.14),=20byte-identical=20stdout=20with=20the=20?= =?UTF-8?q?configuration=20file=20invalid=20or=20missing=20(H-4).=20Every?= =?UTF-8?q?=20row's=20two=20invocations=20now=20run=20under=20`assertLeave?= =?UTF-8?q?sUnchanged`=20(whole-root=20compare,=20`.xspec/`=20included),?= =?UTF-8?q?=20so=20a=20syntax-alone=20error=20is=20seen=20to=20modify=20no?= =?UTF-8?q?thing=20=E2=80=94=20the=20mutating=20`review=20create`=20includ?= =?UTF-8?q?ed;=20and=20beside=20the=20invalid=20configuration=20the=20arm?= =?UTF-8?q?=20stages=20what=20the=20two=20new=20rows=20would=20consult=20n?= =?UTF-8?q?ext=20=E2=80=94=20a=20session=20already=20named=20`n`=20(SPEC?= =?UTF-8?q?=2010.7=20refuses=20the=20name,=20exit=201,=20in=20a=20product?= =?UTF-8?q?=20judging=20it=20before=20the=20flag=20check)=20and=20the=20`a?= =?UTF-8?q?t`=20row's=20`<file>`=20(present=20there,=20absent=20from=20the?= =?UTF-8?q?=20missing-configuration=20workspace,=20so=20a=20product=20judg?= =?UTF-8?q?ing=20the=20file=20before=20the=20argument=20count=20answers=20?= =?UTF-8?q?the=20two=20states=20differently=20and=20fails=20the=20byte-ide?= =?UTF-8?q?ntical=20compare)=20=E2=80=94=20the=20only=20other=20use=20of?= =?UTF-8?q?=20that=20workspace,=20the=20`coverage=20no-such-profile`=2014.?= =?UTF-8?q?14=20arm,=20failing=20at=20configuration=20load=20before=20eith?= =?UTF-8?q?er=20staged=20file=20matters.=20Title=20extended=20(10.7,=2011.?= =?UTF-8?q?5=20cited);=20traceability=20unchanged=20(`12.0`=20covers=20the?= =?UTF-8?q?=20asserted=20passage).=20Against=20the=20built=20product=20the?= =?UTF-8?q?=20arm=20passes=20(a=20hand=20replay=20in=20scratch=20workspace?= =?UTF-8?q?s=20shows=20exit=202,=20`code`/`path`=20null,=20identical=20doc?= =?UTF-8?q?uments=20across=20the=20two=20states,=20and=20unchanged=20trees?= =?UTF-8?q?=20for=20both=20rows);=20a=20red=20check=20giving=20the=20`at`?= =?UTF-8?q?=20row=20its=20offset=20fails=20as=20intended=20on=20the=2014.1?= =?UTF-8?q?4=20`configuration-error`=20the=20null-code=20assertion=20catch?= =?UTF-8?q?es;=20`section-12.0-ii`=207/7=20(T12.0-10=20in=207.7=20s),=20`n?= =?UTF-8?q?pm=20run=20typecheck`=20and=20`npm=20run=20format:check`=20clea?= =?UTF-8?q?n,=20`npm=20run=20test:self`=20342/342=20with=20C-1=20exact;=20?= =?UTF-8?q?T12.0-10=20lies=20in=20no=20certification=20scope,=20so=20the?= =?UTF-8?q?=20certified=20sets=20are=20untouched=20(Task=2040,=20removed?= =?UTF-8?q?=20from=20the=20plan;=207=20tasks=20remain)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyZ5zUv2UCkvTkM1tkYUp2 --- specs/tmp/FIX_PLAN.md | 17 ------ test/suite/registry/section-12.0-ii.ts | 82 +++++++++++++++++++++----- 2 files changed, 68 insertions(+), 31 deletions(-) diff --git a/specs/tmp/FIX_PLAN.md b/specs/tmp/FIX_PLAN.md index de98f11..a30baca 100644 --- a/specs/tmp/FIX_PLAN.md +++ b/specs/tmp/FIX_PLAN.md @@ -65,23 +65,6 @@ fail, but only as diagnosed product failures (H-8) — never as harness errors. ## Stage D — remaining suite gaps, in TEST-SPEC section order -### Task 40 — T12.0-10: missing required flag/argument rows in the syntax class (no configuration load) - -Cites: TEST-SPEC T12.0-10 (§12.0 revised: within class 2 the rows include "a -missing required flag or argument" — `review create --name n` with none of -`--base`/`--strategy audit`/`--coverage`, and `at <file>` alone — each reported -as a usage error without loading configuration); SPEC 12.0. - -Now: `test/suite/registry/section-12.0-ii.ts` `syntaxRows` (≈ lines -1920–1940) hold only unknown command, repeated flag, and `show a#b#c`. - -Do: add the two rows to `syntaxRows` so they run under the same -no-configuration-load proof the existing rows use (a deliberately broken -configuration that must not be reported): exit 2, 12.7 error document, nothing -modified. - -Verify: `npx vitest run … test/suite/section-12.0-ii.test.ts`. - ### Task 41 — T12.7-1: `unavailable`-marker exclusivity walk on every captured JSON document; unpinned-surface range arms Cites: TEST-SPEC T12.7-1 and the §11 preamble (the marker-exclusivity walk diff --git a/test/suite/registry/section-12.0-ii.ts b/test/suite/registry/section-12.0-ii.ts index d69732b..b148e0d 100644 --- a/test/suite/registry/section-12.0-ii.ts +++ b/test/suite/registry/section-12.0-ii.ts @@ -1673,10 +1673,18 @@ async function expectUsageErrorDocument( // contains it; harness-prefixed so a collision is impossible by staging). const PRECEDENCE_NO_SUCH_ITEM = "xspec-harness-no-such-item"; +// The within-class-2 rows' staging: the session name `review create --name n` +// spells and the `<file>` the lone-operand `at` row names, each staged beside +// the invalid configuration so the syntax-alone check is seen to precede what +// the same command would consult next (SPEC 12.0, 10.7, 11.5). +const SYNTAX_SESSION_NAME = "n"; +const SYNTAX_SESSION_REL = `.xspec/reviews/${SYNTAX_SESSION_NAME}.json`; +const SYNTAX_AT_FILE = "specs/A.mdx"; + const T12_0_10 = defineProductTest({ id: "T12.0-10", title: - "argument-check precedence: the rename/move and baseline arms ride on T6.4-4/T6.5-5/T6.3-4; on one workspace failing `build`'s validations each gated read given a usage-error argument exits 2 with that error and reports no validation findings (the exit-2 stdout is exactly the one 12.7 error document) — `coverage <unknown-profile>`, `query nodes --group <code-group>`, `review status <unknown-session>`, `show <file>#<unspelled-id>`, `query node <code-source-path>`, `query edges --from <code-source-path>#<unspelled-unit>` — each check judged from what it consults (configuration; the session directory; parse-local spelled identities or named units of the named file), the same names on a valid twin workspace giving the same exit-2 errors (byte-identical error documents); masking: `show <unparseable-file>#<id>` on the failing workspace yields the gated report of 13.3, exit 1, carrying exactly the workspace's findings; past the gate: on a passing workspace `review resolve <corrupt-session> <any-item-id> --status updated` reports the corruption, exit 1 — the item ID judged only against session content, which the corruption withholds (the same unknown item ID in the well-formed session exits 2 as the pre-corruption premise); within class 2: an unknown command, a repeated flag, and the malformed value `show a#b#c` are reported without loading configuration — byte-identical error documents with the configuration file invalid or missing, each the plain usage error (`code` and `path` null) — while a configuration error precedes every check that consults configuration: `coverage <unknown-profile>` with invalid configuration reports 14.14 (`configuration-error`), not the unknown profile (SPEC 12.0, 13.3, 11.1, 11.2, 4.6, 10.1, 14.14, 14.20, 14.21, 12.7)", + "argument-check precedence: the rename/move and baseline arms ride on T6.4-4/T6.5-5/T6.3-4; on one workspace failing `build`'s validations each gated read given a usage-error argument exits 2 with that error and reports no validation findings (the exit-2 stdout is exactly the one 12.7 error document) — `coverage <unknown-profile>`, `query nodes --group <code-group>`, `review status <unknown-session>`, `show <file>#<unspelled-id>`, `query node <code-source-path>`, `query edges --from <code-source-path>#<unspelled-unit>` — each check judged from what it consults (configuration; the session directory; parse-local spelled identities or named units of the named file), the same names on a valid twin workspace giving the same exit-2 errors (byte-identical error documents); masking: `show <unparseable-file>#<id>` on the failing workspace yields the gated report of 13.3, exit 1, carrying exactly the workspace's findings; past the gate: on a passing workspace `review resolve <corrupt-session> <any-item-id> --status updated` reports the corruption, exit 1 — the item ID judged only against session content, which the corruption withholds (the same unknown item ID in the well-formed session exits 2 as the pre-corruption premise); within class 2: an unknown command, a repeated flag, a missing required flag (`review create --name n` with none of `--base`, `--strategy audit`, or `--coverage`) or argument (`at <file>` alone), and the malformed value `show a#b#c` are reported without loading configuration and modify nothing — byte-identical error documents with the configuration file invalid or missing, each the plain usage error (`code` and `path` null), the syntax-alone check preceding what the command would consult next (a session already named `n` and the named `<file>`, staged beside the invalid configuration, go unconsulted) — while a configuration error precedes every check that consults configuration: `coverage <unknown-profile>` with invalid configuration reports 14.14 (`configuration-error`), not the unknown profile (SPEC 12.0, 13.3, 11.1, 11.2, 4.6, 10.1, 10.7, 11.5, 14.14, 14.20, 14.21, 12.7)", timeoutMs: 240_000, run: async (product) => { // --- Gated reads: usage-error arguments precede the 13.3 gate, judged @@ -1915,6 +1923,17 @@ export default defineConfig({ bogus: true }) `, + // What the missing-flag and missing-argument rows would consult + // next, staged so a product judging it before the syntax check is + // observed: a session already named `n` — SPEC 10.7 refuses + // `review create` with an existing session's name, exit 1; a + // conforming invocation never reads it, so any bytes serve — and + // the `at` row's `<file>`, present here and absent from the + // missing-configuration workspace, so a product judging the file + // before the argument count answers the two states differently + // and fails the byte-identical compare below. + [SYNTAX_SESSION_REL]: "xspec-harness pre-existing session\n", + [SYNTAX_AT_FILE]: '<S id="alpha">\nAlpha text.\n</S>\n', }, }, async (invalidConfig) => { @@ -1931,6 +1950,25 @@ export default defineConfig({ what: "a repeated flag", argv: ["ids", "--json", "--json"], }, + { + what: + "a missing required flag (`review create --name n` with " + + "none of `--base`, `--strategy audit`, or `--coverage`, " + + "SPEC 10.7)", + argv: [ + "review", + "create", + "--name", + SYNTAX_SESSION_NAME, + "--json", + ], + }, + { + what: + "a missing required argument (`at <file>` alone, its " + + "`<offset>` absent, SPEC 11.5)", + argv: ["at", SYNTAX_AT_FILE, "--json"], + }, { what: "the malformed multi-`#` value (T12.0-13's spelling)", argv: ["show", "a#b#c", "--json"], @@ -1938,22 +1976,38 @@ export default defineConfig({ ]; for (const row of syntaxRows) { const command = row.argv.join(" "); - const onInvalid = await expectUsageErrorDocument( - product, - invalidConfig, - row.argv, + // An error the syntax alone determines is reported before + // anything else, so the invocation modifies nothing — the + // mutating `review create` included, whose session directory + // already holds `n` (whole-root compare, `.xspec/` included). + const onInvalid = await assertLeavesUnchanged( + invalidConfig.root, + () => + expectUsageErrorDocument( + product, + invalidConfig, + row.argv, + `T12.0-10 \`${command}\` with the configuration file ` + + `invalid — ${row.what} is determined by the ` + + `invocation's syntax alone and reported without ` + + `loading configuration (SPEC 12.0)`, + ), `T12.0-10 \`${command}\` with the configuration file invalid ` + - `— ${row.what} is determined by the invocation's syntax ` + - `alone and reported without loading configuration ` + - `(SPEC 12.0)`, + `— a syntax-alone usage error modifies nothing (SPEC 12.0)`, ); - const onMissing = await expectUsageErrorDocument( - product, - missingConfig, - row.argv, + const onMissing = await assertLeavesUnchanged( + missingConfig.root, + () => + expectUsageErrorDocument( + product, + missingConfig, + row.argv, + `T12.0-10 \`${command}\` with the configuration file ` + + `missing — ${row.what} is reported without loading ` + + `configuration (SPEC 12.0)`, + ), `T12.0-10 \`${command}\` with the configuration file missing ` + - `— ${row.what} is reported without loading configuration ` + - `(SPEC 12.0)`, + `— a syntax-alone usage error modifies nothing (SPEC 12.0)`, ); for (const [error, state] of [ [onInvalid.error, "invalid"],